mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 09:31:31 +08:00
Compare commits
1 Commits
dependabot
...
hermes-e2e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4312a9cc4e |
5
.github/workflows/contributor-check.yml
vendored
5
.github/workflows/contributor-check.yml
vendored
@@ -1,11 +1,12 @@
|
||||
name: Contributor Attribution Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
99
.github/workflows/deploy-site.yml
vendored
99
.github/workflows/deploy-site.yml
vendored
@@ -11,20 +11,8 @@ on:
|
||||
- 'optional-skills/**'
|
||||
- '.github/workflows/deploy-site.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skills_index_run_id:
|
||||
description: 'Optional Build Skills Index run ID whose skills-index artifact should be deployed'
|
||||
required: false
|
||||
type: string
|
||||
rebuild_skills_index:
|
||||
description: 'Force a fresh multi-source crawl instead of reusing the latest healthy index'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
@@ -67,81 +55,26 @@ jobs:
|
||||
- name: Install PyYAML for skill extraction
|
||||
run: pip install pyyaml==6.0.2 httpx==0.28.1
|
||||
|
||||
- name: Prepare skills index (unified multi-source catalog)
|
||||
- name: Build skills index (unified multi-source catalog)
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }}
|
||||
REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# The unified external catalog is expensive to crawl and can burn
|
||||
# through the repository installation's GitHub API quota when several
|
||||
# docs deploys land close together. Normal docs deploys therefore
|
||||
# reuse the latest healthy catalog: first the artifact from a
|
||||
# scheduled skills-index run, then the currently live index. Only a
|
||||
# manual force rebuild does a fresh crawl here.
|
||||
# Rebuild the unified catalog. The file is gitignored, so a fresh
|
||||
# checkout starts without it and we want the freshest crawl in
|
||||
# every deploy.
|
||||
#
|
||||
# If we do crawl, the build remains fatal. build_skills_index.py runs
|
||||
# the health check BEFORE writing and exits non-zero on source
|
||||
# collapse, keeping the last good Pages deployment live instead of
|
||||
# publishing a degenerate catalog.
|
||||
set -euo pipefail
|
||||
INDEX_PATH="website/static/api/skills-index.json"
|
||||
mkdir -p "$(dirname "$INDEX_PATH")"
|
||||
|
||||
validate_index() {
|
||||
python3 - "$INDEX_PATH" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
print(f"invalid skills index JSON: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
skills = data.get("skills")
|
||||
if not isinstance(skills, list) or len(skills) < 1500:
|
||||
count = len(skills) if isinstance(skills, list) else "missing"
|
||||
print(f"skills index too small: {count}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"skills index ready: {len(skills)} skills")
|
||||
PY
|
||||
}
|
||||
|
||||
if [ "$REBUILD_SKILLS_INDEX" = "true" ]; then
|
||||
python3 scripts/build_skills_index.py
|
||||
validate_index
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "$SKILLS_INDEX_RUN_ID" ]; then
|
||||
tmpdir="$(mktemp -d)"
|
||||
echo "Downloading skills-index artifact from run $SKILLS_INDEX_RUN_ID"
|
||||
if gh run download "$SKILLS_INDEX_RUN_ID" --name skills-index --dir "$tmpdir"; then
|
||||
candidate="$(find "$tmpdir" -name skills-index.json -type f | head -n 1 || true)"
|
||||
if [ -n "$candidate" ]; then
|
||||
cp "$candidate" "$INDEX_PATH"
|
||||
if validate_index; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "::warning::Could not use skills-index artifact from run $SKILLS_INDEX_RUN_ID; trying live index"
|
||||
fi
|
||||
|
||||
echo "Downloading currently live skills index"
|
||||
if curl -fsSL --retry 3 --retry-delay 5 \
|
||||
"https://hermes-agent.nousresearch.com/docs/api/skills-index.json" \
|
||||
-o "$INDEX_PATH" && validate_index; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::warning::Live skills index unavailable or unhealthy; falling back to a fresh crawl"
|
||||
rm -f "$INDEX_PATH"
|
||||
# This MUST be fatal. build_skills_index.py runs a health check and
|
||||
# exits non-zero WITHOUT writing the output file when a source
|
||||
# collapses (e.g. a GitHub API rate limit zeroes the github /
|
||||
# claude-marketplace / well-known taps all at once). Letting the
|
||||
# deploy continue would either (a) ship a degenerate index missing
|
||||
# whole hubs — the June 2026 regression where OpenAI/Anthropic/
|
||||
# HuggingFace/NVIDIA tabs vanished — or (b) fall through to a
|
||||
# local-only catalog. Failing here keeps the last good deployment
|
||||
# live (GitHub Pages serves the previous build) instead of
|
||||
# publishing a broken catalog. Re-run the workflow once the
|
||||
# transient rate limit clears.
|
||||
python3 scripts/build_skills_index.py
|
||||
validate_index
|
||||
|
||||
- name: Extract skill metadata for dashboard
|
||||
run: python3 website/scripts/extract-skills.py
|
||||
|
||||
9
.github/workflows/docker-lint.yml
vendored
9
.github/workflows/docker-lint.yml
vendored
@@ -18,12 +18,13 @@ on:
|
||||
- docker/**
|
||||
- .hadolint.yaml
|
||||
- .github/workflows/docker-lint.yml
|
||||
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- Dockerfile
|
||||
- docker/**
|
||||
- .hadolint.yaml
|
||||
- .github/workflows/docker-lint.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
17
.github/workflows/docker-publish.yml
vendored
17
.github/workflows/docker-publish.yml
vendored
@@ -11,13 +11,16 @@ on:
|
||||
- 'docker/**'
|
||||
- '.github/workflows/docker-publish.yml'
|
||||
- '.github/actions/hermes-smoke-test/**'
|
||||
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
paths:
|
||||
- '**/*.py'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- 'Dockerfile'
|
||||
- 'docker/**'
|
||||
- '.github/workflows/docker-publish.yml'
|
||||
- '.github/actions/hermes-smoke-test/**'
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
@@ -87,7 +90,7 @@ jobs:
|
||||
# (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`` — guaranteed to
|
||||
# ``docker build`` under a 180s pytest-timeout cap — guaranteed to
|
||||
# die in fixture setup.
|
||||
#
|
||||
# Piggybacking here avoids a second image build: the smoke test
|
||||
@@ -111,7 +114,7 @@ jobs:
|
||||
run: |
|
||||
uv venv .venv --python 3.11
|
||||
source .venv/bin/activate
|
||||
# ``dev`` extra pulls in pytest, pytest-asyncio —
|
||||
# ``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.
|
||||
|
||||
16
.github/workflows/docs-site-checks.yml
vendored
16
.github/workflows/docs-site-checks.yml
vendored
@@ -1,12 +1,10 @@
|
||||
name: Docs Site Checks
|
||||
|
||||
on:
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
paths:
|
||||
- 'website/**'
|
||||
- '.github/workflows/docs-site-checks.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -16,9 +14,9 @@ jobs:
|
||||
docs-site-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -28,9 +26,9 @@ jobs:
|
||||
run: npm ci
|
||||
working-directory: website
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install ascii-guard
|
||||
run: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3
|
||||
|
||||
49
.github/workflows/e2e-cli-install.yml
vendored
Normal file
49
.github/workflows/e2e-cli-install.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: E2E CLI Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e-tui-test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: cd e2e && CI=true npm run test
|
||||
env:
|
||||
# Ensure tests don't accidentally call real APIs
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
- name: Bundle TUI traces into self-contained replay HTML
|
||||
if: always()
|
||||
run: node e2e/scripts/bundle-replay-html.mjs
|
||||
|
||||
- name: Upload TUI replay viewer
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: tui-replay-viewer
|
||||
path: tui-replay-viewer/
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload raw TUI test traces
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: tui-test-traces
|
||||
path: e2e/tui-traces/
|
||||
retention-days: 7
|
||||
7
.github/workflows/history-check.yml
vendored
7
.github/workflows/history-check.yml
vendored
@@ -14,9 +14,6 @@ name: History Check
|
||||
# the PR head and main to be non-empty.
|
||||
|
||||
on:
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
@@ -27,9 +24,9 @@ jobs:
|
||||
check-common-ancestor:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # full history both sides for merge-base
|
||||
fetch-depth: 0 # full history both sides for merge-base
|
||||
|
||||
- name: Reject PRs with no common ancestor on main
|
||||
run: |
|
||||
|
||||
9
.github/workflows/lint.yml
vendored
9
.github/workflows/lint.yml
vendored
@@ -15,12 +15,12 @@ on:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- "website/**"
|
||||
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- "website/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -154,6 +154,7 @@ jobs:
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
ruff-blocking:
|
||||
# Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently
|
||||
# PLW1514 (unspecified-encoding) — catches bare ``open()`` /
|
||||
|
||||
255
.github/workflows/nix-lockfile-fix.yml
vendored
Normal file
255
.github/workflows/nix-lockfile-fix.yml
vendored
Normal file
@@ -0,0 +1,255 @@
|
||||
name: Nix Lockfile Fix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'package-lock.json'
|
||||
- 'package.json'
|
||||
- 'ui-tui/package.json'
|
||||
- 'apps/desktop/package.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to fix (leave empty to run on the selected branch)'
|
||||
required: false
|
||||
type: string
|
||||
issue_comment:
|
||||
types: [edited]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: nix-lockfile-fix-${{ github.event.issue.number || github.event.inputs.pr_number || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# ── Auto-fix on main ───────────────────────────────────────────────
|
||||
# Fires when a push to main touches package.json or package-lock.json.
|
||||
# Runs fix-lockfiles and pushes the hash update commit directly to main
|
||||
# so Nix builds never stay broken.
|
||||
#
|
||||
# Safety invariants:
|
||||
# 1. The fix commit only touches nix/*.nix files, which are NOT in
|
||||
# the paths filter above, so this cannot re-trigger itself.
|
||||
# 2. An explicit file-whitelist check before commit aborts if
|
||||
# fix-lockfiles ever modifies unexpected files.
|
||||
# 3. Job-level concurrency with cancel-in-progress: true ensures
|
||||
# back-to-back pushes collapse to the newest; ref: main checkout
|
||||
# always operates on the latest branch state.
|
||||
# 4. Uses a GitHub App token (not GITHUB_TOKEN) so the fix commit
|
||||
# triggers downstream nix.yml verification.
|
||||
auto-fix-main:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
concurrency:
|
||||
group: auto-fix-main
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@7bfa3a4717ef143a604ee0a99d859b8886a96d00 # v1.9.3
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Apply lockfile hashes
|
||||
id: apply
|
||||
run: nix run .#fix-lockfiles -- --apply
|
||||
|
||||
- name: Commit & push
|
||||
if: steps.apply.outputs.changed == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Ensure only nix/lib.nix (home of the single npmDepsHash) was
|
||||
# modified — prevents accidental self-triggering if fix-lockfiles
|
||||
# ever touches package files.
|
||||
unexpected="$(git diff --name-only | grep -Ev '^nix/lib\.nix$' || true)"
|
||||
if [ -n "$unexpected" ]; then
|
||||
echo "::error::Unexpected modified files: $unexpected"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Record the base SHA before committing — used to detect package
|
||||
# file changes if we need to rebase after a non-fast-forward push.
|
||||
BASE_SHA="$(git rev-parse HEAD)"
|
||||
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git add nix/lib.nix
|
||||
git commit -m "fix(nix): auto-refresh npm lockfile hashes" \
|
||||
-m "Source: $GITHUB_SHA" \
|
||||
-m "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
|
||||
|
||||
# Retry push with rebase in case main advanced with an unrelated
|
||||
# commit during the nix build. Without this, a non-fast-forward
|
||||
# rejection silently loses the fix. If package files changed during
|
||||
# the rebase, abort — a fresh auto-fix run will handle the new state.
|
||||
for attempt in 1 2 3; do
|
||||
if git push origin HEAD:main; then
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::Push attempt $attempt failed (non-fast-forward?), rebasing…"
|
||||
git fetch origin main
|
||||
|
||||
# If package files changed between our base and the new main,
|
||||
# our computed hashes are stale. Abort and let the next triggered
|
||||
# run recompute from the correct package-lock state.
|
||||
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
|
||||
'package-lock.json' 'package.json' \
|
||||
'ui-tui/package.json' 'apps/desktop/package.json' || true)"
|
||||
if [ -n "$pkg_changed" ]; then
|
||||
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git rebase origin/main
|
||||
done
|
||||
echo "::error::Failed to push after 3 rebase attempts"
|
||||
exit 1
|
||||
|
||||
# ── PR fix (manual / checkbox) ─────────────────────────────────────
|
||||
# Existing behavior: run on manual dispatch OR when a task-list
|
||||
# checkbox in the sticky lockfile-check comment flips from [ ] to [x].
|
||||
fix:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issue_comment'
|
||||
&& github.event.issue.pull_request != null
|
||||
&& contains(github.event.comment.body, '[x] **Apply lockfile fix**')
|
||||
&& !contains(github.event.changes.body.from, '[x] **Apply lockfile fix**'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Authorize & resolve PR
|
||||
id: resolve
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
// 1. Verify the actor has write access — applies to both checkbox
|
||||
// clicks and manual dispatch.
|
||||
const { data: perm } =
|
||||
await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: context.actor,
|
||||
});
|
||||
if (!['admin', 'write', 'maintain'].includes(perm.permission)) {
|
||||
core.setFailed(
|
||||
`${context.actor} lacks write access (has: ${perm.permission})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Resolve which ref to check out.
|
||||
let prNumber = '';
|
||||
if (context.eventName === 'issue_comment') {
|
||||
prNumber = String(context.payload.issue.number);
|
||||
} else if (context.eventName === 'workflow_dispatch') {
|
||||
prNumber = context.payload.inputs.pr_number || '';
|
||||
}
|
||||
|
||||
if (!prNumber) {
|
||||
core.setOutput('ref', context.ref.replace(/^refs\/heads\//, ''));
|
||||
core.setOutput('repo', context.repo.repo);
|
||||
core.setOutput('owner', context.repo.owner);
|
||||
core.setOutput('pr', '');
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: Number(prNumber),
|
||||
});
|
||||
core.setOutput('ref', pr.head.ref);
|
||||
core.setOutput('repo', pr.head.repo.name);
|
||||
core.setOutput('owner', pr.head.repo.owner.login);
|
||||
core.setOutput('pr', String(pr.number));
|
||||
|
||||
# Wipe the sticky lockfile-check comment to a "running" state as soon
|
||||
# as the job is authorized, so the user sees their click was picked up
|
||||
# before the ~minute of nix build work.
|
||||
- name: Mark sticky as running
|
||||
if: steps.resolve.outputs.pr != ''
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
number: ${{ steps.resolve.outputs.pr }}
|
||||
message: |
|
||||
### 🔄 Applying lockfile fix…
|
||||
|
||||
Triggered by @${{ github.actor }} — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: ${{ steps.resolve.outputs.owner }}/${{ steps.resolve.outputs.repo }}
|
||||
ref: ${{ steps.resolve.outputs.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Apply lockfile hashes
|
||||
id: apply
|
||||
run: nix run .#fix-lockfiles
|
||||
|
||||
- name: Commit & push
|
||||
if: steps.apply.outputs.changed == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git add nix/lib.nix
|
||||
git commit -m "fix(nix): refresh npm lockfile hashes"
|
||||
git push
|
||||
|
||||
- name: Update sticky (applied)
|
||||
if: steps.apply.outputs.changed == 'true' && steps.resolve.outputs.pr != ''
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
number: ${{ steps.resolve.outputs.pr }}
|
||||
message: |
|
||||
### ✅ Lockfile fix applied
|
||||
|
||||
Pushed a commit refreshing the npm lockfile hashes — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
|
||||
- name: Update sticky (already current)
|
||||
if: steps.apply.outputs.changed == 'false' && steps.resolve.outputs.pr != ''
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
number: ${{ steps.resolve.outputs.pr }}
|
||||
message: |
|
||||
### ✅ Lockfile hashes already current
|
||||
|
||||
Nothing to commit — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
|
||||
- name: Update sticky (failed)
|
||||
if: failure() && steps.resolve.outputs.pr != ''
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
number: ${{ steps.resolve.outputs.pr }}
|
||||
message: |
|
||||
### ❌ Lockfile fix failed
|
||||
|
||||
See the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for logs.
|
||||
105
.github/workflows/nix.yml
vendored
Normal file
105
.github/workflows/nix.yml
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Resolve head SHA
|
||||
if: github.event_name == 'pull_request'
|
||||
id: sha
|
||||
shell: bash
|
||||
run: |
|
||||
FULL="${{ github.event.pull_request.head.sha || github.sha }}"
|
||||
echo "full=$FULL" >> "$GITHUB_OUTPUT"
|
||||
echo "short=${FULL:0:7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check flake
|
||||
id: flake
|
||||
continue-on-error: true
|
||||
run: nix flake check --print-build-logs
|
||||
|
||||
# When the flake check fails, run a targeted diagnostic to see if
|
||||
# the failure is specifically a stale npm lockfile hash in one of the
|
||||
# known npm subpackages (tui / web). This avoids surfacing a generic
|
||||
# "build failed" message when the fix is a single known command.
|
||||
- name: Diagnose npm lockfile hashes
|
||||
id: hash_check
|
||||
if: steps.flake.outcome == 'failure' && runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
env:
|
||||
LINK_SHA: ${{ steps.sha.outputs.full }}
|
||||
run: nix run .#fix-lockfiles -- --check
|
||||
|
||||
# If fix-lockfiles itself crashes (infrastructure blip, cache throttle,
|
||||
# etc.) it won't set stale=true/false. Treat that as a distinct failure
|
||||
# mode rather than silently ignoring it.
|
||||
- name: Fail if hash check crashed without reporting
|
||||
if: steps.hash_check.outcome == 'failure' && steps.hash_check.outputs.stale != 'true' && steps.hash_check.outputs.stale != 'false'
|
||||
run: |
|
||||
echo "::error::fix-lockfiles exited without reporting stale status — likely an infrastructure or script failure"
|
||||
exit 1
|
||||
|
||||
- name: Post sticky PR comment (stale hashes)
|
||||
if: steps.hash_check.outputs.stale == 'true' && github.event_name == 'pull_request'
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
message: |
|
||||
### ⚠️ npm lockfile hash out of date
|
||||
|
||||
Checked against commit [`${{ steps.sha.outputs.short }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ steps.sha.outputs.full }}) (PR head at check time).
|
||||
|
||||
The `hash = "sha256-..."` line in these nix files no longer matches the committed `package-lock.json`:
|
||||
|
||||
${{ steps.hash_check.outputs.report }}
|
||||
|
||||
#### Apply the fix
|
||||
|
||||
- [ ] **Apply lockfile fix** — tick to push a commit with the correct hashes to this PR branch
|
||||
- Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`)
|
||||
- Or locally: `nix run .#fix-lockfiles` and commit the diff
|
||||
|
||||
# Clear the sticky comment when either the flake check passed outright (no
|
||||
# hash check needed) or the hash check explicitly returned stale=false
|
||||
# (check failed for a non-hash reason).
|
||||
- name: Clear sticky PR comment (resolved)
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
(steps.hash_check.outputs.stale == 'false' ||
|
||||
steps.flake.outcome == 'success')
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
delete: true
|
||||
|
||||
- name: Final fail if flake check failed
|
||||
if: steps.flake.outcome == 'failure'
|
||||
run: |
|
||||
if [ "${{ steps.hash_check.outputs.stale }}" == "true" ]; then
|
||||
echo "::error::Nix build failed due to stale npm lockfile hash. Run: nix run .#fix-lockfiles"
|
||||
else
|
||||
echo "::error::Nix flake check failed. See logs above."
|
||||
fi
|
||||
exit 1
|
||||
26
.github/workflows/osv-scanner.yml
vendored
26
.github/workflows/osv-scanner.yml
vendored
@@ -20,23 +20,29 @@ name: OSV-Scanner
|
||||
# vulnerabilities in pinned deps that we may need to patch deliberately.
|
||||
|
||||
on:
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'uv.lock'
|
||||
- 'pyproject.toml'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'ui-tui/package.json'
|
||||
- 'website/package.json'
|
||||
- 'website/package-lock.json'
|
||||
- '.github/workflows/osv-scanner.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "uv.lock"
|
||||
- "pyproject.toml"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "website/package-lock.json"
|
||||
- 'uv.lock'
|
||||
- 'pyproject.toml'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'website/package-lock.json'
|
||||
schedule:
|
||||
# Weekly scan against main — catches CVEs published after merge for
|
||||
# deps that haven't changed since.
|
||||
- cron: "0 9 * * 1"
|
||||
- cron: '0 9 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -48,7 +54,7 @@ permissions:
|
||||
jobs:
|
||||
scan:
|
||||
name: Scan lockfiles
|
||||
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
|
||||
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
|
||||
with:
|
||||
# Scan explicit lockfiles rather than recursing, so we only look at
|
||||
# the three sources of truth and skip vendored / test / worktree dirs.
|
||||
|
||||
2
.github/workflows/skills-index.yml
vendored
2
.github/workflows/skills-index.yml
vendored
@@ -53,4 +53,4 @@ jobs:
|
||||
- name: Trigger Deploy Site workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}
|
||||
run: gh workflow run deploy-site.yml --repo ${{ github.repository }}
|
||||
|
||||
67
.github/workflows/supply-chain-audit.yml
vendored
67
.github/workflows/supply-chain-audit.yml
vendored
@@ -1,11 +1,11 @@
|
||||
name: Supply Chain Audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
# No paths filter — the jobs must always run so required checks
|
||||
# report a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -29,10 +29,8 @@ jobs:
|
||||
scan: ${{ steps.filter.outputs.scan }}
|
||||
# True when pyproject.toml changed in this PR
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
# True when the curated MCP catalog / bundled MCP manifests changed.
|
||||
mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check for relevant file changes
|
||||
@@ -56,14 +54,6 @@ jobs:
|
||||
else
|
||||
echo "deps=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
MCP_CATALOG_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
|
||||
'optional-mcps/**' \
|
||||
'hermes_cli/mcp_catalog.py' || true)
|
||||
if [ -n "$MCP_CATALOG_FILES" ]; then
|
||||
echo "mcp_catalog=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "mcp_catalog=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
scan:
|
||||
name: Scan PR for critical supply chain risks
|
||||
@@ -72,7 +62,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -207,7 +197,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -278,50 +268,3 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No pyproject.toml changes, skipping dependency bounds check."
|
||||
|
||||
mcp-catalog-review:
|
||||
name: MCP catalog security review
|
||||
needs: changes
|
||||
if: needs.changes.outputs.mcp_catalog == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Require explicit MCP catalog review label
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
|
||||
if echo "$LABELS" | grep -Fxq 'mcp-catalog-reviewed'; then
|
||||
echo "MCP catalog review label present."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BODY="## ⚠️ MCP catalog security review required
|
||||
|
||||
This PR changes the bundled MCP catalog or MCP catalog installer code. MCP entries can define local commands that users later install into \`mcp_servers\`, so this needs explicit maintainer review before merge.
|
||||
|
||||
A maintainer should verify:
|
||||
- any new/changed \`optional-mcps/**/manifest.yaml\` command and args are expected,
|
||||
- stdio transports do not use shell+egress/exfiltration payloads,
|
||||
- git install refs are pinned and bootstrap commands are minimal,
|
||||
- requested env vars/secrets match the upstream MCP's documented needs.
|
||||
|
||||
After review, add the \`mcp-catalog-reviewed\` label and re-run this check."
|
||||
|
||||
gh pr comment "$PR" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
|
||||
echo "::error::MCP catalog changes require the mcp-catalog-reviewed label."
|
||||
exit 1
|
||||
|
||||
mcp-catalog-review-gate:
|
||||
name: MCP catalog security review
|
||||
needs: changes
|
||||
if: always() && needs.changes.outputs.mcp_catalog != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No MCP catalog changes, skipping MCP catalog security review."
|
||||
|
||||
38
.github/workflows/tests.yml
vendored
38
.github/workflows/tests.yml
vendored
@@ -4,13 +4,13 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- '**/*.md'
|
||||
- 'docs/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -30,17 +30,13 @@ jobs:
|
||||
slice: [1, 2, 3, 4, 5, 6]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore duration cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
# main always writes a new suffix, but jobs pick the latest one with the same prefix
|
||||
# quote from https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#cache-hits-and-misses
|
||||
# If you provide restore-keys, the cache action sequentially searches for any caches that match the list of restore-keys.
|
||||
# If there are no exact matches, the action searches for partial matches of the restore keys.
|
||||
# When the action finds a partial match, the most recent cache is restored to the path directory.
|
||||
# Single stable key. main always overwrites, PRs always find it.
|
||||
key: test-durations
|
||||
|
||||
- name: Install ripgrep (prebuilt binary)
|
||||
@@ -58,7 +54,7 @@ jobs:
|
||||
rg --version
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
with:
|
||||
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||||
# Keyed on the dependency manifests, so the cache is reused until
|
||||
@@ -119,7 +115,7 @@ jobs:
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
- name: Upload per-slice durations
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-durations-slice-${{ matrix.slice }}
|
||||
path: test_durations.json
|
||||
@@ -129,11 +125,11 @@ jobs:
|
||||
# (including PRs) get balanced slicing.
|
||||
save-durations:
|
||||
needs: test
|
||||
if: needs.test.result == 'success' && github.ref == 'refs/heads/main'
|
||||
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
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: test-durations-slice-*
|
||||
path: durations
|
||||
@@ -153,17 +149,17 @@ jobs:
|
||||
"
|
||||
|
||||
- name: Save merged duration cache
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: test_durations.json
|
||||
key: test-durations-${{ github.run_id }}
|
||||
key: test-durations
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install ripgrep (prebuilt binary)
|
||||
run: |
|
||||
@@ -180,7 +176,7 @@ jobs:
|
||||
rg --version
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
with:
|
||||
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
|
||||
# Keyed on the dependency manifests, so the cache is reused until
|
||||
@@ -219,4 +215,4 @@ jobs:
|
||||
env:
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
20
.github/workflows/typecheck.yml
vendored
20
.github/workflows/typecheck.yml
vendored
@@ -4,9 +4,6 @@ name: Typecheck
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
@@ -26,20 +23,3 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run --prefix ${{ matrix.package }} typecheck
|
||||
|
||||
# Production build of the desktop renderer. `typecheck` runs `tsc` only,
|
||||
# which does NOT exercise Vite/Rolldown module resolution — so an
|
||||
# unresolvable package export (e.g. a transitive @assistant-ui/tap that no
|
||||
# longer exports "./react-shim") slips past typecheck and only explodes when
|
||||
# users build apps/desktop from source on install/update. Run the real
|
||||
# `vite build` here so that class of break fails in CI instead.
|
||||
desktop-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run --prefix apps/desktop build
|
||||
|
||||
18
.github/workflows/uv-lockfile-check.yml
vendored
18
.github/workflows/uv-lockfile-check.yml
vendored
@@ -47,15 +47,15 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- ".github/workflows/uv-lockfile-check.yml"
|
||||
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- '.github/workflows/uv-lockfile-check.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- '.github/workflows/uv-lockfile-check.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -71,10 +71,10 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
# `uv lock --check` re-resolves the project from pyproject.toml and
|
||||
# compares the result to uv.lock, exiting non-zero if they disagree.
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -19,6 +19,8 @@ __pycache__/
|
||||
.notebooklm-playwright/
|
||||
.pip-cache/
|
||||
.uv-cache/
|
||||
.tui-test/
|
||||
tui-traces/
|
||||
compose.hermes.local.yml
|
||||
export*
|
||||
__pycache__/model_tools.cpython-310.pyc
|
||||
@@ -132,7 +134,3 @@ scripts/out/
|
||||
# stores the published notes. They are not a build artifact and must never be
|
||||
# committed to the repo root. See the hermes-release skill.
|
||||
RELEASE_v*.md
|
||||
|
||||
# Desktop demo-run scratch output (hermes writes demo/*.txt during recorded
|
||||
# walkthroughs). Throwaway artifacts, never part of the app.
|
||||
apps/desktop/demo/
|
||||
|
||||
@@ -78,41 +78,7 @@ This isn't a quality bar — it's a coupling-and-maintenance decision. Memory pr
|
||||
| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) |
|
||||
| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) |
|
||||
|
||||
### Install with the standard installer
|
||||
|
||||
For most contributors, the best development bootstrap is the same path users
|
||||
take: run the standard installer, then work inside the repository it cloned.
|
||||
The installer creates the Hermes venv, wires the `hermes` command, stamps the
|
||||
install method for `hermes update`, and clones the full git project into
|
||||
`$HERMES_HOME/hermes-agent` (usually `~/.hermes/hermes-agent`). That keeps your
|
||||
development environment on the same layout the CLI, updater, lazy dependency
|
||||
installer, gateway, and docs assume.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
|
||||
|
||||
# Add dev/test extras on top of the standard install.
|
||||
uv pip install -e ".[all,dev]"
|
||||
|
||||
# Optional: browser tools / docs site dependencies.
|
||||
npm install
|
||||
```
|
||||
|
||||
After that, create branches and run tests from that checkout:
|
||||
|
||||
```bash
|
||||
git checkout -b fix/description
|
||||
scripts/run_tests.sh
|
||||
```
|
||||
|
||||
### Manual clone fallback
|
||||
|
||||
Use this only if you intentionally do not want Hermes' managed install layout
|
||||
(for example, a throwaway clone inside a container or CI job). If you install
|
||||
this way, make sure you run the `hermes` entrypoint from this venv; running the
|
||||
system `python3 -m hermes_cli.main` can pick up unrelated system Python
|
||||
packages.
|
||||
### Clone and install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
@@ -143,17 +109,13 @@ echo "OPENROUTER_API_KEY=***" >> ~/.hermes/.env
|
||||
### Run
|
||||
|
||||
```bash
|
||||
# The standard installer already put `hermes` on PATH.
|
||||
hermes doctor
|
||||
hermes chat -q "Hello"
|
||||
```
|
||||
|
||||
If you used the manual clone fallback, run `./hermes` from the checkout or
|
||||
symlink this clone's venv explicitly:
|
||||
|
||||
```bash
|
||||
# Symlink for global access
|
||||
mkdir -p ~/.local/bin
|
||||
ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
|
||||
|
||||
# Verify
|
||||
hermes doctor
|
||||
hermes chat -q "Hello"
|
||||
```
|
||||
|
||||
### Run tests
|
||||
|
||||
16
README.md
16
README.md
@@ -181,20 +181,16 @@ See `hermes claw migrate --help` for all options, or use the `openclaw-migration
|
||||
|
||||
We welcome contributions! See the [Contributing Guide](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) for development setup, code style, and PR process.
|
||||
|
||||
Quick start for contributors — use the standard installer, then work from the
|
||||
full git checkout it creates at `$HERMES_HOME/hermes-agent` (usually
|
||||
`~/.hermes/hermes-agent`). This matches the layout used by `hermes update`, the
|
||||
managed venv, lazy dependencies, gateway, and docs tooling.
|
||||
Quick start for contributors — clone and go with `setup-hermes.sh`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
|
||||
uv pip install -e ".[all,dev]"
|
||||
scripts/run_tests.sh
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
cd hermes-agent
|
||||
./setup-hermes.sh # installs uv, creates venv, installs .[all], symlinks ~/.local/bin/hermes
|
||||
./hermes # auto-detects the venv, no need to `source` first
|
||||
```
|
||||
|
||||
Manual clone fallback (for throwaway clones/CI where you intentionally do not
|
||||
want the managed install layout):
|
||||
Manual path (equivalent to the above):
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
@@ -164,18 +164,16 @@ hermes claw migrate --overwrite # 覆盖已有冲突
|
||||
|
||||
欢迎贡献!请参阅 [贡献指南](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) 了解开发设置、代码风格和 PR 流程。
|
||||
|
||||
贡献者快速开始——使用标准安装器,然后在它创建的完整 git checkout 中开发:
|
||||
`$HERMES_HOME/hermes-agent`(通常是 `~/.hermes/hermes-agent`)。这会匹配
|
||||
`hermes update`、托管 venv、lazy dependencies、gateway 和 docs tooling 使用的布局。
|
||||
贡献者快速开始——克隆并使用 `setup-hermes.sh`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
|
||||
uv pip install -e ".[all,dev]"
|
||||
scripts/run_tests.sh
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
cd hermes-agent
|
||||
./setup-hermes.sh # 安装 uv、创建 venv、安装 .[all]、创建符号链接 ~/.local/bin/hermes
|
||||
./hermes # 自动检测 venv,无需先 source
|
||||
```
|
||||
|
||||
手动克隆备用路径(用于一次性 clone / CI,或你明确不想使用 managed install layout 时):
|
||||
手动安装(等效于上述命令):
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
@@ -824,7 +824,6 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
try:
|
||||
from model_tools import get_tool_definitions
|
||||
from agent.memory_manager import inject_memory_provider_tools
|
||||
|
||||
enabled_toolsets = _expand_acp_enabled_toolsets(
|
||||
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"],
|
||||
@@ -840,7 +839,6 @@ class HermesACPAgent(acp.Agent):
|
||||
state.agent.valid_tool_names = {
|
||||
tool["function"]["name"] for tool in state.agent.tools or []
|
||||
}
|
||||
inject_memory_provider_tools(state.agent)
|
||||
invalidate = getattr(state.agent, "_invalidate_system_prompt", None)
|
||||
if callable(invalidate):
|
||||
invalidate()
|
||||
@@ -1781,25 +1779,10 @@ class HermesACPAgent(acp.Agent):
|
||||
def _cmd_tools(self, args: str, state: SessionState) -> str:
|
||||
try:
|
||||
from model_tools import get_tool_definitions
|
||||
from types import SimpleNamespace
|
||||
from agent.memory_manager import inject_memory_provider_tools
|
||||
|
||||
toolsets = _expand_acp_enabled_toolsets(
|
||||
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
|
||||
)
|
||||
tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True)
|
||||
tool_view = SimpleNamespace(
|
||||
tools=list(tools or []),
|
||||
valid_tool_names={
|
||||
tool.get("function", {}).get("name")
|
||||
for tool in tools or []
|
||||
if isinstance(tool, dict)
|
||||
},
|
||||
enabled_toolsets=toolsets,
|
||||
_memory_manager=getattr(state.agent, "_memory_manager", None),
|
||||
)
|
||||
inject_memory_provider_tools(tool_view)
|
||||
tools = tool_view.tools
|
||||
if not tools:
|
||||
return "No tools available."
|
||||
lines = [f"Available tools ({len(tools)}):"]
|
||||
|
||||
@@ -145,7 +145,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
|
||||
account info to show (fail-open: caller just shows nothing).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.nous_account import nous_portal_topup_url
|
||||
from hermes_cli.nous_account import nous_portal_billing_url
|
||||
|
||||
if account_info is None or not getattr(account_info, "logged_in", False):
|
||||
return None
|
||||
@@ -213,8 +213,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
|
||||
if not windows and not details:
|
||||
return None
|
||||
|
||||
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
|
||||
details.append("(or run /credits)")
|
||||
details.append(f"Manage / top up: {nous_portal_billing_url(account_info)}")
|
||||
|
||||
plan = getattr(sub, "plan", None) if sub is not None else None
|
||||
return AccountUsageSnapshot(
|
||||
@@ -338,93 +337,6 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
|
||||
return None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CreditsView:
|
||||
"""Surface-agnostic data for the ``/credits`` command.
|
||||
|
||||
One portal fetch, one parse — consumed identically by the CLI panel, the
|
||||
gateway button, and any other money surface. Fail-open: when not logged in
|
||||
or the portal is unreachable, ``logged_in`` is False / ``topup_url`` is None
|
||||
and callers degrade gracefully.
|
||||
"""
|
||||
|
||||
logged_in: bool
|
||||
balance_lines: tuple[str, ...] = ()
|
||||
identity_line: Optional[str] = None
|
||||
topup_url: Optional[str] = None
|
||||
depleted: bool = False
|
||||
|
||||
|
||||
def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
|
||||
"""Build the /credits view: balance block + identity line + top-up URL.
|
||||
|
||||
Reuses the same account fetch + snapshot + URL builder as the /usage credits
|
||||
block, so the numbers always match. The balance block is the rendered
|
||||
snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
|
||||
supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``.
|
||||
"""
|
||||
not_logged_in = CreditsView(logged_in=False)
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state
|
||||
|
||||
tok = (get_provider_auth_state("nous") or {}).get("access_token")
|
||||
if not (isinstance(tok, str) and tok.strip()):
|
||||
return not_logged_in
|
||||
except Exception:
|
||||
return not_logged_in
|
||||
|
||||
try:
|
||||
import concurrent.futures
|
||||
|
||||
from hermes_cli.nous_account import (
|
||||
get_nous_portal_account_info,
|
||||
nous_portal_topup_url,
|
||||
)
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(
|
||||
timeout=timeout
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
|
||||
return not_logged_in
|
||||
|
||||
if account is None or not getattr(account, "logged_in", False):
|
||||
return not_logged_in
|
||||
|
||||
snapshot = build_nous_credits_snapshot(account)
|
||||
# Balance lines = the snapshot block minus the two trailing affordance lines
|
||||
# ("Top up: <url>" + "(or run /credits)") that build_nous_credits_snapshot
|
||||
# appends for the /usage surface. /credits renders its own button/panel.
|
||||
balance_lines: list[str] = []
|
||||
if snapshot is not None:
|
||||
rendered = render_account_usage_lines(snapshot, markdown=markdown)
|
||||
balance_lines = [
|
||||
line
|
||||
for line in rendered
|
||||
if not line.lstrip().startswith("Top up:")
|
||||
and not line.lstrip().startswith("(or run")
|
||||
]
|
||||
|
||||
# Identity line — shown before any open (roadmap §4.4).
|
||||
email = getattr(account, "email", None)
|
||||
org_name = getattr(account, "org_name", None)
|
||||
who: list[str] = []
|
||||
if email:
|
||||
who.append(str(email))
|
||||
if org_name:
|
||||
who.append(f"org {org_name}")
|
||||
identity_line = ("Topping up as " + " / ".join(who)) if who else None
|
||||
|
||||
return CreditsView(
|
||||
logged_in=True,
|
||||
balance_lines=tuple(balance_lines),
|
||||
identity_line=identity_line,
|
||||
topup_url=nous_portal_topup_url(account),
|
||||
depleted=getattr(account, "paid_service_access", None) is False,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_codex_usage_url(base_url: str) -> str:
|
||||
normalized = (base_url or "").strip().rstrip("/")
|
||||
if not normalized:
|
||||
|
||||
@@ -27,7 +27,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlparse, parse_qs, urlunparse
|
||||
|
||||
from agent.context_compressor import ContextCompressor
|
||||
@@ -195,7 +195,6 @@ def init_agent(
|
||||
status_callback: callable = None,
|
||||
notice_callback: callable = None,
|
||||
notice_clear_callback: callable = None,
|
||||
event_callback: Optional[Callable[[str, dict], None]] = None,
|
||||
max_tokens: int = None,
|
||||
reasoning_config: Dict[str, Any] = None,
|
||||
service_tier: str = None,
|
||||
@@ -300,7 +299,6 @@ def init_agent(
|
||||
# would mangle the escape sequences. None = use builtins.print.
|
||||
agent._print_fn = None
|
||||
agent.background_review_callback = None # Optional sync callback for gateway delivery
|
||||
agent.memory_notifications = "on" # Memory update notifications: "off", "on", "verbose"
|
||||
agent.skip_context_files = skip_context_files
|
||||
agent.load_soul_identity = load_soul_identity
|
||||
agent.pass_session_id = pass_session_id
|
||||
@@ -427,7 +425,6 @@ def init_agent(
|
||||
agent.status_callback = status_callback
|
||||
agent.notice_callback = notice_callback
|
||||
agent.notice_clear_callback = notice_clear_callback
|
||||
agent.event_callback = event_callback
|
||||
agent.tool_gen_callback = tool_gen_callback
|
||||
|
||||
|
||||
@@ -599,7 +596,6 @@ def init_agent(
|
||||
# (e.g. CLI voice mode adds a temporary prefix for the live call only).
|
||||
agent._persist_user_message_idx = None
|
||||
agent._persist_user_message_override = None
|
||||
agent._persist_user_message_timestamp = None
|
||||
|
||||
# Cache anthropic image-to-text fallbacks per image payload/URL so a
|
||||
# single tool loop does not repeatedly re-run auxiliary vision on the
|
||||
@@ -904,9 +900,6 @@ def init_agent(
|
||||
agent.api_key = client_kwargs.get("api_key", "")
|
||||
agent.base_url = client_kwargs.get("base_url", agent.base_url)
|
||||
try:
|
||||
from agent.ssl_guard import verify_ca_bundle_with_fallback
|
||||
|
||||
verify_ca_bundle_with_fallback()
|
||||
agent.client = agent._create_openai_client(client_kwargs, reason="agent_init", shared=True)
|
||||
if not agent.quiet_mode:
|
||||
print(f"🤖 AI Agent initialized with model: {agent.model}")
|
||||
@@ -1200,8 +1193,38 @@ def init_agent(
|
||||
_ra().logger.warning("Memory provider plugin init failed: %s", _mpe)
|
||||
agent._memory_manager = None
|
||||
|
||||
from agent.memory_manager import inject_memory_provider_tools as _inject_memory_provider_tools
|
||||
_inject_memory_provider_tools(agent)
|
||||
# Inject memory provider tool schemas into the tool surface.
|
||||
# Skip tools whose names already exist (plugins may register the
|
||||
# same tools via ctx.register_tool(), which lands in agent.tools
|
||||
# through _ra().get_tool_definitions()). Duplicate function names cause
|
||||
# 400 errors on providers that enforce unique names (e.g. Xiaomi
|
||||
# MiMo via Nous Portal).
|
||||
#
|
||||
# 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
|
||||
if isinstance(t, dict)
|
||||
}
|
||||
for _schema in agent._memory_manager.get_all_tool_schemas():
|
||||
_tname = _schema.get("name", "")
|
||||
if _tname and _tname in _existing_tool_names:
|
||||
continue # already registered via plugin path
|
||||
_wrapped = {"type": "function", "function": _schema}
|
||||
agent.tools.append(_wrapped)
|
||||
if _tname:
|
||||
agent.valid_tool_names.add(_tname)
|
||||
_existing_tool_names.add(_tname)
|
||||
|
||||
# Skills config: nudge interval for skill creation reminders
|
||||
agent._skill_nudge_interval = 10
|
||||
|
||||
@@ -445,45 +445,6 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
|
||||
return repairs
|
||||
|
||||
|
||||
def repair_message_sequence_with_cursor(agent, messages: List[Dict]) -> int:
|
||||
"""Run :func:`repair_message_sequence` and keep the SessionDB flush
|
||||
cursor consistent with the compacted list (#44837).
|
||||
|
||||
``repair_message_sequence`` merges/drops messages in place, shrinking
|
||||
the list. ``_last_flushed_db_idx`` (the DB-write cursor) indexes into
|
||||
that list, so after compaction it can point past the new end — the
|
||||
turn-end flush would then skip the assistant/tool chain entirely — or
|
||||
past unflushed messages shifted to lower indexes.
|
||||
|
||||
Repair preserves object identity for surviving messages, so counting
|
||||
the survivors from the previously-flushed prefix gives the exact new
|
||||
cursor even when messages are dropped/merged at indexes *before* the
|
||||
cursor — a plain ``min()`` clamp would silently skip that many
|
||||
unflushed rows. Falls back to the clamp when no prefix snapshot is
|
||||
available.
|
||||
|
||||
Returns the number of repairs made (same as ``repair_message_sequence``).
|
||||
"""
|
||||
pre_repair_flushed_ids = None
|
||||
flush_cursor = getattr(agent, "_last_flushed_db_idx", None)
|
||||
if isinstance(flush_cursor, int) and flush_cursor > 0:
|
||||
pre_repair_flushed_ids = {id(m) for m in messages[:flush_cursor]}
|
||||
|
||||
repairs = repair_message_sequence(agent, messages)
|
||||
|
||||
if repairs > 0 and hasattr(agent, "_last_flushed_db_idx"):
|
||||
if pre_repair_flushed_ids is not None:
|
||||
agent._last_flushed_db_idx = sum(
|
||||
1 for m in messages if id(m) in pre_repair_flushed_ids
|
||||
)
|
||||
else:
|
||||
agent._last_flushed_db_idx = min(
|
||||
agent._last_flushed_db_idx, len(messages)
|
||||
)
|
||||
|
||||
return repairs
|
||||
|
||||
|
||||
|
||||
def strip_think_blocks(agent, content: str) -> str:
|
||||
"""Remove reasoning/thinking blocks from content, returning only visible text.
|
||||
@@ -618,33 +579,12 @@ def recover_with_credential_pool(
|
||||
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
|
||||
pool_provider = (getattr(pool, "provider", "") or "").strip().lower()
|
||||
if current_provider and pool_provider and current_provider != pool_provider:
|
||||
# Custom endpoints use two naming conventions for the SAME provider:
|
||||
# the agent carries the generic ``custom`` label while the pool is
|
||||
# keyed ``custom:<name>`` (see CUSTOM_POOL_PREFIX). A literal string
|
||||
# compare treats them as a mismatch and skips recovery for every
|
||||
# custom-provider user — 401s/429s then burn the full retry cycle
|
||||
# with no rotation or refresh. Accept the pair as matching only when
|
||||
# the agent's CURRENT base_url actually resolves to this pool key,
|
||||
# so a fallback provider (or a different custom endpoint) still
|
||||
# triggers the guard.
|
||||
_custom_match = False
|
||||
if current_provider == "custom" and pool_provider.startswith("custom:"):
|
||||
try:
|
||||
from agent.credential_pool import get_custom_provider_pool_key
|
||||
_agent_base = (getattr(agent, "base_url", "") or "").strip()
|
||||
_custom_match = bool(_agent_base) and (
|
||||
(get_custom_provider_pool_key(_agent_base) or "").strip().lower()
|
||||
== pool_provider
|
||||
)
|
||||
except Exception:
|
||||
_custom_match = False
|
||||
if not _custom_match:
|
||||
_ra().logger.warning(
|
||||
"Credential pool provider mismatch: pool=%s, agent=%s — "
|
||||
"skipping pool mutation to avoid cross-provider contamination",
|
||||
pool_provider, current_provider,
|
||||
)
|
||||
return False, has_retried_429
|
||||
_ra().logger.warning(
|
||||
"Credential pool provider mismatch: pool=%s, agent=%s — "
|
||||
"skipping pool mutation to avoid cross-provider contamination",
|
||||
pool_provider, current_provider,
|
||||
)
|
||||
return False, has_retried_429
|
||||
|
||||
effective_reason = classified_reason
|
||||
if effective_reason is None:
|
||||
@@ -881,8 +821,6 @@ def try_recover_primary_transport(
|
||||
|
||||
def drop_thinking_only_and_merge_users(
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
drop_codex_reasoning_items: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Drop thinking-only assistant turns; merge any adjacent user messages left behind.
|
||||
|
||||
@@ -904,13 +842,7 @@ def drop_thinking_only_and_merge_users(
|
||||
return messages
|
||||
|
||||
# Pass 1: drop thinking-only assistant turns.
|
||||
kept = [
|
||||
m for m in messages
|
||||
if not _ra().AIAgent._is_thinking_only_assistant(
|
||||
m,
|
||||
drop_codex_reasoning_items=drop_codex_reasoning_items,
|
||||
)
|
||||
]
|
||||
kept = [m for m in messages if not _ra().AIAgent._is_thinking_only_assistant(m)]
|
||||
dropped = len(messages) - len(kept)
|
||||
if dropped == 0:
|
||||
return messages
|
||||
@@ -1217,23 +1149,12 @@ 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"
|
||||
|
||||
# Redact secrets before persisting/printing. This dump captures the
|
||||
# full request body (system prompt, tool defs, context-embedded
|
||||
# values), and this path fires unconditionally on API errors — so it
|
||||
# otherwise lands any context-embedded secret in cleartext on disk.
|
||||
# Run the serialized dump through the same scrubber used for logs/tool
|
||||
# output, then hand the resulting payload back to the shared atomic
|
||||
# JSON writer so request dumps keep the same write semantics as before.
|
||||
from agent.redact import redact_sensitive_text
|
||||
_serialized = json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str)
|
||||
_redacted_payload = json.loads(redact_sensitive_text(_serialized, force=True))
|
||||
atomic_json_write(dump_file, _redacted_payload, default=str)
|
||||
atomic_json_write(dump_file, dump_payload, default=str)
|
||||
|
||||
agent._vprint(f"{agent.log_prefix}🧾 Request debug dump written to: {dump_file}")
|
||||
|
||||
if env_var_enabled("HERMES_DUMP_REQUEST_STDOUT"):
|
||||
print(json.dumps(_redacted_payload, ensure_ascii=False, indent=2, default=str))
|
||||
print(json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str))
|
||||
|
||||
return dump_file
|
||||
except Exception as dump_error:
|
||||
|
||||
@@ -372,7 +372,7 @@ def _detect_claude_code_version() -> str:
|
||||
|
||||
|
||||
_CLAUDE_CODE_SYSTEM_PREFIX = "You are Claude Code, Anthropic's official CLI for Claude."
|
||||
_MCP_TOOL_PREFIX = "mcp__"
|
||||
_MCP_TOOL_PREFIX = "mcp_"
|
||||
|
||||
|
||||
def _get_claude_code_version() -> str:
|
||||
@@ -751,9 +751,6 @@ def build_anthropic_client(
|
||||
from httpx import Timeout
|
||||
|
||||
normalized_base_url = _normalize_base_url_text(base_url)
|
||||
if normalized_base_url:
|
||||
import re as _re
|
||||
normalized_base_url = _re.sub(r"/v1/?$", "", normalized_base_url.rstrip("/"))
|
||||
_read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0
|
||||
kwargs = {
|
||||
"timeout": Timeout(timeout=float(_read_timeout), connect=10.0),
|
||||
@@ -2349,46 +2346,25 @@ def build_anthropic_kwargs(
|
||||
text = text.replace("Nous Research", "Anthropic")
|
||||
block["text"] = text
|
||||
|
||||
# 3. Normalize tool names so NOTHING goes on the OAuth wire with a
|
||||
# single-underscore ``mcp_`` prefix. Anthropic's subscription/OAuth
|
||||
# billing classifier treats a single-underscore ``mcp_`` tool name as
|
||||
# a third-party-app fingerprint and rejects the request with HTTP 400
|
||||
# "Third-party apps now draw from extra usage, not plan limits"
|
||||
# (verified empirically: a single ``mcp_foo`` tool flips a request
|
||||
# from plan-billing to the extra-usage lane; ``mcp__foo`` is accepted).
|
||||
#
|
||||
# Two cases, both must land on the double-underscore ``mcp__`` form:
|
||||
# a) bare Hermes-native tools (``read_file``) -> ``mcp__read_file``
|
||||
# b) native MCP server tools registered under their full
|
||||
# single-underscore ``mcp_<server>_<tool>`` name
|
||||
# (``mcp_linear_get_issue``) -> ``mcp__linear_get_issue``
|
||||
# Case (b) is the gap that the bare ``mcp_``->``mcp__`` constant swap
|
||||
# left open: those tools were *skipped* and stayed single-underscore,
|
||||
# so any session with an MCP server configured still tripped the
|
||||
# classifier. normalize_response reverses both forms via registry
|
||||
# lookup so the dispatcher still sees the original name. GH-25255.
|
||||
def _to_oauth_wire_name(name: str) -> str:
|
||||
if name.startswith("mcp__"):
|
||||
return name # already correct, don't double-prefix
|
||||
if name.startswith("mcp_"):
|
||||
# single-underscore native MCP tool -> promote to double
|
||||
return "mcp__" + name[len("mcp_"):]
|
||||
return _MCP_TOOL_PREFIX + name # bare name -> mcp__<name>
|
||||
|
||||
# 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:
|
||||
tool["name"] = _to_oauth_wire_name(tool["name"])
|
||||
if "name" in tool and not tool["name"].startswith(_MCP_TOOL_PREFIX):
|
||||
tool["name"] = _MCP_TOOL_PREFIX + tool["name"]
|
||||
|
||||
# 4. Apply the same normalization to tool names in message history
|
||||
# (tool_use blocks) so replayed turns match the wire names above.
|
||||
# 4. Prefix tool names in message history (tool_use and tool_result blocks)
|
||||
for msg in anthropic_messages:
|
||||
content = msg.get("content")
|
||||
if isinstance(content, list):
|
||||
for block in content:
|
||||
if isinstance(block, dict):
|
||||
if block.get("type") == "tool_use" and "name" in block:
|
||||
block["name"] = _to_oauth_wire_name(block["name"])
|
||||
if not block["name"].startswith(_MCP_TOOL_PREFIX):
|
||||
block["name"] = _MCP_TOOL_PREFIX + block["name"]
|
||||
elif block.get("type") == "tool_result" and "tool_use_id" in block:
|
||||
pass # tool_result uses ID, not name
|
||||
|
||||
|
||||
@@ -1144,8 +1144,7 @@ def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
|
||||
normalized = (base_url or "").strip().lower().rstrip("/")
|
||||
if not normalized:
|
||||
return False
|
||||
path = urlparse(normalized).path.rstrip("/")
|
||||
if path.endswith("/anthropic") or path.endswith("/anthropic/v1"):
|
||||
if normalized.endswith("/anthropic"):
|
||||
return True
|
||||
hostname = base_url_hostname(normalized)
|
||||
if hostname == "api.anthropic.com":
|
||||
@@ -3079,20 +3078,23 @@ def _try_configured_fallback_chain(
|
||||
if not fb_provider or fb_provider.lower() == skip:
|
||||
continue
|
||||
fb_model = str(entry.get("model", "")).strip() or None
|
||||
fb_base_url = str(entry.get("base_url", "")).strip() or None
|
||||
fb_api_key = str(entry.get("api_key", "")).strip() or None
|
||||
|
||||
label = f"fallback_chain[{i}]({fb_provider})"
|
||||
|
||||
try:
|
||||
fb_client, resolved_model = _resolve_fallback_entry(entry)
|
||||
fb_client = _resolve_single_provider(
|
||||
fb_provider, fb_model, fb_base_url, fb_api_key)
|
||||
except Exception:
|
||||
fb_client, resolved_model = None, None
|
||||
fb_client = None
|
||||
|
||||
if fb_client is not None:
|
||||
logger.info(
|
||||
"Auxiliary %s: %s on %s — configured fallback to %s (%s)",
|
||||
task, reason, failed_provider, label, resolved_model or fb_model or "default",
|
||||
task, reason, failed_provider, label, fb_model or "default",
|
||||
)
|
||||
return fb_client, resolved_model or fb_model, label
|
||||
return fb_client, fb_model, label
|
||||
tried.append(label)
|
||||
|
||||
if tried:
|
||||
@@ -3103,103 +3105,6 @@ def _try_configured_fallback_chain(
|
||||
return None, None, ""
|
||||
|
||||
|
||||
def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]:
|
||||
"""Resolve inline or env-backed API key from a fallback-chain entry."""
|
||||
explicit = str(entry.get("api_key") or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
key_env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip()
|
||||
if key_env:
|
||||
return os.getenv(key_env, "").strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_fallback_entry(entry: Dict[str, Any]) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Resolve one fallback entry through the central provider router."""
|
||||
provider = str(entry.get("provider") or "").strip()
|
||||
model = str(entry.get("model") or "").strip() or None
|
||||
if not provider or not model:
|
||||
return None, None
|
||||
base_url = str(entry.get("base_url") or "").strip() or None
|
||||
api_key = _fallback_entry_api_key(entry)
|
||||
api_mode = str(entry.get("api_mode") or entry.get("transport") or "").strip() or None
|
||||
return resolve_provider_client(
|
||||
provider,
|
||||
model=model,
|
||||
explicit_base_url=base_url,
|
||||
explicit_api_key=api_key,
|
||||
api_mode=api_mode,
|
||||
)
|
||||
|
||||
|
||||
def _try_main_fallback_chain(
|
||||
task: Optional[str],
|
||||
failed_provider: str = "",
|
||||
reason: str = "error",
|
||||
) -> Tuple[Optional[Any], Optional[str], str]:
|
||||
"""Try the top-level main-agent fallback chain for an auxiliary call.
|
||||
|
||||
``provider: auto`` auxiliary tasks should respect the user's declared
|
||||
main fallback policy before dropping into Hermes' built-in discovery
|
||||
chain. The top-level chain is read through ``get_fallback_chain`` so
|
||||
both modern ``fallback_providers`` and legacy ``fallback_model`` entries
|
||||
participate in the same order as the main agent.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.fallback_config import get_fallback_chain
|
||||
|
||||
chain = get_fallback_chain(load_config())
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary %s: could not load main fallback chain: %s", task or "call", exc)
|
||||
return None, None, ""
|
||||
|
||||
if not chain:
|
||||
return None, None, ""
|
||||
|
||||
failed_norm = (failed_provider or "").strip().lower()
|
||||
main_norm = (_read_main_provider() or "").strip().lower()
|
||||
skip = {p for p in (failed_norm, main_norm, "auto") if p}
|
||||
tried: List[str] = []
|
||||
|
||||
for i, entry in enumerate(chain):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
fb_provider = str(entry.get("provider") or "").strip()
|
||||
fb_model = str(entry.get("model") or "").strip()
|
||||
if not fb_provider or not fb_model:
|
||||
continue
|
||||
fb_norm = fb_provider.lower()
|
||||
label = f"fallback_providers[{i}]({fb_provider})"
|
||||
if fb_norm in skip:
|
||||
tried.append(f"{label} (skipped)")
|
||||
continue
|
||||
if _is_provider_unhealthy(fb_norm):
|
||||
_log_skip_unhealthy(fb_norm, task)
|
||||
tried.append(f"{label} (unhealthy)")
|
||||
continue
|
||||
try:
|
||||
fb_client, resolved_model = _resolve_fallback_entry(entry)
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary %s: main fallback %s failed to resolve: %s", task or "call", label, exc)
|
||||
fb_client, resolved_model = None, None
|
||||
if fb_client is not None:
|
||||
logger.info(
|
||||
"Auxiliary %s: %s on %s — main fallback chain to %s (%s)",
|
||||
task or "call", reason, failed_provider or "auto", label,
|
||||
resolved_model or fb_model,
|
||||
)
|
||||
return fb_client, resolved_model or fb_model, fb_provider
|
||||
tried.append(label)
|
||||
|
||||
if tried:
|
||||
logger.debug(
|
||||
"Auxiliary %s: main fallback chain exhausted (tried: %s)",
|
||||
task or "call", ", ".join(tried),
|
||||
)
|
||||
return None, None, ""
|
||||
|
||||
|
||||
def _resolve_single_provider(
|
||||
provider: str,
|
||||
model: Optional[str] = None,
|
||||
@@ -3210,19 +3115,16 @@ def _resolve_single_provider(
|
||||
|
||||
Uses the existing provider resolution infrastructure where possible.
|
||||
"""
|
||||
# Reuse resolve_provider_client which handles provider→client mapping.
|
||||
# Reuse resolve_provider_client which handles provider→client mapping
|
||||
client, resolved_model = resolve_provider_client(
|
||||
provider=provider,
|
||||
model=model,
|
||||
explicit_base_url=base_url,
|
||||
explicit_api_key=api_key,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
)
|
||||
return client
|
||||
|
||||
def _resolve_auto(
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
task: Optional[str] = None,
|
||||
) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
"""Full auto-detection chain.
|
||||
|
||||
Priority:
|
||||
@@ -3288,7 +3190,7 @@ def _resolve_auto(
|
||||
if (main_provider and main_model
|
||||
and main_provider not in {"auto", ""}):
|
||||
resolved_provider = main_provider
|
||||
explicit_base_url = runtime_base_url or None
|
||||
explicit_base_url = None
|
||||
explicit_api_key = None
|
||||
if runtime_base_url and (main_provider == "custom" or main_provider.startswith("custom:")):
|
||||
resolved_provider = "custom"
|
||||
@@ -3320,22 +3222,7 @@ def _resolve_auto(
|
||||
main_provider, resolved or main_model)
|
||||
return client, resolved or main_model
|
||||
|
||||
# ── Step 2: user-configured fallback policy ─────────────────────────
|
||||
# In auto mode, respect the task-specific fallback chain first, then the
|
||||
# main agent's top-level fallback_providers/fallback_model chain. The
|
||||
# hardcoded provider discovery chain below is only the convenience default
|
||||
# for users who have not declared a fallback policy.
|
||||
if task:
|
||||
fb_client, fb_model, _fb_label = _try_configured_fallback_chain(
|
||||
task, main_provider or "auto", reason="main provider unavailable")
|
||||
if fb_client is not None:
|
||||
return fb_client, fb_model
|
||||
fb_client, fb_model, _fb_label = _try_main_fallback_chain(
|
||||
task, main_provider or "auto", reason="main provider unavailable")
|
||||
if fb_client is not None:
|
||||
return fb_client, fb_model
|
||||
|
||||
# ── Step 3: aggregator / fallback chain ──────────────────────────────
|
||||
# ── Step 2: aggregator / fallback chain ──────────────────────────────
|
||||
tried = []
|
||||
for label, try_fn in _get_provider_chain():
|
||||
if _is_provider_unhealthy(label):
|
||||
@@ -3456,7 +3343,6 @@ def resolve_provider_client(
|
||||
api_mode: str = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
is_vision: bool = False,
|
||||
task: Optional[str] = None,
|
||||
) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Central router: given a provider name and optional model, return a
|
||||
configured client with the correct auth, base URL, and API format.
|
||||
@@ -3577,7 +3463,7 @@ def resolve_provider_client(
|
||||
|
||||
# ── Auto: try all providers in priority order ────────────────────
|
||||
if provider == "auto":
|
||||
client, resolved = _resolve_auto(main_runtime=main_runtime, task=task)
|
||||
client, resolved = _resolve_auto(main_runtime=main_runtime)
|
||||
if client is None:
|
||||
return None, None
|
||||
# When auto-detection lands on a non-OpenRouter provider (e.g. a
|
||||
@@ -4470,16 +4356,11 @@ def _client_cache_key(
|
||||
api_mode: Optional[str] = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
is_vision: bool = False,
|
||||
task: Optional[str] = None,
|
||||
) -> tuple:
|
||||
runtime = _normalize_main_runtime(main_runtime)
|
||||
runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else ()
|
||||
# `auto` can now resolve through task-specific or main fallback policy,
|
||||
# so the task participates in the cache key. Non-auto providers keep the
|
||||
# old cache shape because the explicit provider/model tuple is sufficient.
|
||||
task_key = (task or "") if provider == "auto" else ""
|
||||
pool_hint = _pool_cache_hint(provider, main_runtime=main_runtime)
|
||||
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, task_key, pool_hint)
|
||||
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, pool_hint)
|
||||
|
||||
|
||||
def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:
|
||||
@@ -4672,7 +4553,6 @@ def _get_cached_client(
|
||||
api_mode: str = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
is_vision: bool = False,
|
||||
task: Optional[str] = None,
|
||||
) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Get or create a cached client for the given provider.
|
||||
|
||||
@@ -4710,7 +4590,6 @@ def _get_cached_client(
|
||||
api_mode=api_mode,
|
||||
main_runtime=main_runtime,
|
||||
is_vision=is_vision,
|
||||
task=task,
|
||||
)
|
||||
with _client_cache_lock:
|
||||
if cache_key in _client_cache:
|
||||
@@ -4755,7 +4634,6 @@ def _get_cached_client(
|
||||
api_mode=api_mode,
|
||||
main_runtime=runtime,
|
||||
is_vision=is_vision,
|
||||
task=task,
|
||||
)
|
||||
if client is not None:
|
||||
# For async clients, remember which loop they were created on so we
|
||||
@@ -5126,7 +5004,7 @@ def _build_call_kwargs(
|
||||
|
||||
# Provider-specific extra_body
|
||||
merged_extra = dict(extra_body or {})
|
||||
if provider == "nous":
|
||||
if provider == "nous" or auxiliary_is_nous:
|
||||
merged_extra.setdefault("tags", []).extend(_nous_portal_tags())
|
||||
if merged_extra:
|
||||
kwargs["extra_body"] = merged_extra
|
||||
@@ -5261,7 +5139,7 @@ def call_llm(
|
||||
if not resolved_base_url:
|
||||
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
|
||||
task or "call", resolved_provider)
|
||||
client, final_model = _get_cached_client("auto", main_runtime=main_runtime, task=task)
|
||||
client, final_model = _get_cached_client("auto", main_runtime=main_runtime)
|
||||
if client is None:
|
||||
raise RuntimeError(
|
||||
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
||||
@@ -5587,19 +5465,14 @@ def call_llm(
|
||||
|
||||
# Fallback order (#26882, #26803):
|
||||
# 1. User-configured fallback_chain (per-task) if set
|
||||
# 2. For auto: top-level main fallback_providers/fallback_model
|
||||
# 3. For auto: built-in auxiliary discovery chain
|
||||
# 4. For explicit aux providers: main agent model safety net
|
||||
# 2. Main agent model (last-resort safety net)
|
||||
# For auto users (no explicit aux provider), use the full
|
||||
# auto-detection chain instead — its Step 1 IS the main agent
|
||||
# model, so users on `auto` already get main-model fallback.
|
||||
fb_client, fb_model, fb_label = (None, None, "")
|
||||
if is_auto:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_main_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task, reason=reason)
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task, reason=reason)
|
||||
else:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
@@ -5762,7 +5635,7 @@ async def async_call_llm(
|
||||
if not resolved_base_url:
|
||||
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
|
||||
task or "call", resolved_provider)
|
||||
client, final_model = _get_cached_client("auto", async_mode=True, main_runtime=main_runtime, task=task)
|
||||
client, final_model = _get_cached_client("auto", async_mode=True)
|
||||
if client is None:
|
||||
raise RuntimeError(
|
||||
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
||||
@@ -6030,19 +5903,13 @@ async def async_call_llm(
|
||||
|
||||
# Fallback order (#26882, #26803):
|
||||
# 1. User-configured fallback_chain (per-task) if set
|
||||
# 2. For auto: top-level main fallback_providers/fallback_model
|
||||
# 3. For auto: built-in auxiliary discovery chain
|
||||
# 4. For explicit aux providers: main agent model safety net
|
||||
# 2. Main agent model (last-resort safety net)
|
||||
# Auto users get the full auto-detection chain instead — its
|
||||
# Step 1 IS the main agent model.
|
||||
fb_client, fb_model, fb_label = (None, None, "")
|
||||
if is_auto:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_main_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task, reason=reason)
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task, reason=reason)
|
||||
else:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
|
||||
@@ -237,25 +237,18 @@ _COMBINED_REVIEW_PROMPT = (
|
||||
def summarize_background_review_actions(
|
||||
review_messages: List[Dict],
|
||||
prior_snapshot: List[Dict],
|
||||
notification_mode: str = "on",
|
||||
) -> List[str]:
|
||||
"""Build the human-facing action summary for a background review pass.
|
||||
|
||||
Walks the review agent's session messages and collects successful memory
|
||||
and skill-management actions to surface to the user. Tool messages already
|
||||
present in ``prior_snapshot`` are skipped so stale inherited results are
|
||||
not re-surfaced as fresh background work (issue #14944).
|
||||
Walks the review agent's session messages and collects "successful tool
|
||||
action" descriptions to surface to the user (e.g. "Memory updated").
|
||||
Tool messages already present in ``prior_snapshot`` are skipped so we
|
||||
don't re-surface stale results from the prior conversation that the
|
||||
review agent inherited via ``conversation_history`` (issue #14944).
|
||||
|
||||
``notification_mode`` controls display detail:
|
||||
- ``off``: return no actions.
|
||||
- ``on``: generic "Memory updated"/tool messages.
|
||||
- ``verbose``: include compact content previews from tool-call arguments.
|
||||
Matching is by ``tool_call_id`` when available, with a content-equality
|
||||
fallback for tool messages that lack one.
|
||||
"""
|
||||
mode = str(notification_mode or "on").lower()
|
||||
if mode == "off":
|
||||
return []
|
||||
verbose = mode == "verbose"
|
||||
|
||||
existing_tool_call_ids = set()
|
||||
existing_tool_contents = set()
|
||||
for prior in prior_snapshot or []:
|
||||
@@ -269,42 +262,6 @@ def summarize_background_review_actions(
|
||||
if isinstance(content, str):
|
||||
existing_tool_contents.add(content)
|
||||
|
||||
# Map review-agent tool results back to the calls that produced them. The
|
||||
# result JSON only says "Entry added"; the call arguments contain action,
|
||||
# target, and content previews. Restricting to notify_tools also prevents
|
||||
# helper tools from surfacing as memory work just because they succeeded.
|
||||
notify_tools = {"memory", "skill_manage"}
|
||||
all_tool_call_ids: set = set()
|
||||
call_details: dict = {}
|
||||
for msg in review_messages or []:
|
||||
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||
continue
|
||||
for tc in msg.get("tool_calls", []) or []:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
fn = tc.get("function", {}) or {}
|
||||
fn_name = fn.get("name", "")
|
||||
tcid = tc.get("id")
|
||||
if tcid:
|
||||
all_tool_call_ids.add(tcid)
|
||||
if fn_name not in notify_tools:
|
||||
continue
|
||||
try:
|
||||
args = json.loads(fn.get("arguments", "{}"))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
if tcid:
|
||||
call_details[tcid] = {
|
||||
"tool": fn_name,
|
||||
"action": args.get("action", "?"),
|
||||
"target": args.get("target", "memory"),
|
||||
"content": args.get("content", ""),
|
||||
"old_text": args.get("old_text", ""),
|
||||
"name": args.get("name", ""),
|
||||
"old_string": args.get("old_string", ""),
|
||||
"new_string": args.get("new_string", ""),
|
||||
}
|
||||
|
||||
actions: List[str] = []
|
||||
for msg in review_messages or []:
|
||||
if not isinstance(msg, dict) or msg.get("role") != "tool":
|
||||
@@ -316,8 +273,6 @@ def summarize_background_review_actions(
|
||||
content_str = msg.get("content")
|
||||
if isinstance(content_str, str) and content_str in existing_tool_contents:
|
||||
continue
|
||||
if tcid and all_tool_call_ids and tcid not in call_details:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(msg.get("content", "{}"))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
@@ -325,75 +280,19 @@ def summarize_background_review_actions(
|
||||
if not isinstance(data, dict) or not data.get("success"):
|
||||
continue
|
||||
message = data.get("message", "")
|
||||
detail = call_details.get(tcid, {})
|
||||
target = data.get("target", "") or detail.get("target", "")
|
||||
is_skill = detail.get("tool") == "skill_manage"
|
||||
|
||||
message_lower = message.lower()
|
||||
if not verbose:
|
||||
if "created" in message_lower:
|
||||
actions.append(message)
|
||||
continue
|
||||
if "updated" in message_lower:
|
||||
actions.append(message)
|
||||
continue
|
||||
if is_skill and "patched" in message_lower:
|
||||
actions.append(message)
|
||||
continue
|
||||
|
||||
if is_skill:
|
||||
label = "Skill"
|
||||
elif target:
|
||||
target = data.get("target", "")
|
||||
if "created" in message.lower():
|
||||
actions.append(message)
|
||||
elif "updated" in message.lower():
|
||||
actions.append(message)
|
||||
elif "added" in message.lower() or (target and "add" in message.lower()):
|
||||
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
|
||||
actions.append(f"{label} updated")
|
||||
elif "Entry added" in message:
|
||||
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
|
||||
actions.append(f"{label} updated")
|
||||
elif "removed" in message.lower() or "replaced" in message.lower():
|
||||
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
|
||||
else:
|
||||
continue
|
||||
|
||||
if verbose:
|
||||
action = detail.get("action", "")
|
||||
content = detail.get("content", "")
|
||||
old_text = detail.get("old_text", "")
|
||||
skill_name = detail.get("name", "")
|
||||
max_preview = 120
|
||||
if is_skill:
|
||||
change = data.get("_change", {})
|
||||
old_string = change.get("old", "") or detail.get("old_string", "")
|
||||
new_string = change.get("new", "") or detail.get("new_string", "")
|
||||
description = change.get("description", "")
|
||||
if action == "patch" and (old_string or new_string):
|
||||
old_preview = old_string[:80].replace("\n", " ") + (
|
||||
"…" if len(old_string) > 80 else ""
|
||||
)
|
||||
new_preview = new_string[:80].replace("\n", " ") + (
|
||||
"…" if len(new_string) > 80 else ""
|
||||
)
|
||||
actions.append(
|
||||
f"📝 Skill '{skill_name}' patched: "
|
||||
f"\"{old_preview}\" → \"{new_preview}\""
|
||||
)
|
||||
elif action == "create" and description:
|
||||
actions.append(f"📝 Skill '{skill_name}' created: {description}")
|
||||
elif action == "edit" and description:
|
||||
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
|
||||
else:
|
||||
actions.append(f"📝 {message}" if message else f"Skill {action}")
|
||||
elif action == "add" and content:
|
||||
preview = content[:max_preview] + ("…" if len(content) > max_preview else "")
|
||||
actions.append(f"{label} ➕ {preview}")
|
||||
elif action == "replace" and content:
|
||||
preview = content[:max_preview] + ("…" if len(content) > max_preview else "")
|
||||
actions.append(f"{label} ✏️ {preview}")
|
||||
elif action == "remove" and old_text:
|
||||
preview = old_text[:60] + ("…" if len(old_text) > 60 else "")
|
||||
actions.append(f"{label} ➖ {preview}")
|
||||
else:
|
||||
actions.append(f"{label} updated")
|
||||
elif (
|
||||
"added" in message_lower
|
||||
or "replaced" in message_lower
|
||||
or "removed" in message_lower
|
||||
or (target and "add" in message.lower())
|
||||
or "Entry added" in message
|
||||
):
|
||||
actions.append(f"{label} updated")
|
||||
return actions
|
||||
|
||||
@@ -623,7 +522,6 @@ def _run_review_in_thread(
|
||||
actions = summarize_background_review_actions(
|
||||
review_messages,
|
||||
messages_snapshot,
|
||||
notification_mode=getattr(agent, "memory_notifications", "on"),
|
||||
)
|
||||
|
||||
if actions:
|
||||
|
||||
@@ -58,34 +58,17 @@ _bedrock_runtime_client_cache: Dict[str, Any] = {}
|
||||
_bedrock_control_client_cache: Dict[str, Any] = {}
|
||||
|
||||
|
||||
_MIN_BOTO3_VERSION = (1, 34, 59)
|
||||
|
||||
|
||||
def _require_boto3():
|
||||
"""Import boto3, raising a clear error if not installed or too old."""
|
||||
"""Import boto3, raising a clear error if not installed."""
|
||||
try:
|
||||
import boto3
|
||||
return boto3
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"The 'boto3' package is required for the AWS Bedrock provider. "
|
||||
"Install it with: pip install boto3\n"
|
||||
"Or install Hermes with Bedrock support: pip install -e '.[bedrock]'"
|
||||
)
|
||||
# converse() / converse_stream() were added in boto3 1.34.59.
|
||||
# When Hermes is installed editable into system Python, the system boto3
|
||||
# (e.g. Ubuntu 24.04 ships 1.34.46) may take precedence over the venv
|
||||
# version pinned in pyproject.toml.
|
||||
try:
|
||||
version = tuple(int(x) for x in boto3.__version__.split(".")[:3])
|
||||
except (AttributeError, ValueError):
|
||||
return boto3 # can't parse — don't block on version check
|
||||
if version < _MIN_BOTO3_VERSION:
|
||||
raise RuntimeError(
|
||||
f"boto3 {boto3.__version__} does not support converse_stream "
|
||||
f"(minimum 1.34.59 required). Upgrade with: "
|
||||
f"pip install --upgrade boto3"
|
||||
)
|
||||
return boto3
|
||||
|
||||
|
||||
def _get_bedrock_runtime_client(region: str):
|
||||
@@ -952,14 +935,11 @@ def build_converse_kwargs(
|
||||
if system_prompt:
|
||||
kwargs["system"] = system_prompt
|
||||
|
||||
from agent.anthropic_adapter import _forbids_sampling_params
|
||||
if temperature is not None:
|
||||
kwargs["inferenceConfig"]["temperature"] = temperature
|
||||
|
||||
if not _forbids_sampling_params(model):
|
||||
if temperature is not None:
|
||||
kwargs["inferenceConfig"]["temperature"] = temperature
|
||||
|
||||
if top_p is not None:
|
||||
kwargs["inferenceConfig"]["topP"] = top_p
|
||||
if top_p is not None:
|
||||
kwargs["inferenceConfig"]["topP"] = top_p
|
||||
|
||||
if stop_sequences:
|
||||
kwargs["inferenceConfig"]["stopSequences"] = stop_sequences
|
||||
|
||||
@@ -127,21 +127,14 @@ def _chat_content_to_responses_parts(content: Any, *, role: str = "user") -> Lis
|
||||
return converted
|
||||
|
||||
|
||||
def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str:
|
||||
"""Flatten message content to a plain-text summary.
|
||||
def _summarize_user_message_for_log(content: Any) -> str:
|
||||
"""Return a short text summary of a user message for logging/trajectory.
|
||||
|
||||
Multimodal messages arrive as a list of ``{type:"text"|"image_url", ...}``
|
||||
parts from the API server. Several consumers want a plain string:
|
||||
|
||||
- Logging, spinner previews, and trajectory files (the default ``sep=" "``).
|
||||
- External memory providers, which feed the text to regexes
|
||||
(``sanitize_context``) and text APIs — a raw list crashes the sync with
|
||||
``expected string or bytes-like object, got 'list'`` (use ``sep="\\n"``).
|
||||
|
||||
Text parts are joined with ``sep``; images become a ``[N image(s)]`` marker
|
||||
so the turn isn't recorded as if the attachment never existed. Returns an
|
||||
empty string for empty lists and ``str(content)`` for unexpected scalar
|
||||
types.
|
||||
parts from the API server. Logging, spinner previews, and trajectory
|
||||
files all want a plain string — this helper extracts the first chunk of
|
||||
text and notes any attached images. Returns an empty string for empty
|
||||
lists and ``str(content)`` for unexpected scalar types.
|
||||
"""
|
||||
if content is None:
|
||||
return ""
|
||||
@@ -164,7 +157,7 @@ def _summarize_user_message_for_log(content: Any, *, sep: str = " ") -> str:
|
||||
text_bits.append(text)
|
||||
elif ptype in {"image_url", "input_image"}:
|
||||
image_count += 1
|
||||
summary = sep.join(text_bits).strip()
|
||||
summary = " ".join(text_bits).strip()
|
||||
if image_count:
|
||||
note = f"[{image_count} image{'s' if image_count != 1 else ''}]"
|
||||
summary = f"{note} {summary}" if summary else note
|
||||
@@ -1081,7 +1074,6 @@ def _normalize_codex_response(
|
||||
message_items_raw: List[Dict[str, Any]] = []
|
||||
tool_calls: List[Any] = []
|
||||
has_incomplete_items = response_status in {"queued", "in_progress", "incomplete"}
|
||||
saw_streaming_or_item_incomplete = response_status in {"queued", "in_progress"}
|
||||
saw_commentary_phase = False
|
||||
saw_final_answer_phase = False
|
||||
saw_reasoning_item = False
|
||||
@@ -1096,7 +1088,6 @@ def _normalize_codex_response(
|
||||
|
||||
if item_status in {"queued", "in_progress", "incomplete"}:
|
||||
has_incomplete_items = True
|
||||
saw_streaming_or_item_incomplete = True
|
||||
|
||||
if item_type == "message":
|
||||
item_phase = getattr(item, "phase", None)
|
||||
@@ -1254,9 +1245,7 @@ def _normalize_codex_response(
|
||||
finish_reason = "tool_calls"
|
||||
elif leaked_tool_call_text:
|
||||
finish_reason = "incomplete"
|
||||
elif saw_streaming_or_item_incomplete:
|
||||
finish_reason = "incomplete"
|
||||
elif (has_incomplete_items or saw_commentary_phase) and not saw_final_answer_phase:
|
||||
elif has_incomplete_items or (saw_commentary_phase and not saw_final_answer_phase):
|
||||
finish_reason = "incomplete"
|
||||
elif (reasoning_items_raw or reasoning_parts or saw_reasoning_item) and not final_text:
|
||||
# Response contains only reasoning (encrypted thinking state and/or
|
||||
|
||||
@@ -190,10 +190,6 @@ CODING_AGENT_GUIDANCE = (
|
||||
"Verify, and know when to stop:\n"
|
||||
"- Use `terminal` for git, builds, tests, and inspection. Run the relevant "
|
||||
"tests/linter/build and confirm they pass before claiming the work is done.\n"
|
||||
"- Terminal state persists across calls: current directory and exported "
|
||||
"environment variables carry forward. Activate a virtualenv or export setup "
|
||||
"vars once, then reuse that state instead of re-sourcing it before every "
|
||||
"test command.\n"
|
||||
"- Fix root causes, not symptoms: when you find a bug, check sibling call "
|
||||
"paths for the same flaw and fix the class, not just the reported site.\n"
|
||||
"- When fixing linter/type errors on a file, stop after about three "
|
||||
@@ -715,13 +711,10 @@ def build_coding_workspace_block(cwd: Optional[str | Path] = None) -> str:
|
||||
lines.append("- Branch: (detached HEAD)")
|
||||
|
||||
# Linked worktree: the per-worktree git dir differs from the shared common dir.
|
||||
# We surface the fact that it's a worktree (so the model knows branches/stashes
|
||||
# are shared state) but deliberately do NOT expose the primary tree path —
|
||||
# giving the model a second absolute path causes it to sometimes run commands
|
||||
# in the wrong directory.
|
||||
git_dir, common_dir = _git(root, "rev-parse", "--git-dir"), _git(root, "rev-parse", "--git-common-dir")
|
||||
if git_dir and common_dir and Path(git_dir).resolve() != Path(common_dir).resolve():
|
||||
lines.append("- Worktree: linked (git state shared with primary tree)")
|
||||
main_tree = Path(common_dir).resolve().parent
|
||||
lines.append(f"- Worktree: linked (primary tree at {main_tree})")
|
||||
|
||||
dirty = [f"{n} {label}" for label, n in (
|
||||
("staged", counts["staged"]), ("modified", counts["modified"]),
|
||||
|
||||
@@ -69,31 +69,6 @@ SUMMARY_PREFIX = (
|
||||
)
|
||||
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
|
||||
|
||||
# Metadata key added to context compression summary messages so that frontends
|
||||
# (CLI, Desktop, gateway, TUI) can distinguish them from real assistant/user
|
||||
# messages and filter or render them appropriately without content-prefix
|
||||
# heuristics. See https://github.com/NousResearch/hermes-agent/issues/38389
|
||||
#
|
||||
# Underscore-prefixed ON PURPOSE: the wire sanitizers
|
||||
# (agent/transports/chat_completions.py convert_messages and the summary-path
|
||||
# mirror in agent/chat_completion_helpers.py) strip every top-level message
|
||||
# key starting with "_" before the request leaves the process. Strict
|
||||
# OpenAI-compatible gateways (Fireworks, Mistral, Moonshot/Kimi, opencode-go)
|
||||
# reject payloads carrying unknown keys with "Extra inputs are not permitted",
|
||||
# poisoning every subsequent request in the session — a bare key like
|
||||
# "is_compressed_summary" would reach the wire and trip exactly that.
|
||||
COMPRESSED_SUMMARY_METADATA_KEY = "_compressed_summary"
|
||||
|
||||
# Appended to every standalone summary message (and to the merged-into-tail
|
||||
# prefix) so the model has an unambiguous "summary ends here" boundary.
|
||||
# Without it, weak models read the verbatim "## Active Task" quote as fresh
|
||||
# user input (#11475, #14521) or regurgitate an assistant-role summary as
|
||||
# their own output (#33256).
|
||||
_SUMMARY_END_MARKER = (
|
||||
"--- END OF CONTEXT SUMMARY — "
|
||||
"respond to the message below, not the summary above ---"
|
||||
)
|
||||
|
||||
# Handoff prefixes that shipped in earlier releases. A summary persisted under
|
||||
# one of these can be inherited into a resumed lineage (#35344); when it is
|
||||
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
|
||||
@@ -168,23 +143,10 @@ _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
|
||||
# become another unbounded transcript copy after the LLM summarizer failed.
|
||||
_FALLBACK_SUMMARY_MAX_CHARS = 8_000
|
||||
_FALLBACK_TURN_MAX_CHARS = 700
|
||||
_AUTO_FOCUS_MAX_TURNS = 3
|
||||
_AUTO_FOCUS_TURN_MAX_CHARS = 260
|
||||
_AUTO_FOCUS_MAX_CHARS = 700
|
||||
# Keep a short run of recent messages verbatim even when the token budget is
|
||||
# already exhausted. The public ``protect_last_n`` default is intentionally
|
||||
# high for small/light tails, but using all 20 as a hard floor here would bring
|
||||
# back the old large-tool-output case where nothing can be compacted.
|
||||
_MAX_TAIL_MESSAGE_FLOOR = 8
|
||||
|
||||
|
||||
_PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
|
||||
|
||||
# MEDIA delivery directives must not reach the summarizer — if one leaks into
|
||||
# the summary, the downstream model may re-emit it as an active directive on
|
||||
# the next turn, triggering bogus attachment sends (#14665).
|
||||
_MEDIA_DIRECTIVE_RE = re.compile(r"MEDIA:\S+")
|
||||
|
||||
|
||||
def _dedupe_append(items: list[str], value: str, *, limit: int) -> None:
|
||||
value = value.strip()
|
||||
@@ -1045,7 +1007,6 @@ class ContextCompressor(ContextEngine):
|
||||
for msg in turns:
|
||||
role = msg.get("role", "unknown")
|
||||
content = redact_sensitive_text(msg.get("content") or "")
|
||||
content = _MEDIA_DIRECTIVE_RE.sub("[media attachment]", content)
|
||||
|
||||
# Tool results: keep enough content for the summarizer
|
||||
if role == "tool":
|
||||
@@ -1493,7 +1454,7 @@ Use this exact structure:
|
||||
prompt += f"""
|
||||
|
||||
FOCUS TOPIC: "{focus_topic}"
|
||||
This compaction should PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED]."""
|
||||
The user has requested that this compaction PRIORITISE preserving all information related to the focus topic above. For content related to "{focus_topic}", include full detail — exact values, file paths, command outputs, error messages, and decisions. For content NOT related to the focus topic, summarise more aggressively (brief one-liners or omit if truly irrelevant). The focus topic sections should receive roughly 60-70% of the summary token budget. Even for the focus topic, NEVER preserve API keys, tokens, passwords, or credentials — use [REDACTED]."""
|
||||
|
||||
try:
|
||||
call_kwargs = {
|
||||
@@ -1646,13 +1607,7 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
text = (summary or "").strip()
|
||||
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX, *_HISTORICAL_SUMMARY_PREFIXES):
|
||||
if text.startswith(prefix):
|
||||
text = text[len(prefix):].lstrip()
|
||||
break
|
||||
# Strip the trailing end marker too — a rehydrated handoff body that
|
||||
# keeps it would leak the boundary directive into the iterative-update
|
||||
# summarizer prompt (and the marker is re-appended on insertion anyway).
|
||||
if text.endswith(_SUMMARY_END_MARKER):
|
||||
text = text[: -len(_SUMMARY_END_MARKER)].rstrip()
|
||||
return text[len(prefix):].lstrip()
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
@@ -1668,52 +1623,6 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
return True
|
||||
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
|
||||
|
||||
@staticmethod
|
||||
def _has_compressed_summary_metadata(message: Any) -> bool:
|
||||
"""Return True if *message* carries the compressed-summary flag.
|
||||
|
||||
Callers (frontends, CLI, gateway) can use this to distinguish context
|
||||
compaction summaries from real assistant or user messages without
|
||||
relying on content-prefix heuristics. The flag is in-process only —
|
||||
the wire sanitizers strip underscore-prefixed keys before API calls.
|
||||
"""
|
||||
if not isinstance(message, dict):
|
||||
return False
|
||||
return bool(message.get(COMPRESSED_SUMMARY_METADATA_KEY))
|
||||
|
||||
@classmethod
|
||||
def _derive_auto_focus_topic(
|
||||
cls,
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> Optional[str]:
|
||||
"""Infer a compact focus hint from the most recent real user turns."""
|
||||
candidates: list[str] = []
|
||||
for idx in range(len(messages) - 1, -1, -1):
|
||||
msg = messages[idx]
|
||||
if msg.get("role") != "user":
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if cls._is_context_summary_content(content):
|
||||
continue
|
||||
text = redact_sensitive_text(_content_text_for_contains(content).strip())
|
||||
if not text:
|
||||
continue
|
||||
text = " ".join(text.split())
|
||||
if len(text) > _AUTO_FOCUS_TURN_MAX_CHARS:
|
||||
text = text[: _AUTO_FOCUS_TURN_MAX_CHARS - 1].rstrip() + "…"
|
||||
candidates.append(text)
|
||||
if len(candidates) >= _AUTO_FOCUS_MAX_TURNS:
|
||||
break
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
candidates.reverse()
|
||||
focus = "Recent user focus:\n" + "\n".join(f"- {item}" for item in candidates)
|
||||
if len(focus) > _AUTO_FOCUS_MAX_CHARS:
|
||||
focus = focus[: _AUTO_FOCUS_MAX_CHARS - 1].rstrip() + "…"
|
||||
return focus
|
||||
|
||||
@classmethod
|
||||
def _find_latest_context_summary(
|
||||
cls,
|
||||
@@ -1866,105 +1775,6 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
return i
|
||||
return -1
|
||||
|
||||
def _find_last_assistant_message_idx(
|
||||
self, messages: List[Dict[str, Any]], head_end: int
|
||||
) -> int:
|
||||
"""Return the index of the last user-visible assistant reply at or
|
||||
after *head_end*, or -1.
|
||||
|
||||
A "user-visible reply" is an assistant message with non-empty
|
||||
textual content — i.e. one that the WebUI / TUI / SessionsPage
|
||||
rendered as a bubble the operator could read. We deliberately
|
||||
skip assistant messages that contain only ``tool_calls`` (and
|
||||
no text), because those render as small "calling tool X"
|
||||
indicators and aren't what the reporter means by "the output
|
||||
of the last message you sent" (#29824).
|
||||
|
||||
Falling back to the most recent assistant message of ANY kind
|
||||
only kicks in when no content-bearing assistant message exists
|
||||
in the compressible region — typically a fresh session that
|
||||
just started a multi-step tool sequence with no prior reply
|
||||
to anchor. In that case the agent fix is a no-op and the
|
||||
existing user-message anchor carries the load.
|
||||
"""
|
||||
last_any = -1
|
||||
for i in range(len(messages) - 1, head_end - 1, -1):
|
||||
msg = messages[i]
|
||||
if msg.get("role") != "assistant":
|
||||
continue
|
||||
if last_any < 0:
|
||||
last_any = i
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str) and content.strip():
|
||||
return i
|
||||
if isinstance(content, list):
|
||||
# Multimodal / Anthropic-style content: look for any
|
||||
# text block with non-empty text.
|
||||
for part in content:
|
||||
if isinstance(part, dict):
|
||||
text = part.get("text") or part.get("content")
|
||||
if isinstance(text, str) and text.strip():
|
||||
return i
|
||||
return last_any
|
||||
|
||||
def _ensure_last_assistant_message_in_tail(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
cut_idx: int,
|
||||
head_end: int,
|
||||
) -> int:
|
||||
"""Guarantee the most recent assistant message is in the protected tail.
|
||||
|
||||
WebUI / TUI / SessionsPage bug (#29824). Without this anchor,
|
||||
``_find_tail_cut_by_tokens`` can leave the user's most recent
|
||||
visible assistant response inside the compressed middle region —
|
||||
especially when the conversation has a single oversized tool
|
||||
result or a long stretch of tool-call/result pairs after the
|
||||
last assistant reply. The summariser then rolls that reply up
|
||||
into the single ``[CONTEXT COMPACTION — REFERENCE ONLY]`` block
|
||||
persisted as ``role="user"`` or ``role="assistant"``. From the
|
||||
operator's perspective the WebUI session viewer
|
||||
(``web/src/pages/SessionsPage.tsx``) and the TUI chat panel
|
||||
both suddenly show the opaque "Context compaction" block in the
|
||||
slot where they were just reading the assistant's actual reply:
|
||||
|
||||
User: "i cant see the output of the last message you
|
||||
sent, i did see it previously, however now see
|
||||
'context compaction'"
|
||||
|
||||
Mirror of ``_ensure_last_user_message_in_tail`` but anchors on
|
||||
the last assistant-role message. Re-runs the tool-group
|
||||
alignment so we don't split a ``tool_call`` / ``tool_result``
|
||||
group that immediately precedes the anchored message — orphaned
|
||||
tool messages would otherwise be removed by
|
||||
``_sanitize_tool_pairs`` and trigger the same data-loss symptom
|
||||
we're trying to prevent.
|
||||
"""
|
||||
last_asst_idx = self._find_last_assistant_message_idx(messages, head_end)
|
||||
if last_asst_idx < 0:
|
||||
# No assistant message in the compressible region — nothing
|
||||
# to anchor (single-turn pre-reply state, etc.).
|
||||
return cut_idx
|
||||
if last_asst_idx >= cut_idx:
|
||||
# Already in the tail — the token-budget walk did the right
|
||||
# thing on its own.
|
||||
return cut_idx
|
||||
# Pull cut_idx back to the assistant message, then re-align so
|
||||
# we don't split a tool group that immediately precedes it
|
||||
# (e.g. an ``assistant(tool_calls)`` → ``tool(result)`` →
|
||||
# ``assistant(final reply)`` sequence would otherwise leave the
|
||||
# ``tool`` orphan when cut lands at the final reply).
|
||||
new_cut = self._align_boundary_backward(messages, last_asst_idx)
|
||||
if not self.quiet_mode:
|
||||
logger.debug(
|
||||
"Anchoring tail cut to last assistant message at index %d "
|
||||
"(was %d, aligned to %d) to keep the previously-visible "
|
||||
"reply out of the compaction summary (#29824)",
|
||||
last_asst_idx, cut_idx, new_cut,
|
||||
)
|
||||
# Safety: never go back into the head region.
|
||||
return max(new_cut, head_end + 1)
|
||||
|
||||
def _ensure_last_user_message_in_tail(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
@@ -2023,12 +1833,11 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
derived from ``summary_target_ratio * context_length``, so it
|
||||
scales automatically with the model's context window.
|
||||
|
||||
Token budget is the primary criterion. A bounded message-count floor
|
||||
keeps a short run of recent turns verbatim even when the budget is
|
||||
exhausted, but the budget is allowed to exceed by up to 1.5x to avoid
|
||||
cutting inside an oversized message (tool output, file read, etc.). If
|
||||
even that floor exceeds 1.5x the budget, the cut is placed right after
|
||||
the head so compression still runs.
|
||||
Token budget is the primary criterion. A hard minimum of 3 messages
|
||||
is always protected, but the budget is allowed to exceed by up to
|
||||
1.5x to avoid cutting inside an oversized message (tool output, file
|
||||
read, etc.). If even the minimum 3 messages exceed 1.5x the budget
|
||||
the cut is placed right after the head so compression still runs.
|
||||
|
||||
Never cuts inside a tool_call/result group. Always ensures the most
|
||||
recent user message is in the tail (see ``_ensure_last_user_message_in_tail``).
|
||||
@@ -2036,19 +1845,8 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
if token_budget is None:
|
||||
token_budget = self.tail_token_budget
|
||||
n = len(messages)
|
||||
# Hard minimum: always keep a bounded recent-message floor in the tail.
|
||||
# ``protect_last_n`` remains a minimum up to the cap; the cap avoids
|
||||
# preserving a whole run of bulky tool outputs on every compaction.
|
||||
available_tail = max(0, n - head_end - 1)
|
||||
min_tail_floor = max(3, min(self.protect_last_n, _MAX_TAIL_MESSAGE_FLOOR))
|
||||
# Leave at least two non-head messages available to summarize on short
|
||||
# transcripts; otherwise compression can replace a tiny middle with a
|
||||
# summary and save no messages at all.
|
||||
compressible_tail_cap = max(3, available_tail - 2)
|
||||
min_tail = (
|
||||
min(min_tail_floor, compressible_tail_cap, available_tail)
|
||||
if available_tail > 1 else 0
|
||||
)
|
||||
# Hard minimum: always keep at least 3 messages in the tail
|
||||
min_tail = min(3, n - head_end - 1) if n - head_end > 1 else 0
|
||||
soft_ceiling = int(token_budget * 1.5)
|
||||
accumulated = 0
|
||||
cut_idx = n # start from beyond the end
|
||||
@@ -2120,13 +1918,6 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
# active task is never lost to compression (fixes #10896).
|
||||
cut_idx = self._ensure_last_user_message_in_tail(messages, cut_idx, head_end)
|
||||
|
||||
# Ensure the most recent assistant message is always in the tail
|
||||
# so the previously-visible reply isn't silently rolled into the
|
||||
# ``[CONTEXT COMPACTION — REFERENCE ONLY]`` block (fixes #29824).
|
||||
# Each anchor only walks ``cut_idx`` backward, so chaining them is
|
||||
# monotonic — the tail can only grow, never shrink.
|
||||
cut_idx = self._ensure_last_assistant_message_in_tail(messages, cut_idx, head_end)
|
||||
|
||||
return max(cut_idx, head_end + 1)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -2279,8 +2070,7 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
)
|
||||
|
||||
# Phase 3: Generate structured summary
|
||||
summary_focus_topic = focus_topic or self._derive_auto_focus_topic(messages)
|
||||
summary = self._generate_summary(turns_to_summarize, focus_topic=summary_focus_topic)
|
||||
summary = self._generate_summary(turns_to_summarize, focus_topic=focus_topic)
|
||||
|
||||
# If summary generation failed, behavior splits on
|
||||
# ``abort_on_summary_failure`` (config: compression.abort_on_summary_failure):
|
||||
@@ -2360,33 +2150,32 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
|
||||
# When the summary lands as a standalone role="user" message,
|
||||
# weak models read the verbatim "## Active Task" quote of a past
|
||||
# user request as fresh input (#11475, #14521).
|
||||
# When it lands as role="assistant", models may regurgitate the
|
||||
# summary text as their own output (#33256). In both cases, append
|
||||
# the explicit end marker so the model has a clear "summary ends
|
||||
# here, respond to the message below" signal.
|
||||
if not _merge_summary_into_tail:
|
||||
summary = summary + "\n\n" + _SUMMARY_END_MARKER
|
||||
# user request as fresh input (#11475, #14521). Append the explicit
|
||||
# end marker — the same one used in the merge-into-tail path — so
|
||||
# the model has a clear "summary above, not new input" signal.
|
||||
if not _merge_summary_into_tail and summary_role == "user":
|
||||
summary = (
|
||||
summary
|
||||
+ "\n\n--- END OF CONTEXT SUMMARY — "
|
||||
"respond to the message below, not the summary above ---"
|
||||
)
|
||||
|
||||
if not _merge_summary_into_tail:
|
||||
compressed.append({
|
||||
"role": summary_role,
|
||||
"content": summary,
|
||||
COMPRESSED_SUMMARY_METADATA_KEY: True,
|
||||
})
|
||||
compressed.append({"role": summary_role, "content": summary})
|
||||
|
||||
for i in range(compress_end, n_messages):
|
||||
msg = messages[i].copy()
|
||||
if _merge_summary_into_tail and i == compress_end:
|
||||
merged_prefix = summary + "\n\n" + _SUMMARY_END_MARKER + "\n\n"
|
||||
merged_prefix = (
|
||||
summary
|
||||
+ "\n\n--- END OF CONTEXT SUMMARY — "
|
||||
"respond to the message below, not the summary above ---\n\n"
|
||||
)
|
||||
msg["content"] = _append_text_to_content(
|
||||
msg.get("content"),
|
||||
merged_prefix,
|
||||
prepend=True,
|
||||
)
|
||||
# Mark the merged message so frontends can identify it as
|
||||
# containing a compression summary prefix.
|
||||
msg[COMPRESSED_SUMMARY_METADATA_KEY] = True
|
||||
_merge_summary_into_tail = False
|
||||
compressed.append(msg)
|
||||
|
||||
|
||||
@@ -40,16 +40,6 @@ from agent.model_metadata import estimate_request_tokens_rough
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Stable marker the gateway matches on to re-tag the auto-compaction lifecycle
|
||||
# status as ``kind="compacting"`` (tui_gateway/server.py::_status_update), so
|
||||
# drivers like the desktop app can show an explicit "Summarizing…" indicator
|
||||
# instead of the transcript appearing to silently reset. Keep the marker phrase
|
||||
# intact if you reword COMPACTION_STATUS.
|
||||
COMPACTION_STATUS_MARKER = "Compacting context"
|
||||
COMPACTION_STATUS = (
|
||||
f"🗜️ {COMPACTION_STATUS_MARKER} — summarizing earlier conversation so I can continue..."
|
||||
)
|
||||
|
||||
|
||||
def _compression_lock_holder(agent: Any) -> str:
|
||||
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
|
||||
@@ -334,7 +324,9 @@ def compress_context(
|
||||
f"{approx_tokens:,}" if approx_tokens else "unknown", agent.model,
|
||||
focus_topic,
|
||||
)
|
||||
agent._emit_status(COMPACTION_STATUS)
|
||||
agent._emit_status(
|
||||
"🗜️ Compacting context — summarizing earlier conversation so I can continue..."
|
||||
)
|
||||
|
||||
# ── Compression lock ────────────────────────────────────────────────
|
||||
# Atomic, state.db-backed lock per session_id. Without this, two
|
||||
@@ -603,20 +595,6 @@ def compress_context(
|
||||
force=True,
|
||||
)
|
||||
|
||||
# Emit session:compress event so hooks (e.g. MemPalace sync) can ingest
|
||||
# the completed old session before its details are lost.
|
||||
_old_sid_for_event = locals().get("old_session_id")
|
||||
if getattr(agent, "event_callback", None):
|
||||
try:
|
||||
agent.event_callback("session:compress", {
|
||||
"platform": agent.platform or "",
|
||||
"session_id": agent.session_id,
|
||||
"old_session_id": _old_sid_for_event or "",
|
||||
"compression_count": agent.context_compressor.compression_count,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug("event_callback error on session:compress: %s", e)
|
||||
|
||||
# Keep the post-compression rough estimate for diagnostics, but do not
|
||||
# treat it as provider-reported prompt usage. Schema-heavy rough estimates
|
||||
# can remain above threshold even after the next real API request fits.
|
||||
@@ -653,11 +631,7 @@ def compress_context(
|
||||
return compressed, new_system_prompt
|
||||
|
||||
|
||||
def try_shrink_image_parts_in_messages(
|
||||
api_messages: list,
|
||||
*,
|
||||
max_dimension: int = 8000,
|
||||
) -> bool:
|
||||
def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
"""Re-encode all native image parts at a smaller size to recover from
|
||||
image-too-large errors (Anthropic 5 MB, unknown other providers).
|
||||
|
||||
@@ -668,8 +642,7 @@ def try_shrink_image_parts_in_messages(
|
||||
Strategy: look for ``image_url`` / ``input_image`` parts carrying a
|
||||
``data:image/...;base64,...`` payload. For each one whose encoded
|
||||
size exceeds 4 MB (a safe target that slides under Anthropic's 5 MB
|
||||
ceiling with header overhead) or whose longest side exceeds
|
||||
``max_dimension``, write the base64 to a tempfile, call
|
||||
ceiling with header overhead), write the base64 to a tempfile, call
|
||||
``vision_tools._resize_image_for_vision`` to produce a smaller data
|
||||
URL, and substitute it in place.
|
||||
|
||||
@@ -691,9 +664,10 @@ def try_shrink_image_parts_in_messages(
|
||||
# after a confirmed provider rejection, so the alternative is failure.
|
||||
target_bytes = 4 * 1024 * 1024
|
||||
# Anthropic enforces an 8000px per-side dimension cap independently of
|
||||
# the 5 MB byte cap. In many-image requests, the provider can report a
|
||||
# lower cap (observed: 2000px). The caller passes that parsed ceiling
|
||||
# when the rejection includes it.
|
||||
# the 5 MB byte cap. A tall screenshot can be well under 5 MB yet far
|
||||
# over 8000px (e.g. 1200×12000 at 0.06 MB). We check pixel dimensions
|
||||
# even when the byte budget is fine.
|
||||
max_dimension = 8000
|
||||
changed_count = 0
|
||||
# Track parts that are over the target but could NOT be shrunk under it.
|
||||
# If any survive, retrying is pointless — the same oversized payload will
|
||||
@@ -710,9 +684,9 @@ def try_shrink_image_parts_in_messages(
|
||||
# Check both byte size AND pixel dimensions.
|
||||
needs_shrink = len(url) > target_bytes # over byte budget
|
||||
if not needs_shrink:
|
||||
# Even if bytes are fine, check pixel dimensions against the
|
||||
# provider's reported per-side cap. A screenshot can be tiny in
|
||||
# bytes yet too large in pixels.
|
||||
# Even if bytes are fine, check pixel dimensions against
|
||||
# Anthropic's 8000px cap. A tall image can be tiny in bytes
|
||||
# yet huge in pixels.
|
||||
try:
|
||||
import base64 as _b64_dim
|
||||
header_d, _, data_d = url.partition(",")
|
||||
@@ -821,8 +795,6 @@ def try_shrink_image_parts_in_messages(
|
||||
|
||||
|
||||
__all__ = [
|
||||
"COMPACTION_STATUS",
|
||||
"COMPACTION_STATUS_MARKER",
|
||||
"check_compression_model_feasibility",
|
||||
"replay_compression_warning",
|
||||
"compress_context",
|
||||
|
||||
@@ -71,35 +71,6 @@ logger = logging.getLogger(__name__)
|
||||
INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model response ("
|
||||
|
||||
|
||||
def _image_error_max_dimension(error: Exception) -> Optional[int]:
|
||||
"""Extract a provider-reported image dimension ceiling, if present."""
|
||||
parts = []
|
||||
for value in (
|
||||
error,
|
||||
getattr(error, "message", None),
|
||||
getattr(error, "body", None),
|
||||
):
|
||||
if value:
|
||||
try:
|
||||
parts.append(str(value))
|
||||
except Exception:
|
||||
pass
|
||||
text = " ".join(parts).lower()
|
||||
if "image" not in text or "dimension" not in text or "max allowed size" not in text:
|
||||
return None
|
||||
|
||||
match = re.search(r"max allowed size(?:\s+for [^:]+)?:\s*(\d{3,5})\s*pixels?", text)
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
max_dimension = int(match.group(1))
|
||||
except ValueError:
|
||||
return None
|
||||
if 512 <= max_dimension <= 8000:
|
||||
return max_dimension
|
||||
return None
|
||||
|
||||
|
||||
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):
|
||||
@@ -300,20 +271,11 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
|
||||
agent.session_id, exc,
|
||||
)
|
||||
|
||||
if stored_prompt and _stored_prompt_matches_runtime(agent, stored_prompt):
|
||||
if stored_prompt:
|
||||
# Continuing session — reuse the exact system prompt from the
|
||||
# previous turn so the Anthropic cache prefix matches.
|
||||
agent._cached_system_prompt = stored_prompt
|
||||
return
|
||||
if stored_prompt:
|
||||
stored_state = "stale_runtime"
|
||||
logger.info(
|
||||
"Stored system prompt for session %s has stale runtime identity; "
|
||||
"rebuilding for model=%s provider=%s.",
|
||||
agent.session_id,
|
||||
getattr(agent, "model", "") or "",
|
||||
getattr(agent, "provider", "") or "",
|
||||
)
|
||||
|
||||
if conversation_history and stored_state in ("null", "empty"):
|
||||
# Continuing session whose stored prompt is unusable. The
|
||||
@@ -375,30 +337,6 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
|
||||
)
|
||||
|
||||
|
||||
def _stored_prompt_matches_runtime(agent, prompt: str) -> bool:
|
||||
"""Return False when the persisted Model/Provider lines are stale."""
|
||||
|
||||
def line_value(label: str) -> str:
|
||||
prefix = f"{label}:"
|
||||
value = ""
|
||||
for line in prompt.splitlines():
|
||||
if line.startswith(prefix):
|
||||
value = line[len(prefix):].strip()
|
||||
return value
|
||||
|
||||
stored_model = line_value("Model")
|
||||
current_model = str(getattr(agent, "model", "") or "").strip()
|
||||
if stored_model and current_model and stored_model != current_model:
|
||||
return False
|
||||
|
||||
stored_provider = line_value("Provider")
|
||||
current_provider = str(getattr(agent, "provider", "") or "").strip()
|
||||
if stored_provider and current_provider and stored_provider != current_provider:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List[str]] = None) -> str:
|
||||
if is_partial_stub and dropped_tools:
|
||||
tool_list = ", ".join(dropped_tools[:3])
|
||||
@@ -430,42 +368,6 @@ def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List
|
||||
)
|
||||
|
||||
|
||||
# Shared recovery hint appended to every content-policy refusal message. Both
|
||||
# the HTTP-200 refusal path (``finish_reason=content_filter``) and the
|
||||
# exception path (a provider moderation error classified as
|
||||
# ``content_policy_blocked``) end with the same actionable next steps, so they
|
||||
# share one trailer to keep the guidance from drifting between the two sites.
|
||||
_CONTENT_POLICY_RECOVERY_HINT = (
|
||||
"Try rephrasing the request, narrowing the context, or "
|
||||
"adding a fallback provider with `hermes fallback add`."
|
||||
)
|
||||
|
||||
|
||||
def _content_policy_blocked_result(
|
||||
messages: List[Dict],
|
||||
api_call_count: int,
|
||||
*,
|
||||
final_response: str,
|
||||
error_detail: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the terminal turn result for a content-policy block.
|
||||
|
||||
A content-policy refusal is deterministic for the unchanged prompt, so the
|
||||
turn ends here (no retry). Both the HTTP-200 refusal handler and the
|
||||
exception-path handler return the identical shape — a failed, non-completed
|
||||
turn carrying the user-facing message and a ``content_policy_blocked:``
|
||||
prefixed error — so they funnel through this one builder.
|
||||
"""
|
||||
return {
|
||||
"final_response": final_response,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
"failed": True,
|
||||
"error": f"content_policy_blocked: {error_detail}",
|
||||
}
|
||||
|
||||
|
||||
def run_conversation(
|
||||
agent,
|
||||
user_message: str,
|
||||
@@ -474,7 +376,6 @@ def run_conversation(
|
||||
task_id: str = None,
|
||||
stream_callback: Optional[callable] = None,
|
||||
persist_user_message: Optional[str] = None,
|
||||
persist_user_timestamp: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Run a complete conversation with tool calling until completion.
|
||||
@@ -490,8 +391,6 @@ def run_conversation(
|
||||
persist_user_message: Optional clean user message to store in
|
||||
transcripts/history when user_message contains API-only
|
||||
synthetic prefixes.
|
||||
persist_user_timestamp: Optional platform event timestamp to store
|
||||
as metadata on that persisted user message.
|
||||
or queuing follow-up prefetch work.
|
||||
|
||||
Returns:
|
||||
@@ -513,7 +412,6 @@ def run_conversation(
|
||||
task_id,
|
||||
stream_callback,
|
||||
persist_user_message,
|
||||
persist_user_timestamp,
|
||||
restore_or_build_system_prompt=_restore_or_build_system_prompt,
|
||||
install_safe_stdio=_install_safe_stdio,
|
||||
sanitize_surrogates=_sanitize_surrogates,
|
||||
@@ -697,11 +595,7 @@ def run_conversation(
|
||||
# landed after an orphan tool result). Most providers return
|
||||
# empty content on malformed sequences, which would otherwise
|
||||
# retrigger the empty-retry loop indefinitely.
|
||||
# repair_message_sequence_with_cursor also recomputes the SessionDB
|
||||
# flush cursor (_last_flushed_db_idx) when repair compacts the list,
|
||||
# so the turn-end flush doesn't skip the assistant/tool chain (#44837).
|
||||
from agent.agent_runtime_helpers import repair_message_sequence_with_cursor
|
||||
repaired_seq = repair_message_sequence_with_cursor(agent, messages)
|
||||
repaired_seq = agent._repair_message_sequence(messages)
|
||||
if repaired_seq > 0:
|
||||
request_logger.info(
|
||||
"Repaired %s message-alternation violations before request (session=%s)",
|
||||
@@ -809,10 +703,7 @@ def run_conversation(
|
||||
# a thinking-only turn. Runs on the per-call copy only — the
|
||||
# stored conversation history keeps the reasoning block for the
|
||||
# UI transcript and session persistence.
|
||||
api_messages = agent._drop_thinking_only_and_merge_users(
|
||||
api_messages,
|
||||
drop_codex_reasoning_items=agent.api_mode != "codex_responses",
|
||||
)
|
||||
api_messages = agent._drop_thinking_only_and_merge_users(api_messages)
|
||||
|
||||
# Normalize message whitespace and tool-call JSON for consistent
|
||||
# prefix matching. Ensures bit-perfect prefixes across turns,
|
||||
@@ -1421,106 +1312,6 @@ def run_conversation(
|
||||
)
|
||||
finish_reason = "length"
|
||||
|
||||
# ── Content-policy refusal (HTTP 200) ──────────────────
|
||||
# The model — or the provider's safety system — returned a
|
||||
# *successful* response whose stop/finish reason is a refusal:
|
||||
# Anthropic ``stop_reason="refusal"`` → ``content_filter``;
|
||||
# OpenAI / portal ``finish_reason="content_filter"`` or a
|
||||
# populated ``message.refusal`` (mapped in the chat_completions
|
||||
# transport); Bedrock ``guardrail_intervened``. The content is
|
||||
# typically empty, so without this branch the response falls
|
||||
# through to the empty-response / invalid-response retry loops
|
||||
# and is mis-surfaced as "rate limited" / "no content after
|
||||
# retries" — burning paid attempts reproducing a deterministic
|
||||
# refusal. Surface it clearly and stop. Mirrors the
|
||||
# exception-based ``content_policy_blocked`` recovery: try a
|
||||
# configured fallback once, otherwise return the refusal.
|
||||
if finish_reason == "content_filter":
|
||||
_refusal_transport = agent._get_transport()
|
||||
if agent.api_mode == "anthropic_messages":
|
||||
_refusal_result = _refusal_transport.normalize_response(
|
||||
response, strip_tool_prefix=agent._is_anthropic_oauth
|
||||
)
|
||||
else:
|
||||
_refusal_result = _refusal_transport.normalize_response(response)
|
||||
_refusal_text = (getattr(_refusal_result, "content", None) or "").strip()
|
||||
# Some refusals carry the explanation only in the reasoning
|
||||
# channel; fall back to it so the user sees *something*.
|
||||
if not _refusal_text:
|
||||
_refusal_text = (agent._extract_reasoning(_refusal_result) or "").strip()
|
||||
|
||||
agent._invoke_api_request_error_hook(
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
api_call_count=api_call_count,
|
||||
api_start_time=api_start_time,
|
||||
api_kwargs=api_kwargs,
|
||||
error_type="ContentPolicyBlocked",
|
||||
error_message=_refusal_text or "model declined to respond (content_filter)",
|
||||
status_code=None,
|
||||
retry_count=retry_count,
|
||||
max_retries=max_retries,
|
||||
retryable=False,
|
||||
reason=FailoverReason.content_policy_blocked.value,
|
||||
)
|
||||
|
||||
if thinking_spinner:
|
||||
thinking_spinner.stop("")
|
||||
thinking_spinner = None
|
||||
if agent.thinking_callback:
|
||||
agent.thinking_callback("")
|
||||
|
||||
# Deterministic for the unchanged prompt — never retry.
|
||||
# Try a configured fallback once (a different model may not
|
||||
# refuse); otherwise surface the refusal terminally.
|
||||
if agent._has_pending_fallback():
|
||||
agent._buffer_status(
|
||||
"⚠️ Model declined to respond (safety refusal) — trying fallback..."
|
||||
)
|
||||
if agent._try_activate_fallback():
|
||||
retry_count = 0
|
||||
compression_attempts = 0
|
||||
_retry.primary_recovery_attempted = False
|
||||
continue
|
||||
|
||||
agent._flush_status_buffer()
|
||||
_refusal_log = (
|
||||
_refusal_text[:500] + "..."
|
||||
if len(_refusal_text) > 500
|
||||
else _refusal_text
|
||||
)
|
||||
logger.warning(
|
||||
"%sModel declined to respond (finish_reason=content_filter). "
|
||||
"model=%s provider=%s refusal=%s",
|
||||
agent.log_prefix, agent.model, agent.provider,
|
||||
_refusal_log or "(no text)",
|
||||
)
|
||||
agent._emit_status(
|
||||
"⚠️ The model declined to respond to this request (safety refusal)."
|
||||
)
|
||||
|
||||
_refusal_detail = (
|
||||
f"Model's explanation: {_refusal_text}"
|
||||
if _refusal_text
|
||||
else "The model returned no explanation."
|
||||
)
|
||||
_refusal_response = (
|
||||
"⚠️ The model declined to respond to this request "
|
||||
"(safety refusal — not a Hermes/gateway failure).\n\n"
|
||||
f"{_refusal_detail}\n\n"
|
||||
f"{_CONTENT_POLICY_RECOVERY_HINT}"
|
||||
)
|
||||
|
||||
agent._cleanup_task_resources(effective_task_id)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
return _content_policy_blocked_result(
|
||||
messages,
|
||||
api_call_count,
|
||||
final_response=_refusal_response,
|
||||
error_detail=_refusal_text or "model declined (content_filter)",
|
||||
)
|
||||
|
||||
if finish_reason == "length":
|
||||
if getattr(response, "id", "") == PARTIAL_STREAM_STUB_ID:
|
||||
agent._vprint(
|
||||
@@ -2272,11 +2063,7 @@ def run_conversation(
|
||||
and not _retry.image_shrink_retry_attempted
|
||||
):
|
||||
_retry.image_shrink_retry_attempted = True
|
||||
image_max_dimension = _image_error_max_dimension(api_error) or 8000
|
||||
if agent._try_shrink_image_parts_in_messages(
|
||||
api_messages,
|
||||
max_dimension=image_max_dimension,
|
||||
):
|
||||
if agent._try_shrink_image_parts_in_messages(api_messages):
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}📐 Image(s) exceeded provider size limit — "
|
||||
f"shrank and retrying...",
|
||||
@@ -2844,13 +2631,10 @@ def run_conversation(
|
||||
except Exception:
|
||||
pass
|
||||
if _genuine_nous_rate_limit:
|
||||
# Re-enter the loop exactly once so the
|
||||
# top-of-loop Nous guard handles fallback or
|
||||
# bails cleanly. (Setting retry_count to
|
||||
# max_retries would make the while condition
|
||||
# false immediately and the guard would never
|
||||
# run -- no fallback, generic exhaustion error.)
|
||||
retry_count = max(0, max_retries - 1)
|
||||
# Skip straight to max_retries -- the
|
||||
# top-of-loop guard will handle fallback or
|
||||
# bail cleanly.
|
||||
retry_count = max_retries
|
||||
continue
|
||||
# Upstream capacity 429: fall through to normal
|
||||
# retry logic. A different model (or the same
|
||||
@@ -3292,17 +3076,20 @@ def run_conversation(
|
||||
if classified.reason == FailoverReason.content_policy_blocked:
|
||||
_summary = agent._summarize_api_error(api_error)
|
||||
_policy_response = (
|
||||
"⚠️ The model provider's safety filter blocked this request "
|
||||
"(not a Hermes/gateway failure).\n\n"
|
||||
f"⚠️ The model provider's safety filter blocked this request "
|
||||
f"(not a Hermes/gateway failure).\n\n"
|
||||
f"Provider message: {_summary}\n\n"
|
||||
f"{_CONTENT_POLICY_RECOVERY_HINT}"
|
||||
)
|
||||
return _content_policy_blocked_result(
|
||||
messages,
|
||||
api_call_count,
|
||||
final_response=_policy_response,
|
||||
error_detail=_summary,
|
||||
f"Try rephrasing the request, narrowing the context, or "
|
||||
f"adding a fallback provider with `hermes fallback add`."
|
||||
)
|
||||
return {
|
||||
"final_response": _policy_response,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
"failed": True,
|
||||
"error": f"content_policy_blocked: {_summary}",
|
||||
}
|
||||
return {
|
||||
"final_response": None,
|
||||
"messages": messages,
|
||||
|
||||
@@ -70,6 +70,16 @@ def _resolve_args() -> list[str]:
|
||||
|
||||
def _resolve_home_dir() -> str:
|
||||
"""Return a stable HOME for child ACP processes."""
|
||||
|
||||
try:
|
||||
from hermes_constants import get_subprocess_home
|
||||
|
||||
profile_home = get_subprocess_home()
|
||||
if profile_home:
|
||||
return profile_home
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
home = os.environ.get("HOME", "").strip()
|
||||
if home:
|
||||
return home
|
||||
@@ -95,10 +105,7 @@ def _resolve_home_dir() -> str:
|
||||
|
||||
def _build_subprocess_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
home = _resolve_home_dir()
|
||||
env["HOME"] = home
|
||||
from hermes_constants import apply_subprocess_home_env
|
||||
apply_subprocess_home_env(env)
|
||||
env["HOME"] = _resolve_home_dir()
|
||||
return env
|
||||
|
||||
|
||||
|
||||
@@ -286,16 +286,6 @@ def evaluate_credits_notices(
|
||||
for band in CREDITS_USAGE_BANDS: # ascending → last match wins = highest
|
||||
if uf >= band[0]:
|
||||
current_band = band
|
||||
# Top-up suppression: when the account holds purchased (top-up) credits,
|
||||
# the subscription-cap gauge is the wrong denominator — warning "90% used"
|
||||
# at a user sitting on $50 of top-up is noise (and it previously stuck
|
||||
# PERMANENTLY alongside grant_spent at >=100%). Suppress the usage band
|
||||
# entirely; the cap-reached case is covered by the grant_spent info notice
|
||||
# below, which already names the remaining top-up balance. A top-up landing
|
||||
# mid-session flips current_band → None and the clear path below removes
|
||||
# any showing band line.
|
||||
if state.purchased_micros > 0:
|
||||
current_band = None
|
||||
grant_cond = (
|
||||
state.denominator_kind == "subscription_cap"
|
||||
and uf is not None
|
||||
@@ -355,7 +345,7 @@ def evaluate_credits_notices(
|
||||
if show_depleted and "credits.depleted" not in active:
|
||||
to_show.append(
|
||||
AgentNotice(
|
||||
text="✕ Credit access paused · run /credits to top up",
|
||||
text="✕ Credit access paused · run /usage for balance",
|
||||
level="error",
|
||||
kind=CREDITS_NOTICE_KIND,
|
||||
key="credits.depleted",
|
||||
|
||||
@@ -57,11 +57,6 @@ DEFAULT_INTERVAL_HOURS = 24 * 7 # 7 days
|
||||
DEFAULT_MIN_IDLE_HOURS = 2
|
||||
DEFAULT_STALE_AFTER_DAYS = 30
|
||||
DEFAULT_ARCHIVE_AFTER_DAYS = 90
|
||||
# Consolidation (the LLM umbrella-building fork) is OFF by default. The
|
||||
# deterministic inactivity prune (apply_automatic_transitions) still runs
|
||||
# whenever the curator is enabled; only the opinionated, aux-model-cost
|
||||
# consolidation pass is opt-in.
|
||||
DEFAULT_CONSOLIDATE = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -187,22 +182,6 @@ def get_prune_builtins() -> bool:
|
||||
return bool(cfg.get("prune_builtins", True))
|
||||
|
||||
|
||||
def get_consolidate() -> bool:
|
||||
"""Whether the curator runs its LLM consolidation (umbrella-building) pass.
|
||||
|
||||
OFF by default. When off, a curator run does ONLY the deterministic
|
||||
inactivity prune (mark stale / archive long-unused skills) and skips the
|
||||
forked aux-model review entirely — no consolidation, no umbrella-building,
|
||||
no aux-model cost. Set ``curator.consolidate: true`` to opt back into the
|
||||
LLM pass that merges overlapping skills into class-level umbrellas.
|
||||
|
||||
The explicit ``hermes curator run --consolidate`` flag overrides this for
|
||||
a single invocation regardless of the config value.
|
||||
"""
|
||||
cfg = _load_config()
|
||||
return bool(cfg.get("consolidate", DEFAULT_CONSOLIDATE))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Idle / interval check
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1429,38 +1408,25 @@ def run_curator_review(
|
||||
on_summary: Optional[Callable[[str], None]] = None,
|
||||
synchronous: bool = False,
|
||||
dry_run: bool = False,
|
||||
consolidate: Optional[bool] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a single curator review pass.
|
||||
|
||||
Steps:
|
||||
1. Apply automatic state transitions (pure, no LLM).
|
||||
2. If consolidation is enabled AND there are agent-created skills, spawn
|
||||
a forked AIAgent that runs the LLM review prompt against the current
|
||||
candidate list.
|
||||
2. If there are agent-created skills, spawn a forked AIAgent that runs
|
||||
the LLM review prompt against the current candidate list.
|
||||
3. Update .curator_state with last_run_at and a one-line summary.
|
||||
4. Invoke *on_summary* with a user-visible description.
|
||||
|
||||
If *synchronous* is True, the LLM review runs in the calling thread; the
|
||||
default is to spawn a daemon thread so the caller returns immediately.
|
||||
|
||||
*consolidate* gates the LLM umbrella-building pass. ``None`` (the default)
|
||||
reads ``curator.consolidate`` from config (OFF by default). Passing
|
||||
``True``/``False`` overrides the config for this invocation — used by the
|
||||
``hermes curator run --consolidate`` flag. When consolidation is off, only
|
||||
the deterministic inactivity prune runs and the forked aux-model review is
|
||||
skipped entirely (no aux-model cost).
|
||||
|
||||
If *dry_run* is True, the automatic stale/archive transitions are SKIPPED
|
||||
and the LLM review pass is instructed to produce a report only — no
|
||||
skill_manage mutations, no terminal archive moves. The REPORT.md still
|
||||
gets written and ``state.last_report_path`` still records it so users
|
||||
can read what the curator WOULD have done. A dry-run also honors
|
||||
*consolidate*: when consolidation is off, the preview only reports the
|
||||
deterministic prune candidates.
|
||||
can read what the curator WOULD have done.
|
||||
"""
|
||||
if consolidate is None:
|
||||
consolidate = get_consolidate()
|
||||
start = datetime.now(timezone.utc)
|
||||
if dry_run:
|
||||
# Count candidates without mutating state.
|
||||
@@ -1523,53 +1489,6 @@ def run_curator_review(
|
||||
before_report = []
|
||||
before_names = {r.get("name") for r in before_report if isinstance(r, dict)}
|
||||
|
||||
# Consolidation gate. When off (the default), the curator does ONLY the
|
||||
# deterministic inactivity prune above — no forked aux-model review, no
|
||||
# umbrella-building, no aux-model cost. Record the run, write a report
|
||||
# reflecting the prune-only outcome, and return without spawning a fork.
|
||||
if not consolidate:
|
||||
final_summary = (
|
||||
f"{prefix}{auto_summary}; llm: skipped (consolidation off)"
|
||||
)
|
||||
llm_meta = {
|
||||
"final": "",
|
||||
"summary": "skipped (consolidation off)",
|
||||
"model": "",
|
||||
"provider": "",
|
||||
"tool_calls": [],
|
||||
"error": None,
|
||||
}
|
||||
elapsed = (datetime.now(timezone.utc) - start).total_seconds()
|
||||
state2 = load_state()
|
||||
state2["last_run_duration_seconds"] = elapsed
|
||||
state2["last_run_summary"] = final_summary
|
||||
try:
|
||||
after_report = skill_usage.agent_created_report()
|
||||
except Exception:
|
||||
after_report = []
|
||||
try:
|
||||
report_path = _write_run_report(
|
||||
started_at=start,
|
||||
elapsed_seconds=elapsed,
|
||||
auto_counts=counts,
|
||||
auto_summary=auto_summary,
|
||||
before_report=before_report,
|
||||
before_names=before_names,
|
||||
after_report=after_report,
|
||||
llm_meta=llm_meta,
|
||||
)
|
||||
if report_path is not None:
|
||||
state2["last_report_path"] = str(report_path)
|
||||
except Exception as e:
|
||||
logger.debug("Curator report write failed: %s", e, exc_info=True)
|
||||
save_state(state2)
|
||||
if on_summary:
|
||||
try:
|
||||
on_summary(f"curator: {final_summary}")
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
llm_meta: Dict[str, Any] = {}
|
||||
try:
|
||||
candidate_list = _render_candidate_list()
|
||||
|
||||
@@ -46,7 +46,7 @@ import shutil
|
||||
import tarfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from agent.skill_utils import is_excluded_skill_path
|
||||
@@ -208,17 +208,13 @@ def _write_manifest(dest: Path, reason: str, archive_path: Path,
|
||||
)
|
||||
|
||||
|
||||
def snapshot_skills(reason: str = "manual", *, protect_ids: Optional[Set[str]] = None) -> Optional[Path]:
|
||||
def snapshot_skills(reason: str = "manual") -> Optional[Path]:
|
||||
"""Create a tar.gz snapshot of ``~/.hermes/skills/`` and prune old ones.
|
||||
|
||||
Returns the snapshot directory path, or ``None`` if the snapshot was
|
||||
skipped (backup disabled, skills dir missing, or an IO error occurred —
|
||||
in which case we log at debug and return None so the curator never
|
||||
aborts a pass because of a backup failure).
|
||||
|
||||
``protect_ids`` is forwarded to the prune step so callers can guarantee
|
||||
specific snapshot ids survive even when they fall outside the keep
|
||||
window (rollback passes the id it is about to restore from).
|
||||
"""
|
||||
if not is_enabled():
|
||||
logger.debug("Curator backup disabled by config; skipping snapshot")
|
||||
@@ -280,19 +276,15 @@ def snapshot_skills(reason: str = "manual", *, protect_ids: Optional[Set[str]] =
|
||||
pass
|
||||
return None
|
||||
|
||||
_prune_old(keep=get_keep(), protect=protect_ids)
|
||||
_prune_old(keep=get_keep())
|
||||
logger.info("Curator snapshot created: %s (%s)", snap_id, reason)
|
||||
return dest
|
||||
|
||||
|
||||
def _prune_old(keep: int, protect: Optional[Set[str]] = None) -> List[str]:
|
||||
def _prune_old(keep: int) -> List[str]:
|
||||
"""Delete regular snapshots beyond the newest *keep*. Returns deleted
|
||||
ids. Snapshot ids in *protect* are never deleted even when they fall
|
||||
outside the keep window — rollback() uses this so the mandatory
|
||||
pre-rollback safety snapshot can never evict the very snapshot being
|
||||
restored. Staging dirs (``.rollback-staging-*``) are implementation
|
||||
detail and pruned independently on every call."""
|
||||
protect = protect or set()
|
||||
ids. Staging dirs (``.rollback-staging-*``) are implementation detail
|
||||
and pruned independently on every call."""
|
||||
backups = _backups_dir()
|
||||
if not backups.exists():
|
||||
return []
|
||||
@@ -313,8 +305,6 @@ def _prune_old(keep: int, protect: Optional[Set[str]] = None) -> List[str]:
|
||||
entries.sort(key=lambda t: t[0], reverse=True)
|
||||
deleted: List[str] = []
|
||||
for _, path in entries[keep:]:
|
||||
if path.name in protect:
|
||||
continue
|
||||
try:
|
||||
shutil.rmtree(path)
|
||||
deleted.append(path.name)
|
||||
@@ -464,16 +454,16 @@ def _restore_cron_skill_links(snapshot_dir: Path) -> Dict[str, Any]:
|
||||
report["attempted"] = True # we tried but there was nothing to do
|
||||
return report
|
||||
|
||||
# Load and rewrite the live jobs under the scheduler's cross-process lock.
|
||||
# Load and rewrite the live jobs under the scheduler's lock.
|
||||
try:
|
||||
from cron.jobs import load_jobs, save_jobs, _jobs_lock
|
||||
from cron.jobs import load_jobs, save_jobs, _jobs_file_lock
|
||||
except ImportError as e:
|
||||
report["error"] = f"cron module unavailable: {e}"
|
||||
return report
|
||||
|
||||
report["attempted"] = True
|
||||
try:
|
||||
with _jobs_lock():
|
||||
with _jobs_file_lock:
|
||||
live_jobs = load_jobs()
|
||||
changed = False
|
||||
|
||||
@@ -574,13 +564,7 @@ def rollback(backup_id: Optional[str] = None) -> Tuple[bool, str, Optional[Path]
|
||||
# out before touching anything — otherwise a failed extract could leave
|
||||
# the user with no skills.
|
||||
try:
|
||||
# Protect the target from this snapshot's prune step: at the steady
|
||||
# keep limit, pruning the oldest snapshot would otherwise delete the
|
||||
# very snapshot we are about to extract from.
|
||||
snapshot_skills(
|
||||
reason=f"pre-rollback to {target.name}",
|
||||
protect_ids={target.name},
|
||||
)
|
||||
snapshot_skills(reason=f"pre-rollback to {target.name}")
|
||||
except Exception as e:
|
||||
return (False, f"pre-rollback safety snapshot failed: {e}", None)
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from difflib import unified_diff
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from utils import safe_json_loads
|
||||
from agent.tool_result_classification import file_mutation_result_landed
|
||||
@@ -169,27 +168,6 @@ def _oneline(text: str) -> str:
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def _truncate_preview(text: str, max_len: int | None) -> str:
|
||||
if max_len and max_len > 0 and len(text) > max_len:
|
||||
if max_len <= 3:
|
||||
return "." * max_len
|
||||
return text[:max_len - 3] + "..."
|
||||
return text
|
||||
|
||||
|
||||
def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]:
|
||||
if not isinstance(tasks, list):
|
||||
return 0, []
|
||||
goals: list[str] = []
|
||||
for task in tasks:
|
||||
if not isinstance(task, dict):
|
||||
continue
|
||||
raw_goal = task.get("goal")
|
||||
goal = "?" if raw_goal is None else _oneline(str(raw_goal))
|
||||
goals.append(_truncate_preview(goal or "?", per_goal_len))
|
||||
return len(goals), goals
|
||||
|
||||
|
||||
def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -> str | None:
|
||||
"""Build a short preview of a tool call's primary argument for display.
|
||||
|
||||
@@ -213,22 +191,6 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
||||
"clarify": "question", "skill_manage": "name",
|
||||
}
|
||||
|
||||
# delegate_task: show goal (single) or individual task goals (batch)
|
||||
if tool_name == "delegate_task":
|
||||
tasks = args.get("tasks")
|
||||
if tasks and isinstance(tasks, list):
|
||||
task_count, goals = _delegate_task_goal_parts(tasks, per_goal_len=40)
|
||||
preview = (
|
||||
f"{task_count} tasks: " + " | ".join(goals)
|
||||
if goals else f"{len(tasks)} parallel tasks"
|
||||
)
|
||||
return _truncate_preview(preview, max_len)
|
||||
goal = args.get("goal", "")
|
||||
if goal is None:
|
||||
return None
|
||||
preview = _oneline(str(goal))
|
||||
return _truncate_preview(preview, max_len) if preview else None
|
||||
|
||||
if tool_name == "process":
|
||||
action = args.get("action", "")
|
||||
sid = args.get("session_id", "")
|
||||
@@ -896,6 +858,20 @@ def _detect_tool_failure(tool_name: str, result: str | None) -> tuple[bool, str]
|
||||
return False, ""
|
||||
|
||||
|
||||
def _used_free_parallel(result: str | None) -> bool:
|
||||
"""True when a web result came from Parallel's free Search MCP.
|
||||
|
||||
Only the keyless Parallel path tags its result with ``provider="parallel"``;
|
||||
the paid REST path and every other provider omit it. Used to label the tool
|
||||
line "Parallel search" / "Parallel fetch" exactly when the free MCP served
|
||||
the call.
|
||||
"""
|
||||
if not isinstance(result, str) or '"provider"' not in result:
|
||||
return False
|
||||
data = safe_json_loads(result)
|
||||
return isinstance(data, dict) and str(data.get("provider", "")).lower() == "parallel"
|
||||
|
||||
|
||||
def get_cute_tool_message(
|
||||
tool_name: str, args: dict, duration: float, result: str | None = None,
|
||||
) -> str:
|
||||
@@ -933,15 +909,17 @@ def get_cute_tool_message(
|
||||
return f"{line}{failure_suffix}"
|
||||
|
||||
if tool_name == "web_search":
|
||||
return _wrap(f"┊ 🔍 search {_trunc(args.get('query', ''), 42)} {dur}")
|
||||
verb = "Parallel search" if _used_free_parallel(result) else "search"
|
||||
return _wrap(f"┊ 🔍 {verb:<9} {_trunc(args.get('query', ''), 42)} {dur}")
|
||||
if tool_name == "web_extract":
|
||||
verb = "Parallel fetch" if _used_free_parallel(result) else "fetch"
|
||||
urls = args.get("urls", [])
|
||||
if urls:
|
||||
url = urls[0] if isinstance(urls, list) else str(urls)
|
||||
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
extra = f" +{len(urls)-1}" if len(urls) > 1 else ""
|
||||
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
|
||||
return _wrap(f"┊ 📄 fetch pages {dur}")
|
||||
return _wrap(f"┊ 📄 {verb:<9} {_trunc(domain, 35)}{extra} {dur}")
|
||||
return _wrap(f"┊ 📄 {verb:<9} pages {dur}")
|
||||
if tool_name == "terminal":
|
||||
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
|
||||
if tool_name == "process":
|
||||
@@ -1057,10 +1035,7 @@ def get_cute_tool_message(
|
||||
if tool_name == "delegate_task":
|
||||
tasks = args.get("tasks")
|
||||
if tasks and isinstance(tasks, list):
|
||||
task_count, goals = _delegate_task_goal_parts(tasks, per_goal_len=30)
|
||||
detail = " | ".join(goals) if goals else "parallel"
|
||||
count_label = task_count or len(tasks)
|
||||
return _wrap(f"┊ 🔀 delegate {count_label}x: {_trunc(detail, 35)} {dur}")
|
||||
return _wrap(f"┊ 🔀 delegate {len(tasks)} parallel tasks {dur}")
|
||||
return _wrap(f"┊ 🔀 delegate {_trunc(args.get('goal', ''), 35)} {dur}")
|
||||
|
||||
preview = build_tool_preview(tool_name, args) or ""
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
class SSLConfigurationError(Exception):
|
||||
"""Raised when SSL/TLS certificate bundle configuration fails."""
|
||||
pass
|
||||
@@ -46,6 +46,11 @@ def build_write_denied_paths(home: str) -> set[str]:
|
||||
# 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"),
|
||||
os.path.join(home, ".bash_profile"),
|
||||
os.path.join(home, ".zprofile"),
|
||||
os.path.join(home, ".netrc"),
|
||||
os.path.join(home, ".pgpass"),
|
||||
os.path.join(home, ".npmrc"),
|
||||
@@ -99,6 +104,12 @@ 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 = []
|
||||
@@ -111,6 +122,12 @@ def is_write_denied(path: str) -> bool:
|
||||
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):
|
||||
|
||||
@@ -41,16 +41,6 @@ DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535
|
||||
|
||||
|
||||
def bare_gemini_model_id(model: str) -> str:
|
||||
"""Strip Gemini's own provider prefix from an aggregator-style model id."""
|
||||
name = (model or "").strip()
|
||||
lowered = name.lower()
|
||||
for prefix in ("google/", "gemini/"):
|
||||
if lowered.startswith(prefix):
|
||||
return name[len(prefix):].strip() or name
|
||||
return name
|
||||
|
||||
|
||||
def is_native_gemini_base_url(base_url: str) -> bool:
|
||||
"""Return True when the endpoint speaks Gemini's native REST API."""
|
||||
normalized = str(base_url or "").strip().rstrip("/").lower()
|
||||
@@ -340,7 +330,7 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st
|
||||
system_instruction = None
|
||||
joined_system = "\n".join(part for part in system_text_parts if part).strip()
|
||||
if joined_system:
|
||||
system_instruction = {"role": "system", "parts": [{"text": joined_system}]}
|
||||
system_instruction = {"parts": [{"text": joined_system}]}
|
||||
return contents, system_instruction
|
||||
|
||||
|
||||
@@ -924,7 +914,6 @@ class GeminiNativeClient:
|
||||
thinking_config=thinking_config,
|
||||
)
|
||||
|
||||
model = bare_gemini_model_id(model)
|
||||
if stream:
|
||||
return self._stream_completion(model=model, request=request, timeout=timeout)
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from agent.skill_commands import extract_user_instruction_from_skill_message
|
||||
from tools.registry import tool_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -45,66 +44,6 @@ logger = logging.getLogger(__name__)
|
||||
_SYNC_DRAIN_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool:
|
||||
"""Return whether external memory-provider tools should be exposed."""
|
||||
if enabled_toolsets is None:
|
||||
return True
|
||||
if not enabled_toolsets:
|
||||
return False
|
||||
if "memory" in enabled_toolsets:
|
||||
return True
|
||||
|
||||
try:
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
return any("memory" in resolve_toolset(name) for name in enabled_toolsets)
|
||||
except Exception:
|
||||
logger.debug("Failed to resolve enabled toolsets for memory-provider tools", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def inject_memory_provider_tools(agent: Any) -> int:
|
||||
"""Append external memory-provider tool schemas to an agent tool surface."""
|
||||
memory_manager = getattr(agent, "_memory_manager", None)
|
||||
tools = getattr(agent, "tools", None)
|
||||
if not memory_manager or tools is None:
|
||||
return 0
|
||||
|
||||
existing_tool_names = {
|
||||
tool.get("function", {}).get("name")
|
||||
for tool in tools
|
||||
if isinstance(tool, dict)
|
||||
}
|
||||
if (
|
||||
"memory" not in existing_tool_names
|
||||
and not memory_provider_tools_enabled(getattr(agent, "enabled_toolsets", None))
|
||||
):
|
||||
return 0
|
||||
|
||||
get_schemas = getattr(memory_manager, "get_all_tool_schemas", None)
|
||||
if not callable(get_schemas):
|
||||
return 0
|
||||
|
||||
valid_tool_names = getattr(agent, "valid_tool_names", None)
|
||||
if valid_tool_names is None:
|
||||
valid_tool_names = set()
|
||||
agent.valid_tool_names = valid_tool_names
|
||||
|
||||
added = 0
|
||||
for schema in get_schemas():
|
||||
if not isinstance(schema, dict):
|
||||
continue
|
||||
tool_name = schema.get("name", "")
|
||||
if not tool_name or tool_name in existing_tool_names:
|
||||
continue
|
||||
tools.append({"type": "function", "function": schema})
|
||||
valid_tool_names.add(tool_name)
|
||||
existing_tool_names.add(tool_name)
|
||||
added += 1
|
||||
|
||||
return added
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context fencing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -431,37 +370,16 @@ class MemoryManager:
|
||||
|
||||
# -- Prefetch / recall ---------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _strip_skill_scaffolding(text: str) -> Optional[str]:
|
||||
"""Return memory-worthy user text, or None to skip the turn.
|
||||
|
||||
When a user invokes a /skill or /bundle, Hermes expands the turn into
|
||||
a model-facing message that embeds the entire skill body. Feeding that
|
||||
verbatim to memory providers pollutes their stores/embeddings with
|
||||
prompt scaffolding instead of what the user actually asked. We recover
|
||||
just the user's instruction here, once, for every provider — so this
|
||||
is fixed for the whole provider fan-out, not per backend.
|
||||
|
||||
- Non-skill messages pass through unchanged.
|
||||
- Skill turns with a user instruction return that instruction.
|
||||
- Bare skill invocations (no instruction) return None → callers skip
|
||||
the turn, since there is no user content worth remembering.
|
||||
"""
|
||||
return extract_user_instruction_from_skill_message(text)
|
||||
|
||||
def prefetch_all(self, query: str, *, session_id: str = "") -> str:
|
||||
"""Collect prefetch context from all providers.
|
||||
|
||||
Returns merged context text labeled by provider. Empty providers
|
||||
are skipped. Failures in one provider don't block others.
|
||||
"""
|
||||
clean_query = self._strip_skill_scaffolding(query)
|
||||
if not clean_query:
|
||||
return ""
|
||||
parts = []
|
||||
for provider in self._providers:
|
||||
try:
|
||||
result = provider.prefetch(clean_query, session_id=session_id)
|
||||
result = provider.prefetch(query, session_id=session_id)
|
||||
if result and result.strip():
|
||||
parts.append(result)
|
||||
except Exception as e:
|
||||
@@ -482,14 +400,10 @@ class MemoryManager:
|
||||
if not providers:
|
||||
return
|
||||
|
||||
clean_query = self._strip_skill_scaffolding(query)
|
||||
if not clean_query:
|
||||
return
|
||||
|
||||
def _run() -> None:
|
||||
for provider in providers:
|
||||
try:
|
||||
provider.queue_prefetch(clean_query, session_id=session_id)
|
||||
provider.queue_prefetch(query, session_id=session_id)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Memory provider '%s' queue_prefetch failed (non-fatal): %s",
|
||||
@@ -541,11 +455,6 @@ class MemoryManager:
|
||||
if not providers:
|
||||
return
|
||||
|
||||
clean_user_content = self._strip_skill_scaffolding(user_content)
|
||||
if not clean_user_content:
|
||||
return
|
||||
user_content = clean_user_content
|
||||
|
||||
def _run() -> None:
|
||||
for provider in providers:
|
||||
try:
|
||||
|
||||
@@ -5,7 +5,6 @@ and run_agent.py for pre-flight context checks.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -17,7 +16,7 @@ from urllib.parse import urlparse
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
from utils import atomic_json_write, base_url_host_matches, base_url_hostname
|
||||
from utils import base_url_host_matches, base_url_hostname
|
||||
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
|
||||
@@ -112,57 +111,6 @@ _endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
||||
_endpoint_model_metadata_cache_time: Dict[str, float] = {}
|
||||
_ENDPOINT_MODEL_CACHE_TTL = 300
|
||||
|
||||
|
||||
def _get_model_metadata_cache_path() -> Path:
|
||||
"""Return path to the OpenRouter model metadata disk cache."""
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home() / "cache" / "openrouter_model_metadata.json"
|
||||
|
||||
|
||||
def _model_metadata_disk_cache_age_seconds() -> Optional[float]:
|
||||
"""Return disk-cache age in seconds, or None if freshness is unknown."""
|
||||
try:
|
||||
cache_path = _get_model_metadata_cache_path()
|
||||
if not cache_path.exists():
|
||||
return None
|
||||
age = time.time() - cache_path.stat().st_mtime
|
||||
if age < 0:
|
||||
return None
|
||||
return age
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _load_model_metadata_disk_cache() -> Dict[str, Dict[str, Any]]:
|
||||
"""Load processed OpenRouter metadata cache from disk."""
|
||||
try:
|
||||
cache_path = _get_model_metadata_cache_path()
|
||||
with cache_path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return {
|
||||
str(key): value
|
||||
for key, value in data.items()
|
||||
if isinstance(value, dict)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug("Failed to load OpenRouter model metadata disk cache: %s", e)
|
||||
return {}
|
||||
|
||||
|
||||
def _save_model_metadata_disk_cache(data: Dict[str, Dict[str, Any]]) -> None:
|
||||
"""Save processed OpenRouter metadata cache to disk atomically."""
|
||||
try:
|
||||
atomic_json_write(
|
||||
_get_model_metadata_cache_path(),
|
||||
data,
|
||||
indent=0,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to save OpenRouter model metadata disk cache: %s", e)
|
||||
|
||||
# Descending tiers for context length probing when the model is unknown.
|
||||
# We start at 256K (covers GPT-5.x, many current large-context models) and
|
||||
# step down on context-length errors until one works. Tier[0] is also the
|
||||
@@ -261,13 +209,7 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
# https://platform.minimax.io/docs/api-reference/text-chat-openai
|
||||
"minimax-m3": 1000000,
|
||||
"minimax": 204800,
|
||||
# GLM — GLM-5.2 ships with a 1M context window (verified empirically:
|
||||
# needle-in-a-haystack retrieval at 789K prompt tokens succeeded with
|
||||
# zero errors on api.z.ai/api/coding/paas/v4). Older GLM models
|
||||
# (5, 5.1, 5-turbo) are ~202K. Longest-key-first substring matching
|
||||
# ensures "glm-5.2" resolves to 1M while older variants still hit the
|
||||
# generic 202K fallback.
|
||||
"glm-5.2": 1_048_576,
|
||||
# GLM
|
||||
"glm": 202752,
|
||||
# xAI Grok — xAI /v1/models does not return context_length metadata,
|
||||
# so these hardcoded fallbacks prevent Hermes from probing-down to
|
||||
@@ -275,7 +217,6 @@ 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-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI)
|
||||
"grok-build": 256000, # grok-build-0.1
|
||||
"grok-code-fast": 256000, # grok-code-fast-1
|
||||
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest
|
||||
@@ -686,15 +627,6 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
|
||||
if not force_refresh and _model_metadata_cache and (time.time() - _model_metadata_cache_time) < _MODEL_CACHE_TTL:
|
||||
return _model_metadata_cache
|
||||
|
||||
if not force_refresh:
|
||||
disk_age = _model_metadata_disk_cache_age_seconds()
|
||||
if disk_age is not None and disk_age < _MODEL_CACHE_TTL:
|
||||
disk_cache = _load_model_metadata_disk_cache()
|
||||
if disk_cache:
|
||||
_model_metadata_cache = disk_cache
|
||||
_model_metadata_cache_time = time.time() - disk_age
|
||||
return _model_metadata_cache
|
||||
|
||||
try:
|
||||
response = requests.get(OPENROUTER_MODELS_URL, timeout=10, verify=_resolve_requests_verify())
|
||||
response.raise_for_status()
|
||||
@@ -716,24 +648,12 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
|
||||
|
||||
_model_metadata_cache = cache
|
||||
_model_metadata_cache_time = time.time()
|
||||
_save_model_metadata_disk_cache(cache)
|
||||
logger.debug("Fetched metadata for %s models from OpenRouter", len(cache))
|
||||
return cache
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch model metadata from OpenRouter: {e}")
|
||||
if _model_metadata_cache:
|
||||
return _model_metadata_cache
|
||||
disk_cache = _load_model_metadata_disk_cache()
|
||||
if disk_cache:
|
||||
_model_metadata_cache = disk_cache
|
||||
disk_age = _model_metadata_disk_cache_age_seconds()
|
||||
if disk_age is not None:
|
||||
_model_metadata_cache_time = time.time() - min(disk_age, _MODEL_CACHE_TTL)
|
||||
else:
|
||||
_model_metadata_cache_time = time.time() - _MODEL_CACHE_TTL + 1
|
||||
return _model_metadata_cache
|
||||
return {}
|
||||
return _model_metadata_cache or {}
|
||||
|
||||
|
||||
def fetch_endpoint_model_metadata(
|
||||
|
||||
@@ -135,14 +135,7 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
||||
|
||||
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Infer a reasonable ``type`` if this schema node has none."""
|
||||
node_type = node.get("type")
|
||||
if isinstance(node_type, list):
|
||||
concrete = next(
|
||||
(t for t in node_type if isinstance(t, str) and t not in {"", "null"}),
|
||||
"string",
|
||||
)
|
||||
return {**node, "type": concrete}
|
||||
if "type" in node and node_type not in {None, ""}:
|
||||
if "type" in node and node["type"] not in {None, ""}:
|
||||
return node
|
||||
|
||||
# Heuristic: presence of ``properties`` → object, ``items`` → array, ``enum``
|
||||
|
||||
@@ -8,7 +8,6 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import contextvars
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
|
||||
@@ -490,41 +489,15 @@ PLATFORM_HINTS = {
|
||||
"files arrive as downloadable documents. You can also include image "
|
||||
"URLs in markdown format  and they will be sent as photos."
|
||||
),
|
||||
"whatsapp_cloud": (
|
||||
"You are on a text messaging communication platform, WhatsApp "
|
||||
"(via Meta's official Business Cloud API). Standard markdown "
|
||||
"(**bold**, ~~strike~~, # headers, [links](url)) is auto-converted "
|
||||
"to WhatsApp's native syntax (*bold*, ~strike~, etc.) — feel free "
|
||||
"to write in markdown. Tables are NOT supported — prefer bullet "
|
||||
"lists or labeled key:value pairs. "
|
||||
"You can send media files natively: include MEDIA:/absolute/path/to/file "
|
||||
"in your response. Images (.jpg, .png) become photo attachments, "
|
||||
"videos (.mp4) play inline, audio (.mp3, .ogg) sends as voice/audio "
|
||||
"messages, other files arrive as documents. Image URLs in markdown "
|
||||
"format  also work. "
|
||||
"IMPORTANT: this platform has a 24-hour conversation window — if the "
|
||||
"user hasn't messaged in 24h, free-form replies are refused by Meta "
|
||||
"(error 131047). This rarely matters for live chat, but is worth "
|
||||
"knowing if you're scheduling a delayed message."
|
||||
),
|
||||
"telegram": (
|
||||
"You are on a text messaging communication platform, Telegram. "
|
||||
"Standard Markdown is automatically converted to Telegram formatting. "
|
||||
"Standard markdown is automatically converted to Telegram format. "
|
||||
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
|
||||
"`inline code`, ```code blocks```, [links](url), and ## headers. "
|
||||
"Telegram now supports rich Markdown, so lean into it: whenever it "
|
||||
"makes the answer clearer or easier to scan, actively reach for real "
|
||||
"Markdown tables (pipe `| col | col |` syntax), bullet and numbered "
|
||||
"lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, "
|
||||
"collapsible details, footnotes/references, math/formulas (`$...$`, "
|
||||
"`$$...$$`), underline, subscript/superscript, marked (highlighted) "
|
||||
"text, and anchors. Default to structured formatting over dense "
|
||||
"paragraphs for any comparison, set of steps, key/value summary, or "
|
||||
"tabular data. Prefer real Markdown tables and task lists over "
|
||||
"hand-built bullet substitutes when presenting structured data; these "
|
||||
"degrade gracefully (tables become readable bullet groups) when rich "
|
||||
"rendering is unavailable, but advanced constructs like math and "
|
||||
"collapsible details may render as plain source text in that case. "
|
||||
"Telegram has NO table syntax — prefer bullet lists or labeled "
|
||||
"key: value pairs over pipe tables (any tables you do emit are "
|
||||
"auto-rewritten into row-group bullets, which you can produce "
|
||||
"directly for cleaner output). "
|
||||
"You can send media files natively: to deliver a file to the user, "
|
||||
"include MEDIA:/absolute/path/to/file in your response. Images "
|
||||
"(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
|
||||
@@ -958,80 +931,6 @@ CONTEXT_FILE_MAX_CHARS = 20_000
|
||||
CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
|
||||
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
|
||||
|
||||
# Dynamic-cap parameters (used when no explicit context_file_max_chars is set).
|
||||
# The cap scales with the model's context window so large-context models rarely
|
||||
# truncate a project doc, while small-context models stay at the historical
|
||||
# 20K floor. ~4 chars/token is the usual English heuristic; we spend a small
|
||||
# slice of the window on context files since they share the cached prefix with
|
||||
# the system prompt, tools, memory, and the whole conversation.
|
||||
_CONTEXT_FILE_CHARS_PER_TOKEN = 4
|
||||
_CONTEXT_FILE_WINDOW_FRACTION = 0.06
|
||||
_CONTEXT_FILE_DYNAMIC_CEILING = 500_000
|
||||
|
||||
|
||||
def _dynamic_context_file_max_chars(context_length: Optional[int]) -> int:
|
||||
"""Derive a char cap from the model's context window.
|
||||
|
||||
Returns at least ``CONTEXT_FILE_MAX_CHARS`` (the historical 20K floor) and
|
||||
at most ``_CONTEXT_FILE_DYNAMIC_CEILING``. When ``context_length`` is
|
||||
unknown/invalid, returns the flat default so behavior is unchanged.
|
||||
"""
|
||||
if not isinstance(context_length, int) or context_length <= 0:
|
||||
return CONTEXT_FILE_MAX_CHARS
|
||||
budget = int(
|
||||
context_length * _CONTEXT_FILE_CHARS_PER_TOKEN * _CONTEXT_FILE_WINDOW_FRACTION
|
||||
)
|
||||
return max(CONTEXT_FILE_MAX_CHARS, min(budget, _CONTEXT_FILE_DYNAMIC_CEILING))
|
||||
|
||||
|
||||
def _get_context_file_max_chars(context_length: Optional[int] = None) -> int:
|
||||
"""Return the context-file truncation limit.
|
||||
|
||||
Resolution order:
|
||||
1. Explicit ``context_file_max_chars`` in config.yaml — user knows best,
|
||||
always wins (including over the dynamic cap).
|
||||
2. Dynamic cap derived from the model's ``context_length`` when provided
|
||||
(scales the budget to the window; floor 20K, ceiling 500K).
|
||||
3. ``CONTEXT_FILE_MAX_CHARS`` (20K) as the upstream-compatible fallback.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
val = load_config().get("context_file_max_chars")
|
||||
if isinstance(val, (int, float)) and val > 0:
|
||||
return int(val)
|
||||
except Exception as e:
|
||||
logger.debug("Could not read context_file_max_chars from config: %s", e)
|
||||
return _dynamic_context_file_max_chars(context_length)
|
||||
|
||||
# Collect truncation warnings so the caller (run_agent) can surface them.
|
||||
# A ContextVar (not a module-global list) isolates accumulation per thread /
|
||||
# per async task, so concurrent gateway-session prompt builds can't drain or
|
||||
# clear each other's pending warnings (cross-session leak). Each build runs in
|
||||
# its own context, collects its own warnings, and drains them synchronously.
|
||||
_truncation_warnings: "contextvars.ContextVar[Optional[list]]" = contextvars.ContextVar(
|
||||
"context_file_truncation_warnings", default=None
|
||||
)
|
||||
|
||||
|
||||
def _record_truncation_warning(msg: str) -> None:
|
||||
"""Append a truncation warning to the current context's accumulator."""
|
||||
warnings = _truncation_warnings.get()
|
||||
if warnings is None:
|
||||
warnings = []
|
||||
_truncation_warnings.set(warnings)
|
||||
warnings.append(msg)
|
||||
|
||||
|
||||
def drain_truncation_warnings() -> list:
|
||||
"""Return and clear any truncation warnings accumulated in this context."""
|
||||
warnings = _truncation_warnings.get()
|
||||
if not warnings:
|
||||
return []
|
||||
drained = list(warnings)
|
||||
warnings.clear()
|
||||
return drained
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Skills prompt cache
|
||||
@@ -1239,7 +1138,7 @@ def build_skills_system_prompt(
|
||||
or get_session_env("HERMES_SESSION_PLATFORM")
|
||||
or ""
|
||||
)
|
||||
disabled = get_disabled_skill_names(_platform_hint or None)
|
||||
disabled = get_disabled_skill_names()
|
||||
cache_key = (
|
||||
str(skills_dir.resolve()),
|
||||
tuple(str(d) for d in external_dirs),
|
||||
@@ -1519,13 +1418,13 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
|
||||
|
||||
lines = [
|
||||
"# Nous Subscription",
|
||||
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, OpenAI Whisper STT, and browser automation (Browser Use) by default. Modal execution is optional.",
|
||||
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browser Use) by default. Modal execution is optional.",
|
||||
"Current capability status:",
|
||||
]
|
||||
lines.extend(_status_line(feature) for feature in features.items())
|
||||
lines.extend(
|
||||
[
|
||||
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, OpenAI Whisper, or Browser-Use API keys.",
|
||||
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browser-Use API keys.",
|
||||
"If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives.",
|
||||
"Do not mention subscription unless the user asks about it or it directly solves the current missing capability.",
|
||||
"Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status.",
|
||||
@@ -1538,47 +1437,19 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
|
||||
# Context files (SOUL.md, AGENTS.md, .cursorrules)
|
||||
# =========================================================================
|
||||
|
||||
def _truncate_content(
|
||||
content: str,
|
||||
filename: str,
|
||||
max_chars: Optional[int] = None,
|
||||
context_length: Optional[int] = None,
|
||||
read_path: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Head/tail truncation with a marker in the middle.
|
||||
|
||||
``filename`` is the human label used in warnings. ``read_path`` is the
|
||||
concrete path the agent should ``read_file`` to recover the full content
|
||||
(defaults to ``filename`` when not supplied). ``context_length`` lets the
|
||||
cap scale to the model's window when no explicit config override is set.
|
||||
"""
|
||||
if max_chars is None:
|
||||
max_chars = _get_context_file_max_chars(context_length)
|
||||
def _truncate_content(content: str, filename: str, max_chars: int = CONTEXT_FILE_MAX_CHARS) -> str:
|
||||
"""Head/tail truncation with a marker in the middle."""
|
||||
if len(content) <= max_chars:
|
||||
return content
|
||||
target = read_path or filename
|
||||
msg = (
|
||||
f"⚠️ Context file {filename} TRUNCATED: "
|
||||
f"{len(content)} chars exceeds limit of {max_chars} — "
|
||||
f"trim the file, pin a larger context_file_max_chars, or use a "
|
||||
f"larger-context model!"
|
||||
)
|
||||
logger.warning(msg)
|
||||
_record_truncation_warning(msg)
|
||||
head_chars = int(max_chars * CONTEXT_TRUNCATE_HEAD_RATIO)
|
||||
tail_chars = int(max_chars * CONTEXT_TRUNCATE_TAIL_RATIO)
|
||||
head = content[:head_chars]
|
||||
tail = content[-tail_chars:]
|
||||
marker = (
|
||||
f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of "
|
||||
f"{len(content)} chars. The middle is omitted — if you need the full "
|
||||
f"instructions, read the complete file with the read_file tool: "
|
||||
f"{target}]\n\n"
|
||||
)
|
||||
marker = f"\n\n[...truncated {filename}: kept {head_chars}+{tail_chars} of {len(content)} chars. Use file tools to read the full file.]\n\n"
|
||||
return head + marker + tail
|
||||
|
||||
|
||||
def load_soul_md(context_length: Optional[int] = None) -> Optional[str]:
|
||||
def load_soul_md() -> Optional[str]:
|
||||
"""Load SOUL.md from HERMES_HOME and return its content, or None.
|
||||
|
||||
Used as the agent identity (slot #1 in the system prompt). When this
|
||||
@@ -1599,17 +1470,14 @@ def load_soul_md(context_length: Optional[int] = None) -> Optional[str]:
|
||||
if not content:
|
||||
return None
|
||||
content = _scan_context_content(content, "SOUL.md")
|
||||
content = _truncate_content(
|
||||
content, "SOUL.md", context_length=context_length,
|
||||
read_path=str(soul_path),
|
||||
)
|
||||
content = _truncate_content(content, "SOUL.md")
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.debug("Could not read SOUL.md from %s: %s", soul_path, e)
|
||||
return None
|
||||
|
||||
|
||||
def _load_hermes_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
|
||||
def _load_hermes_md(cwd_path: Path) -> str:
|
||||
""".hermes.md / HERMES.md — walk to git root."""
|
||||
hermes_md_path = _find_hermes_md(cwd_path)
|
||||
if not hermes_md_path:
|
||||
@@ -1626,16 +1494,13 @@ def _load_hermes_md(cwd_path: Path, context_length: Optional[int] = None) -> str
|
||||
pass
|
||||
content = _scan_context_content(content, rel)
|
||||
result = f"## {rel}\n\n{content}"
|
||||
return _truncate_content(
|
||||
result, ".hermes.md", context_length=context_length,
|
||||
read_path=str(hermes_md_path),
|
||||
)
|
||||
return _truncate_content(result, ".hermes.md")
|
||||
except Exception as e:
|
||||
logger.debug("Could not read %s: %s", hermes_md_path, e)
|
||||
return ""
|
||||
|
||||
|
||||
def _load_agents_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
|
||||
def _load_agents_md(cwd_path: Path) -> str:
|
||||
"""AGENTS.md — top-level only (no recursive walk)."""
|
||||
for name in ["AGENTS.md", "agents.md"]:
|
||||
candidate = cwd_path / name
|
||||
@@ -1645,16 +1510,13 @@ def _load_agents_md(cwd_path: Path, context_length: Optional[int] = None) -> str
|
||||
if content:
|
||||
content = _scan_context_content(content, name)
|
||||
result = f"## {name}\n\n{content}"
|
||||
return _truncate_content(
|
||||
result, "AGENTS.md", context_length=context_length,
|
||||
read_path=str(candidate),
|
||||
)
|
||||
return _truncate_content(result, "AGENTS.md")
|
||||
except Exception as e:
|
||||
logger.debug("Could not read %s: %s", candidate, e)
|
||||
return ""
|
||||
|
||||
|
||||
def _load_claude_md(cwd_path: Path, context_length: Optional[int] = None) -> str:
|
||||
def _load_claude_md(cwd_path: Path) -> str:
|
||||
"""CLAUDE.md / claude.md — cwd only."""
|
||||
for name in ["CLAUDE.md", "claude.md"]:
|
||||
candidate = cwd_path / name
|
||||
@@ -1664,16 +1526,13 @@ def _load_claude_md(cwd_path: Path, context_length: Optional[int] = None) -> str
|
||||
if content:
|
||||
content = _scan_context_content(content, name)
|
||||
result = f"## {name}\n\n{content}"
|
||||
return _truncate_content(
|
||||
result, "CLAUDE.md", context_length=context_length,
|
||||
read_path=str(candidate),
|
||||
)
|
||||
return _truncate_content(result, "CLAUDE.md")
|
||||
except Exception as e:
|
||||
logger.debug("Could not read %s: %s", candidate, e)
|
||||
return ""
|
||||
|
||||
|
||||
def _load_cursorrules(cwd_path: Path, context_length: Optional[int] = None) -> str:
|
||||
def _load_cursorrules(cwd_path: Path) -> str:
|
||||
""".cursorrules + .cursor/rules/*.mdc — cwd only."""
|
||||
cursorrules_content = ""
|
||||
cursorrules_file = cwd_path / ".cursorrules"
|
||||
@@ -1700,17 +1559,10 @@ def _load_cursorrules(cwd_path: Path, context_length: Optional[int] = None) -> s
|
||||
|
||||
if not cursorrules_content:
|
||||
return ""
|
||||
return _truncate_content(
|
||||
cursorrules_content, ".cursorrules", context_length=context_length,
|
||||
read_path=str(cwd_path / ".cursorrules"),
|
||||
)
|
||||
return _truncate_content(cursorrules_content, ".cursorrules")
|
||||
|
||||
|
||||
def build_context_files_prompt(
|
||||
cwd: Optional[str] = None,
|
||||
skip_soul: bool = False,
|
||||
context_length: Optional[int] = None,
|
||||
) -> str:
|
||||
def build_context_files_prompt(cwd: Optional[str] = None, skip_soul: bool = False) -> str:
|
||||
"""Discover and load context files for the system prompt.
|
||||
|
||||
Priority (first found wins — only ONE project context type is loaded):
|
||||
@@ -1720,11 +1572,7 @@ def build_context_files_prompt(
|
||||
4. .cursorrules / .cursor/rules/*.mdc (cwd only)
|
||||
|
||||
SOUL.md from HERMES_HOME is independent and always included when present.
|
||||
|
||||
Each context source is capped before injection. The cap defaults to the
|
||||
model's context window (scaled — see ``_dynamic_context_file_max_chars``)
|
||||
when *context_length* is provided, falling back to 20,000 chars otherwise.
|
||||
An explicit ``context_file_max_chars`` in config.yaml always wins.
|
||||
Each context source is capped at 20,000 chars.
|
||||
|
||||
When *skip_soul* is True, SOUL.md is not included here (it was already
|
||||
loaded via ``load_soul_md()`` for the identity slot).
|
||||
@@ -1737,17 +1585,17 @@ def build_context_files_prompt(
|
||||
|
||||
# Priority-based project context: first match wins
|
||||
project_context = (
|
||||
_load_hermes_md(cwd_path, context_length)
|
||||
or _load_agents_md(cwd_path, context_length)
|
||||
or _load_claude_md(cwd_path, context_length)
|
||||
or _load_cursorrules(cwd_path, context_length)
|
||||
_load_hermes_md(cwd_path)
|
||||
or _load_agents_md(cwd_path)
|
||||
or _load_claude_md(cwd_path)
|
||||
or _load_cursorrules(cwd_path)
|
||||
)
|
||||
if project_context:
|
||||
sections.append(project_context)
|
||||
|
||||
# SOUL.md from HERMES_HOME only — skip when already loaded as identity
|
||||
if not skip_soul:
|
||||
soul_content = load_soul_md(context_length)
|
||||
soul_content = load_soul_md()
|
||||
if soul_content:
|
||||
sections.append(soul_content)
|
||||
|
||||
|
||||
@@ -104,7 +104,6 @@ _PREFIX_PATTERNS = [
|
||||
r"mem0_[A-Za-z0-9]{10,}", # Mem0 Platform API key
|
||||
r"brv_[A-Za-z0-9]{10,}", # ByteRover API key
|
||||
r"xai-[A-Za-z0-9]{30,}", # xAI (Grok) API key
|
||||
r"ntn_[A-Za-z0-9]{10,}", # Notion internal integration token
|
||||
]
|
||||
|
||||
# ENV assignment patterns: KEY=value where KEY contains a secret-like name
|
||||
|
||||
@@ -26,91 +26,6 @@ _skill_commands_platform: Optional[str] = None
|
||||
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
|
||||
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skill-scaffolding markers and the canonical extractor.
|
||||
#
|
||||
# When a user invokes a /skill (or /bundle), Hermes expands the turn into a
|
||||
# model-facing message that embeds the full skill body plus scaffolding. That
|
||||
# expanded text is what flows into the agent loop — and into memory providers
|
||||
# via MemoryManager. Providers that store or embed the raw user turn (mem0,
|
||||
# openviking, hindsight, retaindb, byterover, honcho, supermemory) would
|
||||
# otherwise capture the entire skill body instead of what the user actually
|
||||
# asked. ``extract_user_instruction_from_skill_message`` recovers just the
|
||||
# user's instruction so memory stays clean.
|
||||
#
|
||||
# These markers MUST stay byte-identical to the builders below
|
||||
# (``_build_skill_message`` here, ``build_bundle_invocation_message`` in
|
||||
# agent/skill_bundles.py). They are co-located with the single-skill builder
|
||||
# on purpose, and the bundle markers are asserted against the bundle builder in
|
||||
# tests/openviking_plugin/test_openviking.py::test_skill_markers_match_hermes_scaffolding.
|
||||
# ---------------------------------------------------------------------------
|
||||
_SKILL_INVOCATION_PREFIX = "[IMPORTANT: The user has invoked the "
|
||||
_SINGLE_SKILL_MARKER = "The full skill content is loaded below.]"
|
||||
_SINGLE_SKILL_INSTRUCTION = (
|
||||
"The user has provided the following instruction alongside the skill invocation: "
|
||||
)
|
||||
_RUNTIME_NOTE = "\n\n[Runtime note:"
|
||||
_BUNDLE_MARKER = " skill bundle,"
|
||||
_BUNDLE_USER_INSTRUCTION = "\nUser instruction: "
|
||||
_BUNDLE_FIRST_SKILL_BLOCK = "\n\n[Loaded as part of the "
|
||||
|
||||
|
||||
def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
|
||||
"""Recover the user's instruction from a slash-skill-expanded turn.
|
||||
|
||||
Returns:
|
||||
- The original string unchanged when it is NOT skill scaffolding
|
||||
(a normal user message passes straight through).
|
||||
- The extracted user instruction when the scaffolding carried one.
|
||||
- ``None`` when the content is skill scaffolding with no user
|
||||
instruction (i.e. a bare ``/skill`` invocation). Callers that feed
|
||||
memory providers should skip the turn in that case — there is no
|
||||
user content worth storing.
|
||||
"""
|
||||
if not isinstance(content, str):
|
||||
return None
|
||||
|
||||
if not content.startswith(_SKILL_INVOCATION_PREFIX):
|
||||
return content
|
||||
|
||||
if _BUNDLE_MARKER in content:
|
||||
return _extract_bundle_user_instruction(content)
|
||||
|
||||
if _SINGLE_SKILL_MARKER in content:
|
||||
return _extract_single_skill_user_instruction(content)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _extract_single_skill_user_instruction(message: str) -> Optional[str]:
|
||||
# Single-skill format appends the user instruction after the skill body, so
|
||||
# the last occurrence is the user-provided one; the body may quote this text.
|
||||
marker_idx = message.rfind(_SINGLE_SKILL_INSTRUCTION)
|
||||
if marker_idx < 0:
|
||||
return None
|
||||
|
||||
instruction = message[marker_idx + len(_SINGLE_SKILL_INSTRUCTION):]
|
||||
runtime_idx = instruction.find(_RUNTIME_NOTE)
|
||||
if runtime_idx >= 0:
|
||||
instruction = instruction[:runtime_idx]
|
||||
instruction = instruction.strip()
|
||||
return instruction or None
|
||||
|
||||
|
||||
def _extract_bundle_user_instruction(message: str) -> Optional[str]:
|
||||
# Bundle format puts the user instruction before the loaded skills, so the
|
||||
# first occurrence is the user-provided one.
|
||||
marker_idx = message.find(_BUNDLE_USER_INSTRUCTION)
|
||||
if marker_idx < 0:
|
||||
return None
|
||||
|
||||
instruction = message[marker_idx + len(_BUNDLE_USER_INSTRUCTION):]
|
||||
first_skill_idx = instruction.find(_BUNDLE_FIRST_SKILL_BLOCK)
|
||||
if first_skill_idx >= 0:
|
||||
instruction = instruction[:first_skill_idx]
|
||||
instruction = instruction.strip()
|
||||
return instruction or None
|
||||
|
||||
|
||||
def _resolve_skill_commands_platform() -> Optional[str]:
|
||||
"""Return the current platform scope used for disabled-skill filtering.
|
||||
|
||||
@@ -43,20 +43,14 @@ EXCLUDED_SKILL_DIRS = frozenset(
|
||||
)
|
||||
)
|
||||
|
||||
# Supporting files live inside a skill package and are loaded explicitly via
|
||||
# skill_view(skill, file_path=...). They are not standalone skills and must not
|
||||
# be scanned for active SKILL.md/DESCRIPTION.md entries, even if a Curator or
|
||||
# archive workflow preserves a complete old skill package under references/.
|
||||
SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts"))
|
||||
|
||||
|
||||
def is_excluded_skill_path(path) -> bool:
|
||||
"""True if *path* should be skipped by active skill scanners.
|
||||
"""True if any component of *path* is in EXCLUDED_SKILL_DIRS.
|
||||
|
||||
Use this on every ``SKILL.md`` path produced by direct ``rglob`` scans to
|
||||
prune dependency, virtualenv, VCS, cache, and progressive-disclosure
|
||||
support-package paths. Centralising the check here keeps every
|
||||
skill-scanning site in sync with the shared exclusion set.
|
||||
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.
|
||||
"""
|
||||
@@ -65,36 +59,7 @@ def is_excluded_skill_path(path) -> bool:
|
||||
except AttributeError:
|
||||
from pathlib import PurePath
|
||||
parts = PurePath(str(path)).parts
|
||||
return any(part in EXCLUDED_SKILL_DIRS for part in parts) or is_skill_support_path(
|
||||
path
|
||||
)
|
||||
|
||||
|
||||
def is_skill_support_path(path) -> bool:
|
||||
"""True if *path* is under a support dir of an actual skill root.
|
||||
|
||||
``references/``, ``templates/``, ``assets/``, and ``scripts/`` are
|
||||
progressive-disclosure support areas when they sit directly inside a skill
|
||||
directory containing ``SKILL.md``. They are not active discovery roots for
|
||||
standalone skills. A preserved package such as
|
||||
``some-skill/references/old-skill-package/SKILL.md`` is documentation data
|
||||
unless the caller explicitly loads it via ``file_path``.
|
||||
|
||||
Legitimate categories or skill names such as ``skills/scripts/foo`` remain
|
||||
discoverable because their ``scripts`` component is not directly under a
|
||||
directory that contains ``SKILL.md``.
|
||||
"""
|
||||
path_obj = path if isinstance(path, Path) else Path(str(path))
|
||||
parts = path_obj.parts
|
||||
# Last component may be a file or candidate skill directory name. Only
|
||||
# components before the leaf can be containing support directories.
|
||||
for idx, part in enumerate(parts[:-1]):
|
||||
if part not in SKILL_SUPPORT_DIRS or idx == 0:
|
||||
continue
|
||||
skill_root = Path(*parts[:idx])
|
||||
if (skill_root / "SKILL.md").exists():
|
||||
return True
|
||||
return False
|
||||
return any(part in EXCLUDED_SKILL_DIRS for part in parts)
|
||||
|
||||
|
||||
# ── Lazy YAML loader ─────────────────────────────────────────────────────
|
||||
@@ -307,65 +272,27 @@ def skill_matches_environment(frontmatter: Dict[str, Any]) -> bool:
|
||||
# ── Disabled skills ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
_RAW_CONFIG_CACHE: Dict[Tuple[str, int, int], Dict[str, Any]] = {}
|
||||
|
||||
|
||||
def _raw_config_cache_clear() -> None:
|
||||
"""Test hook — drop the shared raw config cache."""
|
||||
_RAW_CONFIG_CACHE.clear()
|
||||
|
||||
|
||||
def _load_raw_config() -> Dict[str, Any]:
|
||||
"""Read config.yaml with a shared mtime+size keyed cache.
|
||||
|
||||
This module intentionally avoids importing ``hermes_cli.config`` on the
|
||||
skill prompt/build path. A tiny local cache gives the same repeated-read
|
||||
win without pulling the heavier CLI config stack into startup.
|
||||
"""
|
||||
config_path = get_config_path()
|
||||
if not config_path.exists():
|
||||
return {}
|
||||
try:
|
||||
stat = config_path.stat()
|
||||
cache_key = (str(config_path), stat.st_mtime_ns, stat.st_size)
|
||||
except OSError:
|
||||
cache_key = None
|
||||
|
||||
if cache_key is not None:
|
||||
cached = _RAW_CONFIG_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
try:
|
||||
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
logger.debug("Could not read skill config %s: %s", config_path, e)
|
||||
return {}
|
||||
if not isinstance(parsed, dict):
|
||||
return {}
|
||||
|
||||
if cache_key is not None:
|
||||
_RAW_CONFIG_CACHE.clear()
|
||||
_RAW_CONFIG_CACHE[cache_key] = parsed
|
||||
return parsed
|
||||
|
||||
|
||||
def get_disabled_skill_names(platform: str | None = None) -> Set[str]:
|
||||
"""Read disabled skill names from config.yaml.
|
||||
|
||||
Args:
|
||||
platform: Explicit platform name (e.g. ``"telegram"``). When
|
||||
*None*, resolves from ``HERMES_PLATFORM`` or
|
||||
``HERMES_SESSION_PLATFORM`` env vars. Returns the global
|
||||
disabled list, unioned with the platform-specific list when a
|
||||
platform is resolved (a globally-disabled skill stays disabled
|
||||
on every platform).
|
||||
``HERMES_SESSION_PLATFORM`` env vars. Falls back to the
|
||||
global disabled list when no platform is determined.
|
||||
|
||||
Reads the config file directly (no CLI config imports) to stay
|
||||
lightweight.
|
||||
"""
|
||||
parsed = _load_raw_config()
|
||||
if not parsed:
|
||||
config_path = get_config_path()
|
||||
if not config_path.exists():
|
||||
return set()
|
||||
try:
|
||||
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
logger.debug("Could not read skill config %s: %s", config_path, e)
|
||||
return set()
|
||||
if not isinstance(parsed, dict):
|
||||
return set()
|
||||
|
||||
skills_cfg = parsed.get("skills")
|
||||
@@ -378,14 +305,13 @@ def get_disabled_skill_names(platform: str | None = None) -> Set[str]:
|
||||
or os.getenv("HERMES_PLATFORM")
|
||||
or get_session_env("HERMES_SESSION_PLATFORM")
|
||||
)
|
||||
global_disabled = _normalize_string_set(skills_cfg.get("disabled"))
|
||||
if resolved_platform:
|
||||
platform_disabled = (skills_cfg.get("platform_disabled") or {}).get(
|
||||
resolved_platform
|
||||
)
|
||||
if platform_disabled is not None:
|
||||
return global_disabled | _normalize_string_set(platform_disabled)
|
||||
return global_disabled
|
||||
return _normalize_string_set(platform_disabled)
|
||||
return _normalize_string_set(skills_cfg.get("disabled"))
|
||||
|
||||
|
||||
def _normalize_string_set(values) -> Set[str]:
|
||||
@@ -410,7 +336,6 @@ _EXTERNAL_DIRS_CACHE: Dict[Tuple[str, int], List[Path]] = {}
|
||||
def _external_dirs_cache_clear() -> None:
|
||||
"""Test hook — drop the in-process cache."""
|
||||
_EXTERNAL_DIRS_CACHE.clear()
|
||||
_raw_config_cache_clear()
|
||||
|
||||
|
||||
def get_external_skills_dirs() -> List[Path]:
|
||||
@@ -443,8 +368,11 @@ def get_external_skills_dirs() -> List[Path]:
|
||||
# Return a copy so callers can't mutate the cached list.
|
||||
return list(cached)
|
||||
|
||||
parsed = _load_raw_config()
|
||||
if not parsed:
|
||||
try:
|
||||
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(parsed, dict):
|
||||
return []
|
||||
|
||||
skills_cfg = parsed.get("skills")
|
||||
@@ -656,7 +584,15 @@ def resolve_skill_config_values(
|
||||
current values (or the declared default if the key isn't set).
|
||||
Path values are expanded via ``os.path.expanduser``.
|
||||
"""
|
||||
config = _load_raw_config()
|
||||
config_path = get_config_path()
|
||||
config: Dict[str, Any] = {}
|
||||
if config_path.exists():
|
||||
try:
|
||||
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
|
||||
if isinstance(parsed, dict):
|
||||
config = parsed
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
resolved: Dict[str, Any] = {}
|
||||
for var in config_vars:
|
||||
@@ -696,21 +632,12 @@ 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 Hermes metadata, VCS, virtualenv/dependency, cache, and skill
|
||||
support directories. Support directories (references/templates/assets/
|
||||
scripts) can contain arbitrary markdown and even archived package
|
||||
``SKILL.md`` files, but they are progressive-disclosure data loaded through
|
||||
``skill_view(..., file_path=...)`` rather than active skill roots.
|
||||
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):
|
||||
has_skill_md = "SKILL.md" in files
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
if d not in EXCLUDED_SKILL_DIRS
|
||||
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
|
||||
]
|
||||
dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS]
|
||||
if filename in files:
|
||||
matches.append(Path(root) / filename)
|
||||
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
"""Preventive SSL CA certificate checks for Hermes Agent.
|
||||
|
||||
This module catches broken CA bundle paths before OpenAI/httpx turns them into
|
||||
opaque ``FileNotFoundError: [Errno 2] No such file or directory`` failures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
|
||||
from agent.errors import SSLConfigurationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CA_BUNDLE_ENV_VARS = (
|
||||
"HERMES_CA_BUNDLE",
|
||||
"SSL_CERT_FILE",
|
||||
"REQUESTS_CA_BUNDLE",
|
||||
"CURL_CA_BUNDLE",
|
||||
)
|
||||
|
||||
_SKIP_VALUES = {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _skip_ssl_guard_enabled() -> bool:
|
||||
return os.getenv("HERMES_SKIP_SSL_GUARD", "").strip().lower() in _SKIP_VALUES
|
||||
|
||||
|
||||
def _repair_hint() -> str:
|
||||
return (
|
||||
"Repair: python -m pip install --force-reinstall certifi openai httpx\n"
|
||||
"If you configured a custom corporate CA bundle, fix or unset the "
|
||||
"broken CA bundle environment variable."
|
||||
)
|
||||
|
||||
|
||||
def _ssl_err(message: str) -> SSLConfigurationError:
|
||||
"""Create a consistent, user-actionable SSL configuration error."""
|
||||
return SSLConfigurationError(f"{message}\n{_repair_hint()}")
|
||||
|
||||
|
||||
def _validate_bundle_path(label: str, value: str, *, require_substantial: bool = False) -> None:
|
||||
path = Path(value).expanduser()
|
||||
if not path.exists():
|
||||
raise _ssl_err(f"{label} points to a missing CA bundle: {value}")
|
||||
if not path.is_file():
|
||||
raise _ssl_err(f"{label} does not point to a CA bundle file: {value}")
|
||||
if require_substantial and path.stat().st_size < 1024:
|
||||
raise _ssl_err(f"{label} at {value} appears corrupted (too small)")
|
||||
try:
|
||||
ctx = ssl.create_default_context(cafile=str(path))
|
||||
except Exception as exc:
|
||||
raise _ssl_err(f"{label} CA bundle at {value} cannot be loaded: {exc}") from exc
|
||||
if not ctx.get_ca_certs():
|
||||
raise _ssl_err(f"{label} CA bundle at {value} did not load any certificates")
|
||||
|
||||
|
||||
def verify_ca_bundle() -> None:
|
||||
"""Verify configured and bundled CA certificates are present and loadable.
|
||||
|
||||
Raises:
|
||||
SSLConfigurationError: If an explicit CA-bundle environment variable
|
||||
points at a bad path, or if certifi's bundled ``cacert.pem`` is
|
||||
missing/corrupt.
|
||||
"""
|
||||
if _skip_ssl_guard_enabled():
|
||||
logger.debug("SSL CA bundle guard skipped via HERMES_SKIP_SSL_GUARD")
|
||||
return
|
||||
|
||||
for env_var in _CA_BUNDLE_ENV_VARS:
|
||||
value = os.getenv(env_var)
|
||||
if value:
|
||||
_validate_bundle_path(env_var, value)
|
||||
|
||||
try:
|
||||
import certifi
|
||||
except Exception as exc:
|
||||
raise _ssl_err(f"certifi is not importable: {exc}") from exc
|
||||
|
||||
ca_bundle = str(certifi.where())
|
||||
_validate_bundle_path("certifi", ca_bundle, require_substantial=True)
|
||||
|
||||
|
||||
def verify_ca_bundle_with_fallback() -> None:
|
||||
"""Backward-compatible wrapper for older call sites.
|
||||
|
||||
The old PR name mentioned a platform fallback, but allowing startup with a
|
||||
broken certifi bundle still leaves httpx/OpenAI and requests call sites
|
||||
failing later. Keep the wrapper name but enforce the same check.
|
||||
"""
|
||||
verify_ca_bundle()
|
||||
@@ -40,7 +40,6 @@ from agent.prompt_builder import (
|
||||
TASK_COMPLETION_GUIDANCE,
|
||||
TOOL_USE_ENFORCEMENT_GUIDANCE,
|
||||
TOOL_USE_ENFORCEMENT_MODELS,
|
||||
drain_truncation_warnings,
|
||||
)
|
||||
from agent.runtime_cwd import resolve_context_cwd
|
||||
|
||||
@@ -83,17 +82,6 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
# we resolve through ``_ra()`` to honor those patches.
|
||||
_r = _ra()
|
||||
|
||||
# Resolve the model's context window once so context-file caps can scale
|
||||
# to it (dynamic cap — see prompt_builder._dynamic_context_file_max_chars).
|
||||
# None falls back to the historical flat default. This value is stable for
|
||||
# the life of the conversation, so it does not threaten prompt caching.
|
||||
_ctx_len: Optional[int] = None
|
||||
_cc = getattr(agent, "context_compressor", None)
|
||||
if _cc is not None:
|
||||
_cc_len = getattr(_cc, "context_length", None)
|
||||
if isinstance(_cc_len, int) and _cc_len > 0:
|
||||
_ctx_len = _cc_len
|
||||
|
||||
# ── Stable tier ────────────────────────────────────────────────
|
||||
stable_parts: List[str] = []
|
||||
|
||||
@@ -102,7 +90,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
# cwd project instructions disabled.
|
||||
_soul_loaded = False
|
||||
if agent.load_soul_identity or not agent.skip_context_files:
|
||||
_soul_content = _r.load_soul_md(_ctx_len)
|
||||
_soul_content = _r.load_soul_md()
|
||||
if _soul_content:
|
||||
stable_parts.append(_soul_content)
|
||||
_soul_loaded = True
|
||||
@@ -345,8 +333,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
# dir — the user's real cwd there, but the install dir for the gateway
|
||||
# daemon, which is why the gateway sets TERMINAL_CWD.
|
||||
context_files_prompt = _r.build_context_files_prompt(
|
||||
cwd=resolve_context_cwd(), skip_soul=_soul_loaded,
|
||||
context_length=_ctx_len)
|
||||
cwd=resolve_context_cwd(), skip_soul=_soul_loaded)
|
||||
if context_files_prompt:
|
||||
context_parts.append(context_files_prompt)
|
||||
|
||||
@@ -413,14 +400,7 @@ def build_system_prompt(agent: Any, system_message: Optional[str] = None) -> str
|
||||
warm across turns.
|
||||
"""
|
||||
parts = build_system_prompt_parts(agent, system_message=system_message)
|
||||
joined = "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)
|
||||
|
||||
# Surface context-file truncation warnings through the normal agent status
|
||||
# channel so gateway/CLI users see them in chat instead of only in logs.
|
||||
for warning in drain_truncation_warnings():
|
||||
agent._emit_status(warning)
|
||||
|
||||
return joined
|
||||
return "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)
|
||||
|
||||
|
||||
def invalidate_system_prompt(agent: Any) -> None:
|
||||
|
||||
@@ -88,7 +88,7 @@ class AnthropicTransport(ProviderTransport):
|
||||
from agent.transports.types import ToolCall
|
||||
|
||||
strip_tool_prefix = kwargs.get("strip_tool_prefix", False)
|
||||
_MCP_PREFIX = "mcp__"
|
||||
_MCP_PREFIX = "mcp_"
|
||||
|
||||
text_parts = []
|
||||
reasoning_parts = []
|
||||
@@ -132,25 +132,17 @@ class AnthropicTransport(ProviderTransport):
|
||||
elif block.type == "tool_use":
|
||||
name = block.name
|
||||
if strip_tool_prefix and name.startswith(_MCP_PREFIX):
|
||||
# On the OAuth wire every tool carries a double-underscore
|
||||
# ``mcp__`` prefix (added in build_anthropic_kwargs to avoid
|
||||
# Anthropic's single-underscore third-party classifier).
|
||||
# Reverse it back to the name the registry/dispatcher knows.
|
||||
# Two original forms map onto the same ``mcp__`` wire name:
|
||||
# ``mcp__read_file`` <- bare native tool ``read_file``
|
||||
# ``mcp__linear_get_issue`` <- MCP server tool
|
||||
# ``mcp_linear_get_issue``
|
||||
# Resolve by registry lookup, preferring whichever original
|
||||
# is actually registered; never rewrite a name the LLM used
|
||||
# that already resolves natively. GH-25255.
|
||||
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 not _tool_registry.get_entry(name):
|
||||
bare = name[len(_MCP_PREFIX):] # read_file
|
||||
single = "mcp_" + bare # mcp_read_file / mcp_linear_get_issue
|
||||
if _tool_registry.get_entry(single):
|
||||
name = single
|
||||
elif _tool_registry.get_entry(bare):
|
||||
name = bare
|
||||
if (_tool_registry.get_entry(stripped)
|
||||
and not _tool_registry.get_entry(name)):
|
||||
name = stripped
|
||||
tool_calls.append(
|
||||
ToolCall(
|
||||
id=block.id,
|
||||
@@ -194,21 +186,10 @@ class AnthropicTransport(ProviderTransport):
|
||||
def validate_response(self, response: Any) -> bool:
|
||||
"""Check Anthropic response structure is valid.
|
||||
|
||||
An empty content list is legitimate for terminal stop reasons that
|
||||
carry no text payload:
|
||||
|
||||
- ``end_turn`` — the model's canonical "nothing more to add" after a
|
||||
tool turn that already delivered the user-facing text.
|
||||
- ``refusal`` — the model declined to respond (Claude 4.5+). The
|
||||
Messages API returns an empty ``content`` list with this stop
|
||||
reason. Treating it as invalid sends a deterministic refusal into
|
||||
the invalid-response retry loop, which reproduces the refusal on
|
||||
every attempt and surfaces a misleading "rate limited / invalid
|
||||
response" error instead of the refusal. ``normalize_response`` maps
|
||||
``refusal`` → ``content_filter`` so the agent loop's refusal handler
|
||||
can surface it.
|
||||
|
||||
Treating either as invalid falsely retries a completed response.
|
||||
An empty content list is legitimate when ``stop_reason == "end_turn"``
|
||||
— the model's canonical way of signalling "nothing more to add" after
|
||||
a tool turn that already delivered the user-facing text. Treating it
|
||||
as invalid falsely retries a completed response.
|
||||
"""
|
||||
if response is None:
|
||||
return False
|
||||
@@ -216,7 +197,7 @@ class AnthropicTransport(ProviderTransport):
|
||||
if not isinstance(content_blocks, list):
|
||||
return False
|
||||
if not content_blocks:
|
||||
return getattr(response, "stop_reason", None) in {"end_turn", "refusal"}
|
||||
return getattr(response, "stop_reason", None) == "end_turn"
|
||||
return True
|
||||
|
||||
def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]:
|
||||
|
||||
@@ -531,7 +531,6 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
supports_reasoning=params.get("supports_reasoning", False),
|
||||
qwen_session_metadata=params.get("qwen_session_metadata"),
|
||||
model=model,
|
||||
base_url=params.get("base_url"),
|
||||
ollama_num_ctx=params.get("ollama_num_ctx"),
|
||||
session_id=params.get("session_id"),
|
||||
)
|
||||
@@ -665,42 +664,8 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
if rd:
|
||||
provider_data["reasoning_details"] = rd
|
||||
|
||||
# OpenAI structured-refusal field. When a model declines, the SDK
|
||||
# populates ``message.refusal`` with the explanation and leaves
|
||||
# ``content`` empty. OpenAI-compatible proxies that front Anthropic /
|
||||
# Bedrock (e.g. Nous Portal) surface a Claude refusal this way — or via
|
||||
# ``finish_reason="content_filter"`` — instead of the native
|
||||
# ``stop_reason="refusal"``. Without capturing it the refusal looks
|
||||
# like an empty response, so the agent loop retries a deterministic
|
||||
# refusal three times and gives up with "no content after retries".
|
||||
# Promote it to content + a ``content_filter`` finish reason so the
|
||||
# loop's refusal handler surfaces it clearly and stops. ``refusal`` is
|
||||
# ``None`` for normal responses, so this is a no-op in the common case.
|
||||
content = msg.content
|
||||
refusal = getattr(msg, "refusal", None)
|
||||
if refusal is None and hasattr(msg, "model_extra"):
|
||||
_msg_extra = getattr(msg, "model_extra", None) or {}
|
||||
if isinstance(_msg_extra, dict):
|
||||
refusal = _msg_extra.get("refusal")
|
||||
if isinstance(refusal, str) and refusal.strip():
|
||||
# Record the refusal explanation regardless — it's useful provider
|
||||
# metadata even when the model also returned a usable payload.
|
||||
provider_data["refusal"] = refusal
|
||||
_has_text = isinstance(content, str) and content.strip()
|
||||
_has_tool_calls = bool(tool_calls)
|
||||
# Only promote to a terminal ``content_filter`` when the refusal is
|
||||
# the *sole* payload — no visible text and no tool calls. A response
|
||||
# that carries real content (or tool calls) alongside a refusal note
|
||||
# is a normal, usable turn: surfacing it as a failed safety refusal
|
||||
# would discard the model's actual work. In the empty-payload case,
|
||||
# adopt the refusal as content so the loop has something to show.
|
||||
if not _has_text and not _has_tool_calls:
|
||||
content = refusal
|
||||
if finish_reason in (None, "stop"):
|
||||
finish_reason = "content_filter"
|
||||
|
||||
return NormalizedResponse(
|
||||
content=content,
|
||||
content=msg.content,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
reasoning=reasoning,
|
||||
|
||||
@@ -218,14 +218,8 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
kwargs.pop("timeout", None)
|
||||
|
||||
if is_codex_backend:
|
||||
# The Codex backend rejects body-level ``extra_headers`` with
|
||||
# HTTP 400, but the OpenAI SDK's ``extra_headers`` kwarg maps
|
||||
# to actual HTTP request headers (not body fields). We need
|
||||
# these headers for cache-scope routing so prompt cache hits
|
||||
# remain high. Send session_id / x-client-request-id as HTTP
|
||||
# headers while keeping ``prompt_cache_key`` in the body for
|
||||
# standard OpenAI routing as a belt-and-braces fallback.
|
||||
cache_scope_id = str(session_id or "").strip()
|
||||
prompt_cache_key = kwargs.get("prompt_cache_key")
|
||||
cache_scope_id = str(prompt_cache_key or session_id or "").strip()
|
||||
if cache_scope_id:
|
||||
existing_extra_headers = kwargs.get("extra_headers")
|
||||
merged_extra_headers: Dict[str, str] = {}
|
||||
|
||||
@@ -69,7 +69,6 @@ def build_turn_context(
|
||||
task_id: Optional[str],
|
||||
stream_callback,
|
||||
persist_user_message: Optional[str],
|
||||
persist_user_timestamp: Optional[float] = None,
|
||||
*,
|
||||
restore_or_build_system_prompt,
|
||||
install_safe_stdio,
|
||||
@@ -122,7 +121,6 @@ def build_turn_context(
|
||||
agent._stream_callback = stream_callback
|
||||
agent._persist_user_message_idx = None
|
||||
agent._persist_user_message_override = persist_user_message
|
||||
agent._persist_user_message_timestamp = persist_user_timestamp
|
||||
# Generate unique task_id if not provided to isolate VMs between tasks.
|
||||
effective_task_id = task_id or str(uuid.uuid4())
|
||||
agent._current_task_id = effective_task_id
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "0.16.0",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
@@ -40,8 +40,8 @@
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
//! Driven when the installer is launched as `Hermes-Setup.exe --update` (see
|
||||
//! `AppMode` in lib.rs). The desktop app hands off to us — it exits, then we:
|
||||
//!
|
||||
//! 1. wait for the old Hermes desktop process to fully exit (so both the
|
||||
//! venv shim and packaged app.asar are free; otherwise `hermes update`
|
||||
//! or repair bootstrap can race locked files),
|
||||
//! 1. wait for the old Hermes desktop process to fully exit (so the venv
|
||||
//! shim is free; otherwise `hermes update` aborts with exit code 2),
|
||||
//! 2. run `hermes update --yes --gateway` (Python/repo update; this does NOT
|
||||
//! rebuild apps/desktop by design — see cmd_update in hermes_cli/main.py),
|
||||
//! 3. run `hermes desktop --build-only` (the rebuild step update skips),
|
||||
@@ -39,8 +38,8 @@ use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
|
||||
/// hermes_cli/main.py (sys.exit(2)). We surface a targeted message for this.
|
||||
const UPDATE_EXIT_CONCURRENT: i32 = 2;
|
||||
|
||||
/// How long to wait for the old desktop process to release files under the
|
||||
/// install tree before giving up and letting `hermes update`'s own guard decide.
|
||||
/// How long to wait for the old desktop process to release the venv shim
|
||||
/// before giving up and letting `hermes update`'s own guard decide.
|
||||
const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20);
|
||||
const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500);
|
||||
|
||||
@@ -151,10 +150,8 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
// ---- pre-step: wait for the old desktop to die -----------------------
|
||||
// The desktop exec'd us then called app.exit(), but process teardown is
|
||||
// async on Windows. If it still holds the venv shim, `hermes update`
|
||||
// aborts with exit 2. If it still holds the packaged app.asar,
|
||||
// install.ps1's repair/re-clone path cannot move/remove the install tree.
|
||||
// Give both handles a bounded window to clear.
|
||||
wait_for_install_locks_free(&install_root, &app, "update").await;
|
||||
// aborts with exit 2. Give it a bounded window to clear.
|
||||
wait_for_venv_free(&install_root, &app).await;
|
||||
|
||||
// ---- stage 1: hermes update -----------------------------------------
|
||||
// Pass --branch so `hermes update` targets the branch this installer was
|
||||
@@ -176,8 +173,8 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
vec!["update".into(), "--yes".into(), "--gateway".into()];
|
||||
// --force skips `hermes update`'s Windows running-exe guard (which would
|
||||
// `sys.exit(2)` and dead-end the handoff). By contract the desktop has
|
||||
// already exited and waited for the install locks to clear before launching
|
||||
// us, and wait_for_install_locks_free below force-kills any straggler — so by the
|
||||
// already exited and waited for the venv shim to unlock before launching
|
||||
// us, and wait_for_venv_free below force-kills any straggler — so by the
|
||||
// time `hermes update` runs there is no legitimate hermes.exe to protect,
|
||||
// and the guard would only produce a false "Hermes is still running" stop.
|
||||
update_args.push("--force".into());
|
||||
@@ -394,57 +391,48 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll until the venv shim AND packaged desktop app bundle are no longer locked
|
||||
/// (Windows) or a bounded timeout elapses. On non-Windows this is a short fixed
|
||||
/// grace since file locking isn't the failure mode there.
|
||||
pub(crate) async fn wait_for_install_locks_free(install_root: &Path, app: &AppHandle, stage: &str) {
|
||||
let lock_targets = install_lock_probe_paths(install_root);
|
||||
/// Poll until the venv shim is no longer locked (Windows) or a bounded timeout
|
||||
/// elapses. On non-Windows this is a short fixed grace since file locking
|
||||
/// isn't the failure mode there.
|
||||
async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
|
||||
let shim = venv_hermes(install_root);
|
||||
let deadline = Instant::now() + DESKTOP_EXIT_WAIT;
|
||||
|
||||
emit_log(app, Some(stage), LogStream::Stdout, "[handoff] waiting for Hermes to exit…");
|
||||
emit_log(app, Some("update"), LogStream::Stdout, "[update] waiting for Hermes to exit…");
|
||||
|
||||
loop {
|
||||
let locked = locked_paths(&lock_targets);
|
||||
if locked.is_empty() {
|
||||
if !is_locked(&shim) {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
// Last resort: a backend hermes.exe (or the desktop Hermes.exe
|
||||
// itself) is still holding one of the update-sensitive files. The
|
||||
// desktop should have reaped its tree before handing off, but
|
||||
// SIGTERM races / detached grandchildren / AV handles can leave a
|
||||
// straggler. Rather than "proceed anyway" straight into uv's
|
||||
// "Access is denied" or install.ps1's locked app.asar failure,
|
||||
// force-kill every Hermes.exe except ourselves, then give the OS a
|
||||
// beat to unload the image.
|
||||
// Last resort: a backend hermes.exe (or a grandchild it spawned)
|
||||
// is still holding the shim. The desktop should have reaped its
|
||||
// tree before handing off, but SIGTERM races / detached
|
||||
// grandchildren / AV handles can leave a straggler. Rather than
|
||||
// "proceed anyway" straight into uv's "Access is denied", force-kill
|
||||
// every hermes.exe except ourselves, then give the OS a beat to
|
||||
// unload the image.
|
||||
emit_log(
|
||||
app,
|
||||
Some(stage),
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
&format!(
|
||||
"[handoff] Hermes still holding install files ({}); force-killing stragglers…",
|
||||
format_locked_paths(&locked)
|
||||
),
|
||||
"[update] Hermes still holding the venv shim; force-killing stragglers…",
|
||||
);
|
||||
force_kill_other_hermes();
|
||||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||||
let locked_after_kill = locked_paths(&lock_targets);
|
||||
if locked_after_kill.is_empty() {
|
||||
if !is_locked(&shim) {
|
||||
emit_log(
|
||||
app,
|
||||
Some(stage),
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
"[handoff] install files freed after force-kill",
|
||||
"[update] venv shim freed after force-kill",
|
||||
);
|
||||
} else {
|
||||
emit_log(
|
||||
app,
|
||||
Some(stage),
|
||||
Some("update"),
|
||||
LogStream::Stdout,
|
||||
&format!(
|
||||
"[handoff] install files still locked ({}); proceeding (--force + quarantine will handle it)",
|
||||
format_locked_paths(&locked_after_kill)
|
||||
),
|
||||
"[update] venv shim still locked; proceeding (--force + quarantine will handle it)",
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -453,44 +441,13 @@ pub(crate) async fn wait_for_install_locks_free(install_root: &Path, app: &AppHa
|
||||
}
|
||||
}
|
||||
|
||||
fn install_lock_probe_paths(install_root: &Path) -> Vec<PathBuf> {
|
||||
let mut paths = vec![venv_hermes(install_root)];
|
||||
paths.extend(desktop_app_payload_paths(install_root));
|
||||
paths
|
||||
}
|
||||
|
||||
fn desktop_app_payload_paths(install_root: &Path) -> Vec<PathBuf> {
|
||||
let release = install_root.join("apps").join("desktop").join("release");
|
||||
if cfg!(target_os = "windows") {
|
||||
vec![
|
||||
release.join("win-unpacked").join("resources").join("app.asar"),
|
||||
release.join("win-arm64-unpacked").join("resources").join("app.asar"),
|
||||
]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
vec![
|
||||
release.join("mac").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
|
||||
release.join("mac-arm64").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
|
||||
]
|
||||
} else {
|
||||
vec![release.join("linux-unpacked").join("resources").join("app.asar")]
|
||||
}
|
||||
}
|
||||
|
||||
fn locked_paths(paths: &[PathBuf]) -> Vec<PathBuf> {
|
||||
paths.iter().filter(|p| is_locked(p)).cloned().collect()
|
||||
}
|
||||
|
||||
fn format_locked_paths(paths: &[PathBuf]) -> String {
|
||||
paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op
|
||||
/// elsewhere (POSIX has no mandatory-lock contention). We can't selectively
|
||||
/// target "the backend" by PID here — the desktop already exited and we never
|
||||
/// knew its children — so we kill the whole `hermes.exe` image tree via
|
||||
/// taskkill, excluding our own PID.
|
||||
///
|
||||
/// Safe w.r.t. our own update child: this runs inside the install-lock wait,
|
||||
/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`,
|
||||
/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this
|
||||
/// point no update-driven hermes.exe exists yet, so the only hermes.exe images
|
||||
/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID
|
||||
@@ -934,29 +891,6 @@ mod tests {
|
||||
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_probe_paths_include_desktop_app_payload() {
|
||||
let root = Path::new("/x/hermes-agent");
|
||||
let probes = install_lock_probe_paths(root);
|
||||
|
||||
assert!(
|
||||
probes.iter().any(|p| p == &venv_hermes(root)),
|
||||
"venv shim remains part of the update lock probe"
|
||||
);
|
||||
assert!(
|
||||
probes.iter().any(|p| p.ends_with(Path::new("resources/app.asar"))),
|
||||
"packaged app.asar must be probed so repair/re-clone waits for the old desktop to exit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_paths_ignores_missing_payloads() {
|
||||
let root = Path::new("/nonexistent/hermes-agent");
|
||||
let probes = install_lock_probe_paths(root);
|
||||
|
||||
assert!(locked_paths(&probes).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_update_branch_from_space_or_equals_args() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
@@ -34,7 +34,7 @@ It builds and launches the GUI against your existing install — same config, ke
|
||||
|
||||
### Prebuilt installers
|
||||
|
||||
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/).
|
||||
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/desktop).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
const path = require('node:path')
|
||||
|
||||
// Match the POSIX fallback surface used by the Python terminal environment.
|
||||
// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin,
|
||||
// which misses Apple Silicon Homebrew and user-installed CLI tools such as codex.
|
||||
const POSIX_SANE_PATH_ENTRIES = Object.freeze([
|
||||
'/opt/homebrew/bin',
|
||||
'/opt/homebrew/sbin',
|
||||
'/usr/local/sbin',
|
||||
'/usr/local/bin',
|
||||
'/usr/sbin',
|
||||
'/usr/bin',
|
||||
'/sbin',
|
||||
'/bin'
|
||||
])
|
||||
|
||||
function delimiterForPlatform(platform = process.platform) {
|
||||
return platform === 'win32' ? ';' : ':'
|
||||
}
|
||||
|
||||
function pathModuleForPlatform(platform = process.platform) {
|
||||
return platform === 'win32' ? path.win32 : path.posix
|
||||
}
|
||||
|
||||
function pathEnvKey(env = process.env, platform = process.platform) {
|
||||
if (platform !== 'win32') return 'PATH'
|
||||
return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH'
|
||||
}
|
||||
|
||||
function currentPathValue(env = process.env, platform = process.platform) {
|
||||
const key = pathEnvKey(env, platform)
|
||||
return env?.[key] || ''
|
||||
}
|
||||
|
||||
function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
|
||||
const seen = new Set()
|
||||
const ordered = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry) continue
|
||||
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
|
||||
for (const part of parts) {
|
||||
if (!part || seen.has(part)) continue
|
||||
seen.add(part)
|
||||
ordered.push(part)
|
||||
}
|
||||
}
|
||||
|
||||
return ordered.join(delimiter)
|
||||
}
|
||||
|
||||
function buildDesktopBackendPath({
|
||||
hermesHome,
|
||||
venvRoot,
|
||||
currentPath = '',
|
||||
platform = process.platform,
|
||||
pathModule = pathModuleForPlatform(platform)
|
||||
} = {}) {
|
||||
const delimiter = delimiterForPlatform(platform)
|
||||
const hermesNodeBin = hermesHome ? pathModule.join(hermesHome, 'node', 'bin') : null
|
||||
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
|
||||
const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES
|
||||
|
||||
return appendUniquePathEntries(
|
||||
[hermesNodeBin, venvBin, currentPath, saneEntries],
|
||||
{ delimiter }
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) } = {}) {
|
||||
if (!hermesHome) return hermesHome
|
||||
const resolved = pathModule.resolve(String(hermesHome))
|
||||
const parent = pathModule.dirname(resolved)
|
||||
if (pathModule.basename(parent).toLowerCase() === 'profiles') {
|
||||
return pathModule.dirname(parent)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function buildDesktopBackendEnv({
|
||||
hermesHome,
|
||||
pythonPathEntries = [],
|
||||
venvRoot,
|
||||
currentEnv = process.env,
|
||||
platform = process.platform,
|
||||
pathModule = pathModuleForPlatform(platform)
|
||||
} = {}) {
|
||||
const delimiter = delimiterForPlatform(platform)
|
||||
const currentPythonPath = currentEnv?.PYTHONPATH || ''
|
||||
const key = pathEnvKey(currentEnv, platform)
|
||||
|
||||
return {
|
||||
PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }),
|
||||
[key]: buildDesktopBackendPath({
|
||||
hermesHome,
|
||||
venvRoot,
|
||||
currentPath: currentPathValue(currentEnv, platform),
|
||||
platform,
|
||||
pathModule
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
POSIX_SANE_PATH_ENTRIES,
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
delimiterForPlatform,
|
||||
normalizeHermesHomeRoot,
|
||||
pathEnvKey
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const path = require('node:path')
|
||||
|
||||
const {
|
||||
POSIX_SANE_PATH_ENTRIES,
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
normalizeHermesHomeRoot,
|
||||
pathEnvKey
|
||||
} = require('./backend-env.cjs')
|
||||
|
||||
test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
|
||||
const result = buildDesktopBackendPath({
|
||||
hermesHome: '/Users/test/.hermes',
|
||||
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
|
||||
currentPath: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
const entries = result.split(':')
|
||||
assert.equal(entries[0], '/Users/test/.hermes/node/bin')
|
||||
assert.equal(entries[1], '/Users/test/.hermes/hermes-agent/venv/bin')
|
||||
assert.ok(entries.includes('/opt/homebrew/bin'), 'Apple Silicon Homebrew bin is added')
|
||||
assert.ok(entries.includes('/opt/homebrew/sbin'), 'Apple Silicon Homebrew sbin is added')
|
||||
assert.ok(entries.includes('/usr/local/sbin'), 'missing standard sbin is added')
|
||||
|
||||
for (const expected of POSIX_SANE_PATH_ENTRIES) {
|
||||
assert.ok(entries.includes(expected), `${expected} should be present`)
|
||||
}
|
||||
})
|
||||
|
||||
test('desktop backend PATH preserves first occurrence and avoids duplicates', () => {
|
||||
const result = buildDesktopBackendPath({
|
||||
hermesHome: '/Users/test/.hermes',
|
||||
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
|
||||
currentPath: '/opt/homebrew/bin:/usr/bin:/opt/homebrew/bin:/bin',
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
const entries = result.split(':')
|
||||
assert.equal(entries.filter(entry => entry === '/opt/homebrew/bin').length, 1)
|
||||
assert.ok(
|
||||
entries.indexOf('/opt/homebrew/bin') < entries.indexOf('/opt/homebrew/sbin'),
|
||||
'existing Homebrew bin keeps its precedence over appended missing sane entries'
|
||||
)
|
||||
})
|
||||
|
||||
test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () => {
|
||||
const env = buildDesktopBackendEnv({
|
||||
hermesHome: '/Users/test/.hermes',
|
||||
pythonPathEntries: ['/repo/hermes-agent'],
|
||||
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
|
||||
currentEnv: {
|
||||
PATH: '/usr/bin:/bin',
|
||||
PYTHONPATH: '/existing/pythonpath'
|
||||
},
|
||||
platform: 'darwin',
|
||||
pathModule: path.posix
|
||||
})
|
||||
|
||||
assert.equal(env.PYTHONPATH, '/repo/hermes-agent:/existing/pythonpath')
|
||||
assert.ok(env.PATH.startsWith('/Users/test/.hermes/node/bin:/Users/test/.hermes/hermes-agent/venv/bin:'))
|
||||
assert.ok(env.PATH.includes('/opt/homebrew/bin'))
|
||||
})
|
||||
|
||||
test('normalizeHermesHomeRoot maps profile homes back to the global Hermes root', () => {
|
||||
assert.equal(
|
||||
normalizeHermesHomeRoot('/Users/test/.hermes/profiles/oracle', { pathModule: path.posix }),
|
||||
'/Users/test/.hermes'
|
||||
)
|
||||
assert.equal(
|
||||
normalizeHermesHomeRoot('C:\\Users\\test\\AppData\\Local\\hermes\\profiles\\oracle', { pathModule: path.win32 }),
|
||||
'C:\\Users\\test\\AppData\\Local\\hermes'
|
||||
)
|
||||
assert.equal(
|
||||
normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }),
|
||||
'/Users/test/.hermes'
|
||||
)
|
||||
})
|
||||
|
||||
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
|
||||
const env = buildDesktopBackendEnv({
|
||||
hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes',
|
||||
pythonPathEntries: ['C:\\repo\\hermes-agent'],
|
||||
venvRoot: 'C:\\Users\\test\\AppData\\Local\\hermes\\hermes-agent\\venv',
|
||||
currentEnv: {
|
||||
Path: 'C:\\Windows\\System32;C:\\Windows',
|
||||
PYTHONPATH: 'C:\\existing\\pythonpath'
|
||||
},
|
||||
platform: 'win32',
|
||||
pathModule: path.win32
|
||||
})
|
||||
|
||||
assert.equal(pathEnvKey({ Path: 'x' }, 'win32'), 'Path')
|
||||
assert.equal(env.PATH, undefined)
|
||||
assert.ok(env.Path.startsWith('C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;'))
|
||||
assert.ok(env.Path.includes('\\venv\\Scripts;'))
|
||||
assert.ok(env.Path.includes(';C:\\Windows\\System32;C:\\Windows'))
|
||||
assert.equal(env.Path.includes('/opt/homebrew/bin'), false)
|
||||
})
|
||||
|
||||
test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => {
|
||||
assert.equal(
|
||||
appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }),
|
||||
'/a:/b:/c'
|
||||
)
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
const _READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m
|
||||
|
||||
/**
|
||||
* Watch a child process's stdout for the `HERMES_DASHBOARD_READY port=<N>`
|
||||
* line that web_server.py prints after uvicorn binds its socket.
|
||||
*
|
||||
* Returns the parsed port. Rejects if:
|
||||
* - the child exits before emitting the line
|
||||
* - the child emits an `error` event
|
||||
* - no line arrives within the timeout
|
||||
*
|
||||
* A single `cleanup()` tears down every listener (data/exit/error/timeout)
|
||||
* on every terminal path — resolve, reject, or timeout — so repeated
|
||||
* backend spawns don't leak listener slots on the child.
|
||||
*/
|
||||
function waitForDashboardPort(child, timeoutMs = 45_000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buf = ''
|
||||
let done = false
|
||||
|
||||
function cleanup() {
|
||||
if (done) return
|
||||
done = true
|
||||
clearTimeout(timer)
|
||||
child.stdout.off('data', onData)
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
}
|
||||
|
||||
function onData(chunk) {
|
||||
buf += chunk.toString()
|
||||
let nl
|
||||
while ((nl = buf.indexOf('\n')) !== -1) {
|
||||
const line = buf.slice(0, nl)
|
||||
buf = buf.slice(nl + 1)
|
||||
const m = line.match(_READY_RE)
|
||||
if (m) {
|
||||
cleanup()
|
||||
resolve(parseInt(m[1], 10))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onExit(code, signal) {
|
||||
cleanup()
|
||||
reject(new Error(`Hermes backend: exited before port announcement (${signal || code})`))
|
||||
}
|
||||
|
||||
function onError(err) {
|
||||
cleanup()
|
||||
reject(err)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanup()
|
||||
reject(new Error(`Timed out waiting for Hermes backend port announcement (${timeoutMs}ms)`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout.on('data', onData)
|
||||
child.on('exit', onExit)
|
||||
child.on('error', onError)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { waitForDashboardPort }
|
||||
@@ -166,39 +166,6 @@ function profileRemoteOverride(config, profile) {
|
||||
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
|
||||
}
|
||||
|
||||
/**
|
||||
* In global-remote mode one backend serves every Desktop profile, so REST calls
|
||||
* that are scoped by renderer-side `request.profile` must carry that scope as a
|
||||
* query parameter. Local pooled backends and per-profile remote overrides do not
|
||||
* need this: they already run against a backend scoped to the target profile.
|
||||
*/
|
||||
function pathWithGlobalRemoteProfile(path, profile, opts = {}) {
|
||||
const scopedProfile = connectionScopeKey(profile)
|
||||
if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) {
|
||||
return path
|
||||
}
|
||||
|
||||
const rawPath = String(path || '')
|
||||
if (!rawPath) {
|
||||
return path
|
||||
}
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(rawPath, 'http://hermes.local')
|
||||
} catch {
|
||||
return path
|
||||
}
|
||||
|
||||
if (parsed.searchParams.has('profile')) {
|
||||
return path
|
||||
}
|
||||
|
||||
parsed.searchParams.set('profile', scopedProfile)
|
||||
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||
}
|
||||
|
||||
function tokenPreview(value) {
|
||||
const raw = String(value || '')
|
||||
|
||||
@@ -280,7 +247,6 @@ module.exports = {
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
|
||||
@@ -24,7 +24,6 @@ const {
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
@@ -91,72 +90,6 @@ test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
|
||||
assert.equal(profileRemoteOverride(null, 'coder'), null)
|
||||
})
|
||||
|
||||
// --- pathWithGlobalRemoteProfile ---
|
||||
|
||||
test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => {
|
||||
assert.equal(
|
||||
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
|
||||
globalRemote: true,
|
||||
profileRemoteOverride: false
|
||||
}),
|
||||
'/api/model/info?profile=iris'
|
||||
)
|
||||
})
|
||||
|
||||
test('pathWithGlobalRemoteProfile preserves existing query params', () => {
|
||||
assert.equal(
|
||||
pathWithGlobalRemoteProfile('/api/model/options?force=1', 'iris', {
|
||||
globalRemote: true,
|
||||
profileRemoteOverride: false
|
||||
}),
|
||||
'/api/model/options?force=1&profile=iris'
|
||||
)
|
||||
})
|
||||
|
||||
test('pathWithGlobalRemoteProfile does not replace an explicit profile query', () => {
|
||||
assert.equal(
|
||||
pathWithGlobalRemoteProfile('/api/model/info?profile=default', 'iris', {
|
||||
globalRemote: true,
|
||||
profileRemoteOverride: false
|
||||
}),
|
||||
'/api/model/info?profile=default'
|
||||
)
|
||||
})
|
||||
|
||||
test('pathWithGlobalRemoteProfile skips local and per-profile remote override paths', () => {
|
||||
assert.equal(
|
||||
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
|
||||
globalRemote: false,
|
||||
profileRemoteOverride: false
|
||||
}),
|
||||
'/api/model/info'
|
||||
)
|
||||
assert.equal(
|
||||
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
|
||||
globalRemote: true,
|
||||
profileRemoteOverride: true
|
||||
}),
|
||||
'/api/model/info'
|
||||
)
|
||||
})
|
||||
|
||||
test('pathWithGlobalRemoteProfile skips empty profile/path safely', () => {
|
||||
assert.equal(
|
||||
pathWithGlobalRemoteProfile('/api/model/info', '', {
|
||||
globalRemote: true,
|
||||
profileRemoteOverride: false
|
||||
}),
|
||||
'/api/model/info'
|
||||
)
|
||||
assert.equal(
|
||||
pathWithGlobalRemoteProfile('', 'iris', {
|
||||
globalRemote: true,
|
||||
profileRemoteOverride: false
|
||||
}),
|
||||
''
|
||||
)
|
||||
})
|
||||
|
||||
// --- normalizeRemoteBaseUrl ---
|
||||
|
||||
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* Helpers for local dashboard session-token discovery.
|
||||
*
|
||||
* The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
|
||||
* spawns the local dashboard, but the dashboard is the source of truth for the
|
||||
* token it actually serves to the renderer. If those drift, HTTP readiness
|
||||
* probes still pass while /api/ws rejects the renderer's token.
|
||||
*/
|
||||
|
||||
const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
|
||||
|
||||
async function fetchPublicText(url, options = {}) {
|
||||
const { protocol } = new URL(url)
|
||||
if (protocol !== 'http:' && protocol !== 'https:') {
|
||||
throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
|
||||
}
|
||||
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
|
||||
if (error.name === 'TimeoutError') {
|
||||
throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
const text = await res.text()
|
||||
|
||||
if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
function extractInjectedDashboardToken(html) {
|
||||
const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
|
||||
if (!match) return null
|
||||
try {
|
||||
return JSON.parse(match[1])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function dashboardIndexUrl(baseUrl) {
|
||||
return `${String(baseUrl || '').replace(/\/+$/, '')}/`
|
||||
}
|
||||
|
||||
async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
|
||||
const fetchText = options.fetchText || fetchPublicText
|
||||
const html = await fetchText(dashboardIndexUrl(baseUrl), {
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
|
||||
})
|
||||
const servedToken = extractInjectedDashboardToken(html)
|
||||
|
||||
if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
|
||||
options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
|
||||
}
|
||||
|
||||
return servedToken || fallbackToken
|
||||
}
|
||||
|
||||
/**
|
||||
* A served token that differs from our spawn token while our child is DEAD
|
||||
* came from a process we did not spawn (orphan/port squatter that satisfied
|
||||
* the public /api/status readiness probe). With a live child the mismatch is
|
||||
* benign: our own backend regenerated the token because the env pin did not
|
||||
* survive the spawn.
|
||||
*/
|
||||
function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
|
||||
return Boolean(servedToken) && servedToken !== spawnToken && !childAlive
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the token the backend actually serves, adopting benign drift and
|
||||
* failing loudly on a foreign backend. `childAlive` is a thunk so liveness is
|
||||
* sampled after the fetch, not before.
|
||||
*/
|
||||
async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
|
||||
const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
|
||||
options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
|
||||
return spawnToken
|
||||
})
|
||||
|
||||
if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) {
|
||||
throw new Error(
|
||||
`${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.`
|
||||
)
|
||||
}
|
||||
|
||||
return servedToken
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
|
||||
adoptServedDashboardToken,
|
||||
dashboardIndexUrl,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
isForeignBackendToken,
|
||||
resolveServedDashboardToken
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
/**
|
||||
* Tests for electron/dashboard-token.cjs.
|
||||
*
|
||||
* Run with: node --test electron/dashboard-token.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
adoptServedDashboardToken,
|
||||
dashboardIndexUrl,
|
||||
extractInjectedDashboardToken,
|
||||
fetchPublicText,
|
||||
isForeignBackendToken,
|
||||
resolveServedDashboardToken
|
||||
} = require('./dashboard-token.cjs')
|
||||
|
||||
test('extractInjectedDashboardToken reads the JSON-encoded dashboard token', () => {
|
||||
const html = '<script>window.__HERMES_SESSION_TOKEN__="served-token";window.__HERMES_BASE_PATH__=""</script>'
|
||||
assert.equal(extractInjectedDashboardToken(html), 'served-token')
|
||||
})
|
||||
|
||||
test('extractInjectedDashboardToken handles escaped token strings', () => {
|
||||
const html = '<script>window.__HERMES_SESSION_TOKEN__="served\\\\token\\"quoted";</script>'
|
||||
assert.equal(extractInjectedDashboardToken(html), 'served\\token"quoted')
|
||||
})
|
||||
|
||||
test('extractInjectedDashboardToken returns null for missing or malformed values', () => {
|
||||
assert.equal(extractInjectedDashboardToken('<html></html>'), null)
|
||||
assert.equal(extractInjectedDashboardToken('<script>window.__HERMES_SESSION_TOKEN__={bad}</script>'), null)
|
||||
})
|
||||
|
||||
test('dashboardIndexUrl preserves dashboard path prefixes', () => {
|
||||
assert.equal(dashboardIndexUrl('http://127.0.0.1:9120'), 'http://127.0.0.1:9120/')
|
||||
assert.equal(dashboardIndexUrl('https://host.example/hermes/'), 'https://host.example/hermes/')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken uses the served token and logs when it differs', async () => {
|
||||
const logs = []
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async url => {
|
||||
assert.equal(url, 'http://127.0.0.1:9120/')
|
||||
return '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
|
||||
},
|
||||
rememberLog: line => logs.push(line)
|
||||
})
|
||||
|
||||
assert.equal(token, 'served-token')
|
||||
assert.equal(logs.length, 1)
|
||||
assert.match(logs[0], /served a different session token/)
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken falls back when the served HTML has no token', async () => {
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async () => '<html></html>',
|
||||
rememberLog: () => {
|
||||
throw new Error('should not log when no served token is present')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(token, 'spawn-token')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken does not log when served token matches fallback', async () => {
|
||||
const token = await resolveServedDashboardToken('http://127.0.0.1:9120', 'same-token', {
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="same-token";</script>',
|
||||
rememberLog: () => {
|
||||
throw new Error('should not log when token already matches')
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(token, 'same-token')
|
||||
})
|
||||
|
||||
test('resolveServedDashboardToken propagates fetch errors so callers can fall back explicitly', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
resolveServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
fetchText: async () => {
|
||||
throw new Error('boom')
|
||||
}
|
||||
}),
|
||||
/boom/
|
||||
)
|
||||
})
|
||||
|
||||
test('fetchPublicText rejects unsupported protocols', async () => {
|
||||
await assert.rejects(() => fetchPublicText('file:///tmp/index.html'), /Unsupported Hermes backend URL protocol/)
|
||||
})
|
||||
|
||||
test('isForeignBackendToken only flags a mismatched token from a dead child', () => {
|
||||
const cases = [
|
||||
[{ servedToken: 'other', spawnToken: 'mine', childAlive: false }, true],
|
||||
// Live child + drift = our backend regenerated the token (env pin lost).
|
||||
[{ servedToken: 'other', spawnToken: 'mine', childAlive: true }, false],
|
||||
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: false }, false],
|
||||
[{ servedToken: 'mine', spawnToken: 'mine', childAlive: true }, false],
|
||||
[{ servedToken: null, spawnToken: 'mine', childAlive: false }, false],
|
||||
[{ servedToken: '', spawnToken: 'mine', childAlive: false }, false]
|
||||
]
|
||||
for (const [input, expected] of cases) {
|
||||
assert.equal(isForeignBackendToken(input), expected, JSON.stringify(input))
|
||||
}
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken adopts drift from a live child', async () => {
|
||||
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => true,
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="served-token";</script>'
|
||||
})
|
||||
|
||||
assert.equal(token, 'served-token')
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken refuses a foreign token when our child is dead', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => false,
|
||||
fetchText: async () => '<script>window.__HERMES_SESSION_TOKEN__="squatter-token";</script>',
|
||||
label: 'Hermes backend for profile "work"'
|
||||
}),
|
||||
/profile "work".*process we did not spawn/
|
||||
)
|
||||
})
|
||||
|
||||
test('adoptServedDashboardToken falls back to the spawn token when the fetch fails', async () => {
|
||||
const logs = []
|
||||
const token = await adoptServedDashboardToken('http://127.0.0.1:9120', 'spawn-token', {
|
||||
childAlive: () => true,
|
||||
fetchText: async () => {
|
||||
throw new Error('boom')
|
||||
},
|
||||
rememberLog: line => logs.push(line)
|
||||
})
|
||||
|
||||
assert.equal(token, 'spawn-token')
|
||||
assert.equal(logs.length, 1)
|
||||
assert.match(logs[0], /could not read served dashboard token \(Hermes backend\): boom/)
|
||||
})
|
||||
@@ -1,174 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
// Resolve git-worktree relationships for a set of session cwds, reading git's
|
||||
// on-disk metadata directly (no `git` spawn per path):
|
||||
//
|
||||
// - A normal checkout has a `.git` DIRECTORY at its root → it's the main
|
||||
// worktree; its repo root IS that directory's parent.
|
||||
// - A linked worktree has a `.git` FILE: `gitdir: <repo>/.git/worktrees/<name>`.
|
||||
// That admin dir's `commondir` points back at the shared `<repo>/.git`, whose
|
||||
// parent is the main repo root.
|
||||
//
|
||||
// Grouping by repoRoot therefore clusters a repo's main checkout with all of its
|
||||
// linked worktrees, regardless of how the worktree directories are named. The
|
||||
// branch (read from the worktree's own HEAD) gives each worktree a meaningful
|
||||
// label.
|
||||
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { resolveRequestedPathForIpc } = require('./hardening.cjs')
|
||||
|
||||
// Walk up from `start` to the nearest ancestor that carries a `.git` entry
|
||||
// (file for a linked worktree, dir for the main checkout). Capped so a stray
|
||||
// path can't loop forever.
|
||||
function findGitHost(start, fsImpl) {
|
||||
let dir = start
|
||||
|
||||
for (let i = 0; i < 64; i += 1) {
|
||||
const dotgit = path.join(dir, '.git')
|
||||
|
||||
try {
|
||||
if (fsImpl.existsSync(dotgit)) {
|
||||
return dir
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir)
|
||||
|
||||
if (parent === dir) {
|
||||
return null
|
||||
}
|
||||
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function readBranch(gitDir, fsImpl) {
|
||||
try {
|
||||
const head = fsImpl.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim()
|
||||
const ref = head.match(/^ref:\s*refs\/heads\/(.+)$/)
|
||||
|
||||
if (ref) {
|
||||
return ref[1]
|
||||
}
|
||||
|
||||
// Detached HEAD: surface a short sha so the worktree still gets a label.
|
||||
return /^[0-9a-f]{7,40}$/i.test(head) ? head.slice(0, 8) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Given the directory that owns the `.git` entry, resolve its worktree identity.
|
||||
function resolveFromHost(host, fsImpl) {
|
||||
const dotgit = path.join(host, '.git')
|
||||
let stat
|
||||
|
||||
try {
|
||||
stat = fsImpl.statSync(dotgit)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return {
|
||||
repoRoot: host,
|
||||
worktreeRoot: host,
|
||||
isMainWorktree: true,
|
||||
branch: readBranch(dotgit, fsImpl)
|
||||
}
|
||||
}
|
||||
|
||||
// Linked worktree: `.git` is a file pointing at the admin dir.
|
||||
let contents
|
||||
|
||||
try {
|
||||
contents = fsImpl.readFileSync(dotgit, 'utf8').trim()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = contents.match(/^gitdir:\s*(.+)$/m)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const adminDir = path.resolve(host, match[1].trim())
|
||||
|
||||
// `commondir` resolves to the shared `<repo>/.git`; fall back to walking two
|
||||
// levels up from `<repo>/.git/worktrees/<name>` if it's missing.
|
||||
let commonDir
|
||||
|
||||
try {
|
||||
const rel = fsImpl.readFileSync(path.join(adminDir, 'commondir'), 'utf8').trim()
|
||||
commonDir = path.resolve(adminDir, rel)
|
||||
} catch {
|
||||
commonDir = path.dirname(path.dirname(adminDir))
|
||||
}
|
||||
|
||||
return {
|
||||
repoRoot: path.dirname(commonDir),
|
||||
worktreeRoot: host,
|
||||
isMainWorktree: false,
|
||||
branch: readBranch(adminDir, fsImpl)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorktree(startPath, fsImpl = fs) {
|
||||
let resolved
|
||||
|
||||
try {
|
||||
resolved = resolveRequestedPathForIpc(startPath, { purpose: 'Worktree lookup' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
let start = resolved
|
||||
|
||||
try {
|
||||
const stat = fsImpl.statSync(resolved)
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
start = path.dirname(resolved)
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const host = findGitHost(start, fsImpl)
|
||||
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
|
||||
return resolveFromHost(host, fsImpl)
|
||||
}
|
||||
|
||||
// Batch entry point for the renderer: maps each requested cwd to its worktree
|
||||
// info (or null when it isn't inside a git checkout / can't be read). Dedupes so
|
||||
// many sessions sharing a cwd cost one lookup.
|
||||
async function worktreesForIpc(cwds, options = {}) {
|
||||
const fsImpl = options.fs || fs
|
||||
const list = Array.isArray(cwds) ? cwds : []
|
||||
const out = {}
|
||||
|
||||
for (const cwd of list) {
|
||||
if (typeof cwd !== 'string' || !cwd.trim() || cwd in out) {
|
||||
continue
|
||||
}
|
||||
|
||||
out[cwd] = resolveWorktree(cwd, fsImpl)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolveWorktree,
|
||||
worktreesForIpc
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { fileURLToPath } = require('node:url')
|
||||
|
||||
@@ -143,14 +142,7 @@ function rejectUnsafePathSyntax(filePath, purpose = 'File read') {
|
||||
|
||||
function resolveRequestedPathForIpc(filePath, options = {}) {
|
||||
const purpose = String(options.purpose || 'File read')
|
||||
let raw = rejectUnsafePathSyntax(filePath, purpose)
|
||||
|
||||
// Gateway-reported cwds (config `terminal.cwd`, remote sessions) routinely
|
||||
// arrive as `~/...`. Node's fs has no shell — without expansion the path
|
||||
// resolves under process.cwd() and every read "ENOENT"s forever.
|
||||
if (raw === '~' || raw.startsWith('~/') || raw.startsWith('~\\')) {
|
||||
raw = path.join(os.homedir(), raw.slice(1))
|
||||
}
|
||||
const raw = rejectUnsafePathSyntax(filePath, purpose)
|
||||
|
||||
if (/^file:/i.test(raw)) {
|
||||
let resolvedPath
|
||||
|
||||
@@ -106,19 +106,6 @@ test('resolveRequestedPathForIpc resolves relative paths from the trimmed base d
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveRequestedPathForIpc expands ~ to the home directory', () => {
|
||||
assert.equal(resolveRequestedPathForIpc('~', { purpose: 'Directory read' }), path.resolve(os.homedir()))
|
||||
assert.equal(
|
||||
resolveRequestedPathForIpc('~/www/project', { purpose: 'Directory read' }),
|
||||
path.resolve(os.homedir(), 'www/project')
|
||||
)
|
||||
// `~user` shorthand is NOT expanded — only the caller's own home.
|
||||
assert.equal(
|
||||
resolveRequestedPathForIpc('~other/secret', { baseDir: os.tmpdir(), purpose: 'Directory read' }),
|
||||
path.resolve(os.tmpdir(), '~other/secret')
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
@@ -26,25 +26,17 @@ const { pathToFileURL } = require('node:url')
|
||||
const { execFileSync, spawn } = require('node:child_process')
|
||||
const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
const {
|
||||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
createSessionWindowRegistry,
|
||||
SESSION_WINDOW_MIN_HEIGHT,
|
||||
SESSION_WINDOW_MIN_WIDTH
|
||||
} = require('./session-windows.cjs')
|
||||
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
|
||||
const { adoptServedDashboardToken } = require('./dashboard-token.cjs')
|
||||
const { waitForDashboardPort } = require('./backend-ready.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
|
||||
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const { worktreesForIpc } = require('./git-worktrees.cjs')
|
||||
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
|
||||
const {
|
||||
OFFICIAL_REPO_HTTPS_URL,
|
||||
isOfficialSshRemote
|
||||
} = require('./update-remote.cjs')
|
||||
const {
|
||||
buildPosixCleanupScript,
|
||||
buildWindowsCleanupScript,
|
||||
@@ -64,7 +56,6 @@ const {
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
@@ -102,7 +93,6 @@ try {
|
||||
nodePty = require(nodePtyDir)
|
||||
}
|
||||
} catch {
|
||||
console.log(`[terminal] failed to load node-pty from path ${nodePtyDir}`)
|
||||
nodePty = null
|
||||
nodePtyDir = null
|
||||
}
|
||||
@@ -115,6 +105,8 @@ if (USER_DATA_OVERRIDE) {
|
||||
app.setPath('userData', resolvedUserData)
|
||||
}
|
||||
|
||||
const PORT_FLOOR = 9120
|
||||
const PORT_CEILING = 9199
|
||||
const DEV_SERVER = process.env.HERMES_DESKTOP_DEV_SERVER
|
||||
const IS_PACKAGED = app.isPackaged
|
||||
const IS_MAC = process.platform === 'darwin'
|
||||
@@ -243,18 +235,8 @@ if (INSTALL_STAMP) {
|
||||
// HERMES_HOME beneath the throwaway userData dir so a fresh-install run never
|
||||
// touches the user's real ~/.hermes / %LOCALAPPDATA%\hermes.
|
||||
function resolveHermesHome() {
|
||||
if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME)
|
||||
if (process.env.HERMES_HOME) return path.resolve(process.env.HERMES_HOME)
|
||||
if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home')
|
||||
if (IS_WINDOWS) {
|
||||
// A GUI app launched from Explorer inherits the environment block captured
|
||||
// at login, so a HERMES_HOME set via `setx` AFTER login is invisible in
|
||||
// process.env even though the CLI (a fresh shell) sees it. Without this the
|
||||
// backend silently falls back to %LOCALAPPDATA%\hermes and reports "No
|
||||
// inference provider configured" despite a valid configured home (#45471).
|
||||
// Consult the live User-scoped registry value before the default below.
|
||||
const fromRegistry = readWindowsUserEnvVar('HERMES_HOME')
|
||||
if (fromRegistry) return normalizeHermesHomeRoot(fromRegistry)
|
||||
}
|
||||
if (IS_WINDOWS && process.env.LOCALAPPDATA) {
|
||||
const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes')
|
||||
const legacy = path.join(app.getPath('home'), '.hermes')
|
||||
@@ -358,110 +340,10 @@ const APP_ICON_PATHS = [
|
||||
let rendererTitleBarTheme = null
|
||||
const terminalSessions = new Map()
|
||||
|
||||
// Force the NATIVE window appearance (vibrancy material, titlebar, the
|
||||
// pre-first-paint window background) to follow the APP theme instead of the
|
||||
// OS appearance. With `vibrancy` set, macOS paints an NSVisualEffectView that
|
||||
// tracks the window's effective appearance and ignores `backgroundColor` —
|
||||
// so a dark-themed app on a light-mode Mac flashes a white material on every
|
||||
// new window until the renderer covers it. The renderer reports its mode via
|
||||
// 'hermes:native-theme' ('dark' | 'light' | 'system'); we pin
|
||||
// nativeTheme.themeSource to it and persist the value so cold launches paint
|
||||
// correctly before the renderer has even loaded.
|
||||
const NATIVE_THEME_CONFIG_PATH = path.join(app.getPath('userData'), 'native-theme.json')
|
||||
const THEME_SOURCES = new Set(['dark', 'light', 'system'])
|
||||
|
||||
function readPersistedThemeSource() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(NATIVE_THEME_CONFIG_PATH, 'utf8'))
|
||||
|
||||
if (parsed && THEME_SOURCES.has(parsed.themeSource)) {
|
||||
return parsed.themeSource
|
||||
}
|
||||
} catch {
|
||||
// Missing / malformed → follow the OS like a fresh install.
|
||||
}
|
||||
|
||||
return 'system'
|
||||
}
|
||||
|
||||
function writePersistedThemeSource(mode) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(NATIVE_THEME_CONFIG_PATH), { recursive: true })
|
||||
fs.writeFileSync(NATIVE_THEME_CONFIG_PATH, JSON.stringify({ themeSource: mode }, null, 2), 'utf8')
|
||||
} catch (error) {
|
||||
rememberLog(`[theme] write native theme failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
nativeTheme.themeSource = readPersistedThemeSource()
|
||||
|
||||
// Window translucency (see-through window). One lever, 0–100; 0 = off (the
|
||||
// default). Mapped to the native window opacity so the desktop shows through
|
||||
// the whole window. Persisted so a cold launch applies it at window creation,
|
||||
// before the renderer reports its value. macOS + Windows only; `setOpacity` is
|
||||
// a no-op on Linux. See store/translucency.
|
||||
const TRANSLUCENCY_CONFIG_PATH = path.join(app.getPath('userData'), 'translucency.json')
|
||||
|
||||
function clampIntensity(value) {
|
||||
const n = Math.round(Number(value))
|
||||
|
||||
return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 0
|
||||
}
|
||||
|
||||
function readPersistedTranslucency() {
|
||||
try {
|
||||
return clampIntensity(JSON.parse(fs.readFileSync(TRANSLUCENCY_CONFIG_PATH, 'utf8')).intensity)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function writePersistedTranslucency(intensity) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(TRANSLUCENCY_CONFIG_PATH), { recursive: true })
|
||||
fs.writeFileSync(TRANSLUCENCY_CONFIG_PATH, JSON.stringify({ intensity }, null, 2), 'utf8')
|
||||
} catch (error) {
|
||||
rememberLog(`[translucency] write failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
let translucencyIntensity = readPersistedTranslucency()
|
||||
|
||||
// Map the 0–100 lever to a window opacity. Floor at 0.3 so the most see-through
|
||||
// setting is still usable rather than nearly invisible. 0 → fully opaque.
|
||||
function windowOpacity() {
|
||||
return 1 - (translucencyIntensity / 100) * 0.7
|
||||
}
|
||||
|
||||
// Re-apply translucency to a live window (runtime toggle, no recreation).
|
||||
// `setOpacity` is a no-op on Linux, which is fine — it just stays opaque there.
|
||||
function applyWindowTranslucency(win) {
|
||||
if (!win || win.isDestroyed() || typeof win.setOpacity !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
win.setOpacity(windowOpacity())
|
||||
} catch (error) {
|
||||
rememberLog(`[translucency] apply failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function isHexColor(value) {
|
||||
return typeof value === 'string' && /^#[0-9a-f]{6}$/i.test(value)
|
||||
}
|
||||
|
||||
// Background color to paint a window with BEFORE its renderer loads, so a new
|
||||
// (or reopened) window doesn't flash white/light in dark mode. Prefer the theme
|
||||
// the renderer last reported; fall back to the OS preference on first launch.
|
||||
function getWindowBackgroundColor() {
|
||||
if (rendererTitleBarTheme && isHexColor(rendererTitleBarTheme.background)) {
|
||||
return rendererTitleBarTheme.background
|
||||
}
|
||||
|
||||
return nativeTheme.shouldUseDarkColors ? '#111111' : '#f7f7f7'
|
||||
}
|
||||
|
||||
function getTitleBarOverlayOptions() {
|
||||
if (IS_MAC) {
|
||||
return { height: TITLEBAR_HEIGHT }
|
||||
@@ -1274,14 +1156,10 @@ function findSystemPython() {
|
||||
if (pyExe) {
|
||||
for (const version of SUPPORTED_VERSIONS) {
|
||||
try {
|
||||
const out = execFileSync(
|
||||
pyExe,
|
||||
[`-${version}`, '-c', 'import sys; print(sys.executable)'],
|
||||
hiddenWindowsChildOptions({
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
)
|
||||
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}))
|
||||
const candidate = out.trim()
|
||||
if (candidate && fileExists(candidate)) return candidate
|
||||
} catch {
|
||||
@@ -1416,15 +1294,11 @@ function resolveUpdateRoot() {
|
||||
|
||||
function runGit(args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
resolveGitBinary(),
|
||||
IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args,
|
||||
hiddenWindowsChildOptions({
|
||||
cwd: options.cwd,
|
||||
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
)
|
||||
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({
|
||||
cwd: options.cwd,
|
||||
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}))
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
@@ -1848,44 +1722,6 @@ async function applyUpdates(opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handOffWindowsBootstrapRecovery(reason) {
|
||||
if (!IS_WINDOWS || !IS_PACKAGED) return false
|
||||
|
||||
const updater = resolveUpdaterBinary()
|
||||
if (!updater) return false
|
||||
|
||||
const updateRoot = resolveUpdateRoot()
|
||||
const { branch: configuredBranch } = readDesktopUpdateConfig()
|
||||
const branch = directoryExists(path.join(updateRoot, '.git'))
|
||||
? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH)
|
||||
: configuredBranch || DEFAULT_UPDATE_BRANCH
|
||||
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
|
||||
const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes')
|
||||
const updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
|
||||
await releaseBackendLockForUpdate(updateRoot)
|
||||
|
||||
const child = spawn(updater, updaterArgs, {
|
||||
cwd: HERMES_HOME,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME,
|
||||
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter)
|
||||
},
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: false
|
||||
})
|
||||
child.unref()
|
||||
|
||||
rememberLog(`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`)
|
||||
setTimeout(() => {
|
||||
app.quit()
|
||||
}, 600)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Resolve the hermes CLI to drive an in-app update: prefer the venv shim in
|
||||
// the install we're updating, fall back to `hermes` on PATH.
|
||||
function resolveHermesCliBinary(updateRoot) {
|
||||
@@ -1899,15 +1735,11 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
|
||||
return new Promise(resolve => {
|
||||
let child
|
||||
try {
|
||||
child = spawn(
|
||||
command,
|
||||
args,
|
||||
hiddenWindowsChildOptions({
|
||||
cwd,
|
||||
env: { ...process.env, ...(env || {}) },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
)
|
||||
child = spawn(command, args, hiddenWindowsChildOptions({
|
||||
cwd,
|
||||
env: { ...process.env, ...(env || {}) },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}))
|
||||
} catch (err) {
|
||||
resolve({ code: 1, error: err.message })
|
||||
return
|
||||
@@ -2295,11 +2127,9 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) {
|
||||
label,
|
||||
command: python,
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
pythonPathEntries: [root],
|
||||
venvRoot: path.join(root, 'venv')
|
||||
}),
|
||||
env: {
|
||||
PYTHONPATH: [root, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
|
||||
},
|
||||
root,
|
||||
bootstrap: Boolean(options.bootstrap),
|
||||
shell: false
|
||||
@@ -2318,11 +2148,9 @@ function createActiveBackend(dashboardArgs) {
|
||||
label: `Hermes at ${ACTIVE_HERMES_ROOT}`,
|
||||
command: fileExists(venvPython) ? venvPython : findSystemPython(),
|
||||
args: ['-m', 'hermes_cli.main', ...dashboardArgs],
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
pythonPathEntries: [ACTIVE_HERMES_ROOT],
|
||||
venvRoot: VENV_ROOT
|
||||
}),
|
||||
env: {
|
||||
PYTHONPATH: [ACTIVE_HERMES_ROOT, process.env.PYTHONPATH].filter(Boolean).join(path.delimiter)
|
||||
},
|
||||
root: ACTIVE_HERMES_ROOT,
|
||||
bootstrap: true,
|
||||
shell: false
|
||||
@@ -2483,14 +2311,6 @@ async function ensureRuntime(backend) {
|
||||
if (backend.kind === 'bootstrap-needed') {
|
||||
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
|
||||
|
||||
if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) {
|
||||
const handoffError = new Error('Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.')
|
||||
handoffError.isBootstrapFailure = true
|
||||
handoffError.bootstrapHandedOff = true
|
||||
bootstrapFailure = handoffError
|
||||
throw handoffError
|
||||
}
|
||||
|
||||
// Eagerly flip the bootstrap UI state to 'active' so the renderer
|
||||
// shows the install overlay BEFORE the runner finishes fetching the
|
||||
// manifest (which on slow networks can take tens of seconds and would
|
||||
@@ -2620,6 +2440,23 @@ async function ensureRuntime(backend) {
|
||||
return backend
|
||||
}
|
||||
|
||||
function isPortAvailable(port) {
|
||||
return new Promise(resolve => {
|
||||
const server = net.createServer()
|
||||
server.once('error', () => resolve(false))
|
||||
server.once('listening', () => {
|
||||
server.close(() => resolve(true))
|
||||
})
|
||||
server.listen(port, '127.0.0.1')
|
||||
})
|
||||
}
|
||||
|
||||
async function pickPort() {
|
||||
for (let port = PORT_FLOOR; port <= PORT_CEILING; port += 1) {
|
||||
if (await isPortAvailable(port)) return port
|
||||
}
|
||||
throw new Error(`No free localhost port in ${PORT_FLOOR}-${PORT_CEILING}`)
|
||||
}
|
||||
|
||||
function fetchJson(url, token, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -4697,41 +4534,38 @@ async function spawnPoolBackend(profile, entry) {
|
||||
}
|
||||
}
|
||||
|
||||
const port = await pickPort()
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
|
||||
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
|
||||
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
||||
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0']
|
||||
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
|
||||
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
const webDist = resolveWebDist()
|
||||
|
||||
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
||||
|
||||
const child = spawn(
|
||||
backend.command,
|
||||
backend.args,
|
||||
hiddenWindowsChildOptions({
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME,
|
||||
...backend.env,
|
||||
// Pin the gateway's tool/terminal cwd to the same directory we chose for
|
||||
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
|
||||
// can still point at the install dir even when spawn cwd is home.
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_WEB_DIST: webDist
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
)
|
||||
const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME,
|
||||
...backend.env,
|
||||
// Pin the gateway's tool/terminal cwd to the same directory we chose for
|
||||
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
|
||||
// can still point at the install dir even when spawn cwd is home.
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_WEB_DIST: webDist
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}))
|
||||
entry.process = child
|
||||
entry.port = port
|
||||
entry.token = token
|
||||
|
||||
child.stdout.on('data', rememberLog)
|
||||
@@ -4757,28 +4591,18 @@ async function spawnPoolBackend(profile, entry) {
|
||||
}
|
||||
})
|
||||
|
||||
// Discover the ephemeral port the child bound to
|
||||
const port = await Promise.race([waitForDashboardPort(child), startFailed])
|
||||
entry.port = port
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await Promise.race([waitForHermes(baseUrl, token), startFailed])
|
||||
ready = true
|
||||
const authToken = await adoptServedDashboardToken(baseUrl, token, {
|
||||
childAlive: () => child.exitCode === null && !child.killed,
|
||||
label: `Hermes backend for profile "${profile}"`,
|
||||
rememberLog
|
||||
})
|
||||
entry.token = authToken
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
authMode: 'token',
|
||||
token: authToken,
|
||||
token,
|
||||
profile,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
@@ -4900,9 +4724,10 @@ async function startHermes() {
|
||||
}
|
||||
}
|
||||
|
||||
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
|
||||
const port = await pickPort()
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
// --port 0: the OS assigns an ephemeral port; the child announces it on stdout.
|
||||
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0']
|
||||
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
|
||||
// Pin the desktop's chosen profile via the global --profile flag. This is
|
||||
// deterministic (it wins over the sticky ~/.hermes/active_profile file) and
|
||||
// resolves HERMES_HOME the same way `hermes -p <name>` does on the CLI. An
|
||||
@@ -4920,34 +4745,30 @@ async function startHermes() {
|
||||
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
|
||||
rememberLog(`Starting Hermes backend via ${backend.label}`)
|
||||
|
||||
hermesProcess = spawn(
|
||||
backend.command,
|
||||
backend.args,
|
||||
hiddenWindowsChildOptions({
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
// Explicitly pin HERMES_HOME for the child so Python's get_hermes_home()
|
||||
// resolves to the SAME location our resolveHermesHome() picked. Without
|
||||
// this pin, Python falls back to ~/.hermes on every platform — fine on
|
||||
// mac/linux (where our default matches), but on Windows our default is
|
||||
// %LOCALAPPDATA%\hermes, which differs from C:\Users\<u>\.hermes.
|
||||
// Mismatch would split config / sessions / .env / logs across two
|
||||
// directories. install.ps1 sets HERMES_HOME via setx; the desktop
|
||||
// can't reliably do that, so we set it inline for every spawn.
|
||||
HERMES_HOME,
|
||||
...backend.env,
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_WEB_DIST: webDist
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
)
|
||||
hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
// Explicitly pin HERMES_HOME for the child so Python's get_hermes_home()
|
||||
// resolves to the SAME location our resolveHermesHome() picked. Without
|
||||
// this pin, Python falls back to ~/.hermes on every platform — fine on
|
||||
// mac/linux (where our default matches), but on Windows our default is
|
||||
// %LOCALAPPDATA%\hermes, which differs from C:\Users\<u>\.hermes.
|
||||
// Mismatch would split config / sessions / .env / logs across two
|
||||
// directories. install.ps1 sets HERMES_HOME via setx; the desktop
|
||||
// can't reliably do that, so we set it inline for every spawn.
|
||||
HERMES_HOME,
|
||||
...backend.env,
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_WEB_DIST: webDist
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}))
|
||||
|
||||
hermesProcess.stdout.on('data', rememberLog)
|
||||
hermesProcess.stderr.on('data', rememberLog)
|
||||
@@ -4996,19 +4817,10 @@ async function startHermes() {
|
||||
}
|
||||
})
|
||||
|
||||
await advanceBootProgress('backend.port', 'Waiting for Hermes backend to launch', 86)
|
||||
// Discover the ephemeral port the child bound to
|
||||
const port = await Promise.race([waitForDashboardPort(hermesProcess), backendStartFailed])
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
await advanceBootProgress('backend.wait', 'Waiting for Hermes backend to become ready', 90)
|
||||
await Promise.race([waitForHermes(baseUrl, token), backendStartFailed])
|
||||
backendReady = true
|
||||
const authToken = await adoptServedDashboardToken(baseUrl, token, {
|
||||
// The exit/error handlers null hermesProcess when the child dies.
|
||||
childAlive: () => hermesProcess !== null && hermesProcess.exitCode === null && !hermesProcess.killed,
|
||||
rememberLog
|
||||
})
|
||||
updateBootProgress({
|
||||
phase: 'backend.ready',
|
||||
message: 'Hermes backend is ready. Finalizing desktop startup',
|
||||
@@ -5022,8 +4834,8 @@ async function startHermes() {
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
authMode: 'token',
|
||||
token: authToken,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(authToken)}`,
|
||||
token,
|
||||
wsUrl: `ws://127.0.0.1:${port}/api/ws?token=${encodeURIComponent(token)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
@@ -5085,68 +4897,52 @@ function focusWindow(win) {
|
||||
win.focus()
|
||||
}
|
||||
|
||||
function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) {
|
||||
const icon = getAppIconPath()
|
||||
const win = new BrowserWindow({
|
||||
width: SESSION_WINDOW_MIN_WIDTH,
|
||||
height: SESSION_WINDOW_MIN_HEIGHT,
|
||||
minWidth: SESSION_WINDOW_MIN_WIDTH,
|
||||
minHeight: SESSION_WINDOW_MIN_HEIGHT,
|
||||
title: 'Hermes',
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: getTitleBarOverlayOptions(),
|
||||
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
||||
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
||||
opacity: windowOpacity(),
|
||||
icon,
|
||||
// Don't show until the renderer's first themed paint is ready. macOS
|
||||
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
|
||||
// material (which follows the OS appearance, not the app theme), so a
|
||||
// dark-themed app on a light-mode Mac flashes white until the renderer
|
||||
// covers it. ready-to-show fires after the boot-time paint in
|
||||
// themes/context.tsx, so the window appears already themed.
|
||||
show: false,
|
||||
backgroundColor: getWindowBackgroundColor(),
|
||||
webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs'))
|
||||
})
|
||||
|
||||
if (IS_MAC) {
|
||||
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
|
||||
}
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
if (!win.isDestroyed()) win.show()
|
||||
})
|
||||
|
||||
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
|
||||
win.on('enter-full-screen', () => sendWindowStateChanged(true))
|
||||
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||
win.on('leave-full-screen', () => sendWindowStateChanged(false))
|
||||
|
||||
wireCommonWindowHandlers(win)
|
||||
|
||||
win.loadURL(
|
||||
buildSessionWindowUrl(sessionId, {
|
||||
devServer: DEV_SERVER,
|
||||
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
|
||||
watch,
|
||||
newSession
|
||||
})
|
||||
)
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
// Open (or focus) a standalone window for a single chat session.
|
||||
function createSessionWindow(sessionId, { watch = false } = {}) {
|
||||
return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch }))
|
||||
}
|
||||
function createSessionWindow(sessionId) {
|
||||
return sessionWindows.openOrFocus(sessionId, () => {
|
||||
const icon = getAppIconPath()
|
||||
const win = new BrowserWindow({
|
||||
width: 480,
|
||||
height: 800,
|
||||
minWidth: 420,
|
||||
minHeight: 620,
|
||||
title: 'Hermes',
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: getTitleBarOverlayOptions(),
|
||||
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
||||
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
||||
icon,
|
||||
backgroundColor: '#f7f7f7',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
webviewTag: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
devTools: true
|
||||
}
|
||||
})
|
||||
|
||||
// Open a fresh compact window on the new-session draft (#/). Not registry-keyed:
|
||||
// like ⌘N in a browser, every press opens a new window — and a draft window that
|
||||
// later converts to a real session must not get refocused as if it were blank.
|
||||
function createNewSessionWindow() {
|
||||
return spawnSecondaryWindow({ newSession: true })
|
||||
if (IS_MAC) {
|
||||
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
|
||||
}
|
||||
|
||||
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
|
||||
win.on('enter-full-screen', () => sendWindowStateChanged(true))
|
||||
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||
win.on('leave-full-screen', () => sendWindowStateChanged(false))
|
||||
|
||||
wireCommonWindowHandlers(win)
|
||||
|
||||
win.loadURL(
|
||||
buildSessionWindowUrl(sessionId, {
|
||||
devServer: DEV_SERVER,
|
||||
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex()
|
||||
})
|
||||
)
|
||||
|
||||
return win
|
||||
})
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
@@ -5167,18 +4963,25 @@ function createWindow() {
|
||||
titleBarOverlay: getTitleBarOverlayOptions(),
|
||||
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
||||
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
||||
opacity: windowOpacity(),
|
||||
icon,
|
||||
// Hidden until the first themed paint so macOS `vibrancy` (which ignores
|
||||
// `backgroundColor` and follows the OS appearance) can't flash a light
|
||||
// material before the renderer paints the app theme. See createSessionWindow.
|
||||
show: false,
|
||||
backgroundColor: getWindowBackgroundColor(),
|
||||
// Shared with the secondary session windows (chatWindowWebPreferences) so
|
||||
// both keep `backgroundThrottling: false` — the chat transcript streams via
|
||||
// a requestAnimationFrame-gated flush that Chromium pauses for blurred
|
||||
// windows, stalling the live answer until refocus. See session-windows.cjs.
|
||||
webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs'))
|
||||
backgroundColor: '#f7f7f7',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
webviewTag: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
devTools: true,
|
||||
// Keep timers + requestAnimationFrame running at full speed when the
|
||||
// window is blurred/occluded. The chat transcript streams to the screen
|
||||
// through a requestAnimationFrame-gated flush (useSessionStateCache),
|
||||
// so with Chromium's default background throttling the live answer
|
||||
// stalls whenever this window isn't focused (e.g. you switch to your
|
||||
// editor mid-turn, or open detached devtools) and only appears once you
|
||||
// refocus or refresh. A streaming chat app must render in the
|
||||
// background, so opt out — matching the secondary windows above.
|
||||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
|
||||
if (IS_MAC) {
|
||||
@@ -5197,10 +5000,6 @@ function createWindow() {
|
||||
}
|
||||
}
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.show()
|
||||
})
|
||||
|
||||
mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true))
|
||||
mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true))
|
||||
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||
@@ -5312,17 +5111,12 @@ ipcMain.handle('hermes:backend:touch', async (_event, profile) => {
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile))
|
||||
ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
|
||||
ipcMain.handle('hermes:window:openSession', async (_event, sessionId) => {
|
||||
if (typeof sessionId !== 'string' || !sessionId.trim()) {
|
||||
return { ok: false, error: 'invalid-session-id' }
|
||||
}
|
||||
|
||||
createSessionWindow(sessionId.trim(), { watch: opts?.watch === true })
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:window:openNewSession', async () => {
|
||||
createNewSessionWindow()
|
||||
createSessionWindow(sessionId.trim())
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
@@ -5331,8 +5125,8 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
// reset connection state so the next startHermes() call restarts the
|
||||
// full backend flow (including a fresh runBootstrap pass).
|
||||
rememberLog('[bootstrap] reset requested by renderer; clearing latched failure')
|
||||
await teardownPrimaryBackendAndWait()
|
||||
bootstrapFailure = null
|
||||
connectionPromise = null
|
||||
bootstrapState = {
|
||||
active: false,
|
||||
manifest: null,
|
||||
@@ -5595,14 +5389,9 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
|
||||
await prepareProfileDeleteRequest(request)
|
||||
|
||||
const profile = request?.profile
|
||||
const connection = await ensureBackend(profile)
|
||||
const connection = await ensureBackend(request?.profile)
|
||||
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
const requestPath = pathWithGlobalRemoteProfile(request.path, profile, {
|
||||
globalRemote: globalRemoteActive(),
|
||||
profileRemoteOverride: profileHasRemoteOverride(profile)
|
||||
})
|
||||
const url = `${connection.baseUrl}${requestPath}`
|
||||
const url = `${connection.baseUrl}${request.path}`
|
||||
// OAuth gateways authenticate REST via the HttpOnly session cookie held in
|
||||
// the OAuth partition — route through Electron's net stack bound to that
|
||||
// session so the cookie attaches automatically. Token/local modes keep using
|
||||
@@ -5623,30 +5412,11 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
|
||||
ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
if (!Notification.isSupported()) return false
|
||||
// Action buttons render only on signed macOS builds; elsewhere they're dropped
|
||||
// and the body click still works.
|
||||
const actions = Array.isArray(payload?.actions) ? payload.actions : []
|
||||
const notification = new Notification({
|
||||
new Notification({
|
||||
title: payload?.title || 'Hermes',
|
||||
body: payload?.body || '',
|
||||
silent: Boolean(payload?.silent),
|
||||
actions: actions.map(action => ({ type: 'button', text: String(action?.text || '') }))
|
||||
})
|
||||
notification.on('click', () => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
focusWindow(mainWindow)
|
||||
if (payload?.sessionId) {
|
||||
mainWindow.webContents.send('hermes:focus-session', payload.sessionId)
|
||||
}
|
||||
})
|
||||
notification.on('action', (_actionEvent, index) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||
const action = actions[index]
|
||||
if (action?.id) {
|
||||
mainWindow.webContents.send('hermes:notification-action', { sessionId: payload?.sessionId, actionId: action.id })
|
||||
}
|
||||
})
|
||||
notification.show()
|
||||
silent: Boolean(payload?.silent)
|
||||
}).show()
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -5754,35 +5524,6 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
|
||||
mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
|
||||
})
|
||||
|
||||
// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).
|
||||
ipcMain.on('hermes:native-theme', (_event, mode) => {
|
||||
if (!THEME_SOURCES.has(mode)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nativeTheme.themeSource !== mode) {
|
||||
nativeTheme.themeSource = mode
|
||||
writePersistedThemeSource(mode)
|
||||
}
|
||||
})
|
||||
|
||||
// See-through window translucency. Persist + re-apply opacity to every open
|
||||
// window at runtime (no recreation, so caching/sessions are untouched).
|
||||
ipcMain.on('hermes:translucency', (_event, payload) => {
|
||||
const next = clampIntensity(payload && payload.intensity)
|
||||
|
||||
if (next === translucencyIntensity) {
|
||||
return
|
||||
}
|
||||
|
||||
translucencyIntensity = next
|
||||
writePersistedTranslucency(next)
|
||||
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
applyWindowTranslucency(win)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:openExternal', (_event, url) => {
|
||||
if (!openExternalUrl(url)) {
|
||||
throw new Error('Invalid external URL')
|
||||
@@ -6034,8 +5775,6 @@ ipcMain.handle('hermes:fs:readDir', async (_event, dirPath) => readDirForIpc(dir
|
||||
|
||||
ipcMain.handle('hermes:fs:gitRoot', async (_event, startPath) => gitRootForIpc(startPath))
|
||||
|
||||
ipcMain.handle('hermes:fs:worktrees', async (_event, cwds) => worktreesForIpc(cwds))
|
||||
|
||||
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||||
if (!nodePty) {
|
||||
throw new Error('PTY support is unavailable. Reinstall desktop dependencies and restart Hermes.')
|
||||
@@ -6222,15 +5961,11 @@ async function getUninstallSummary() {
|
||||
resolve(value)
|
||||
}
|
||||
try {
|
||||
const child = spawn(
|
||||
py,
|
||||
['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'],
|
||||
hiddenWindowsChildOptions({
|
||||
cwd: agentRoot,
|
||||
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
)
|
||||
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({
|
||||
cwd: agentRoot,
|
||||
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
}))
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
@@ -6388,7 +6123,7 @@ let _rendererReadyForDeepLink = false
|
||||
|
||||
function _extractDeepLink(argv) {
|
||||
if (!Array.isArray(argv)) return null
|
||||
return argv.find(a => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null
|
||||
return argv.find((a) => typeof a === 'string' && a.startsWith(`${HERMES_PROTOCOL}://`)) || null
|
||||
}
|
||||
|
||||
function handleDeepLink(url) {
|
||||
@@ -6432,7 +6167,9 @@ ipcMain.handle('hermes:deep-link-ready', () => {
|
||||
_pendingDeepLink = null
|
||||
handleDeepLink(
|
||||
`${HERMES_PROTOCOL}://${queued.kind}/${encodeURIComponent(queued.name)}` +
|
||||
(Object.keys(queued.params).length ? '?' + new URLSearchParams(queued.params).toString() : '')
|
||||
(Object.keys(queued.params).length
|
||||
? '?' + new URLSearchParams(queued.params).toString()
|
||||
: ''),
|
||||
)
|
||||
}
|
||||
return { ok: true }
|
||||
@@ -6443,7 +6180,9 @@ function registerDeepLinkProtocol() {
|
||||
if (process.defaultApp && process.argv.length >= 2) {
|
||||
// Dev: register with the electron exec path + entry script so the OS can
|
||||
// relaunch us with the URL.
|
||||
app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [path.resolve(process.argv[1])])
|
||||
app.setAsDefaultProtocolClient(HERMES_PROTOCOL, process.execPath, [
|
||||
path.resolve(process.argv[1]),
|
||||
])
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient(HERMES_PROTOCOL)
|
||||
}
|
||||
@@ -6476,6 +6215,7 @@ app.on('open-url', (event, url) => {
|
||||
handleDeepLink(url)
|
||||
})
|
||||
|
||||
|
||||
app.whenReady().then(() => {
|
||||
if (IS_MAC) {
|
||||
Menu.setApplicationMenu(buildApplicationMenu())
|
||||
|
||||
@@ -5,8 +5,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
|
||||
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
||||
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
||||
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
|
||||
openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'),
|
||||
openSessionWindow: sessionId => ipcRenderer.invoke('hermes:window:openSession', sessionId),
|
||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||
@@ -40,8 +39,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
|
||||
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
|
||||
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
|
||||
setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode),
|
||||
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
|
||||
@@ -55,7 +52,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
|
||||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
|
||||
worktrees: cwds => ipcRenderer.invoke('hermes:fs:worktrees', cwds),
|
||||
terminal: {
|
||||
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
|
||||
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),
|
||||
@@ -95,16 +91,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
ipcRenderer.on('hermes:window-state-changed', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:window-state-changed', listener)
|
||||
},
|
||||
onFocusSession: callback => {
|
||||
const listener = (_event, sessionId) => callback(sessionId)
|
||||
ipcRenderer.on('hermes:focus-session', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:focus-session', listener)
|
||||
},
|
||||
onNotificationAction: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:notification-action', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:notification-action', listener)
|
||||
},
|
||||
onPreviewFileChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:preview-file-changed', listener)
|
||||
|
||||
@@ -5,54 +5,22 @@
|
||||
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
// Secondary windows open at the minimum usable size — a compact side panel for
|
||||
// subagent watch / cmd-click session pop-out, not a second full desktop.
|
||||
const SESSION_WINDOW_MIN_WIDTH = 420
|
||||
const SESSION_WINDOW_MIN_HEIGHT = 620
|
||||
|
||||
// Shared webPreferences for every window that renders the chat transcript — the
|
||||
// primary window AND the secondary session windows. Keeping it in one place is
|
||||
// the whole point: the two BrowserWindow definitions in main.cjs used to be
|
||||
// hand-copied, and the secondary windows silently lost `backgroundThrottling:
|
||||
// false`, so a streamed answer stalled until the window regained focus.
|
||||
//
|
||||
// `backgroundThrottling: false` is load-bearing: the transcript streams to the
|
||||
// screen through a requestAnimationFrame-gated flush, which Chromium pauses for
|
||||
// blurred/occluded windows. A streaming chat app must keep painting in the
|
||||
// background, so every chat window opts out. The preload path is injected
|
||||
// because it depends on the Electron entry's __dirname.
|
||||
function chatWindowWebPreferences(preloadPath) {
|
||||
return {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
webviewTag: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
devTools: true,
|
||||
backgroundThrottling: false
|
||||
}
|
||||
}
|
||||
|
||||
// Build the renderer URL for a secondary window. The renderer uses a
|
||||
// HashRouter, so the session route lives after the '#'. The `?win=secondary`
|
||||
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
|
||||
// treated as the route by HashRouter and would break routeSessionId(). The
|
||||
// renderer reads the flag from window.location.search to suppress the install /
|
||||
// onboarding overlays and the global session sidebar. `new=1` marks the compact
|
||||
// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's
|
||||
// session): the renderer resumes it lazily so the gateway never builds an agent
|
||||
// just to stream into it.
|
||||
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession } = {}) {
|
||||
const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}`
|
||||
const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`
|
||||
// onboarding overlays and the global session sidebar.
|
||||
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath } = {}) {
|
||||
const route = `#/${encodeURIComponent(sessionId)}`
|
||||
|
||||
if (devServer) {
|
||||
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
|
||||
|
||||
return `${base}/${query}${route}`
|
||||
return `${base}/?win=secondary${route}`
|
||||
}
|
||||
|
||||
return `${pathToFileURL(rendererIndexPath).toString()}${query}${route}`
|
||||
return `${pathToFileURL(rendererIndexPath).toString()}?win=secondary${route}`
|
||||
}
|
||||
|
||||
// A small registry keyed by sessionId that guarantees one window per chat:
|
||||
@@ -115,10 +83,4 @@ function createSessionWindowRegistry() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
createSessionWindowRegistry,
|
||||
SESSION_WINDOW_MIN_HEIGHT,
|
||||
SESSION_WINDOW_MIN_WIDTH
|
||||
}
|
||||
module.exports = { buildSessionWindowUrl, createSessionWindowRegistry }
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const {
|
||||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
createSessionWindowRegistry
|
||||
} = require('./session-windows.cjs')
|
||||
const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs')
|
||||
|
||||
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
|
||||
// test fire the 'closed' event, mirroring the slice of the Electron API the
|
||||
@@ -80,18 +76,6 @@ test('buildSessionWindowUrl builds a packaged file URL with the flag before the
|
||||
assert.match(url, /^file:\/\/.*index\.html\?win=secondary#\/abc$/)
|
||||
})
|
||||
|
||||
test('buildSessionWindowUrl adds the watch flag for spectator windows, before the hash', () => {
|
||||
const url = buildSessionWindowUrl('abc', { devServer: 'http://localhost:5173', watch: true })
|
||||
|
||||
assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc')
|
||||
})
|
||||
|
||||
test('buildSessionWindowUrl routes new-session windows to the draft (#/)', () => {
|
||||
const url = buildSessionWindowUrl(null, { devServer: 'http://localhost:5173', newSession: true })
|
||||
|
||||
assert.equal(url, 'http://localhost:5173/?win=secondary&new=1#/')
|
||||
})
|
||||
|
||||
test('registry opens one window per session and focuses on re-open', () => {
|
||||
const registry = createSessionWindowRegistry()
|
||||
let built = 0
|
||||
@@ -179,21 +163,3 @@ test('registry trims the session id before keying', () => {
|
||||
|
||||
assert.equal(registry.has('s1'), true)
|
||||
})
|
||||
|
||||
test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => {
|
||||
// Regression: secondary session windows used to omit this flag, so a streamed
|
||||
// answer stalled until the window regained focus (Chromium pauses the
|
||||
// requestAnimationFrame-gated transcript flush for backgrounded windows).
|
||||
const prefs = chatWindowWebPreferences('/tmp/preload.cjs')
|
||||
|
||||
assert.equal(prefs.backgroundThrottling, false)
|
||||
})
|
||||
|
||||
test('chatWindowWebPreferences passes the preload path through and keeps the hardened defaults', () => {
|
||||
const prefs = chatWindowWebPreferences('/some/preload.cjs')
|
||||
|
||||
assert.equal(prefs.preload, '/some/preload.cjs')
|
||||
assert.equal(prefs.contextIsolation, true)
|
||||
assert.equal(prefs.sandbox, true)
|
||||
assert.equal(prefs.nodeIntegration, false)
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ const path = require('node:path')
|
||||
const ELECTRON_DIR = __dirname
|
||||
|
||||
function readElectronFile(name) {
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8').replace(/\r\n/g, '\n')
|
||||
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
|
||||
}
|
||||
|
||||
function requireHiddenChildOptions(source, needle) {
|
||||
@@ -42,9 +42,6 @@ test('intentional or interactive desktop child processes stay documented', () =>
|
||||
const source = readElectronFile('main.cjs')
|
||||
|
||||
assert.match(source, /windowsHide: false/)
|
||||
assert.match(source, /handOffWindowsBootstrapRecovery/)
|
||||
assert.match(source, /'--repair', '--branch'/)
|
||||
assert.match(source, /'--update', '--branch'/)
|
||||
assert.match(source, /nodePty\.spawn\(command, args/)
|
||||
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
|
||||
})
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
// windows-user-env.cjs
|
||||
//
|
||||
// Read a User-scoped environment variable straight from the Windows registry
|
||||
// (HKCU\Environment).
|
||||
//
|
||||
// A GUI app launched from Explorer inherits the environment block captured at
|
||||
// login, so a variable set via `setx` AFTER login is invisible in process.env
|
||||
// even though a fresh shell — and the Hermes CLI — sees it immediately. The
|
||||
// desktop's HERMES_HOME resolution relies on process.env, so that stale-snapshot
|
||||
// gap silently sends the backend to the default %LOCALAPPDATA%\hermes. Reading
|
||||
// the live registry value closes the gap. See #45471.
|
||||
|
||||
const { execFileSync } = require('node:child_process')
|
||||
|
||||
// Parse the output of `reg query HKCU\Environment /v <name>`, which looks like:
|
||||
//
|
||||
// HKEY_CURRENT_USER\Environment
|
||||
// HERMES_HOME REG_SZ F:\Hermes\data
|
||||
//
|
||||
// Returns the raw value string (spaces inside the value preserved), or null when
|
||||
// the requested value line isn't present.
|
||||
function parseRegQueryValue(stdout, name) {
|
||||
if (!stdout || !name) return null
|
||||
const typePattern =
|
||||
/^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
|
||||
for (const rawLine of String(stdout).split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
const match = line.match(typePattern)
|
||||
if (match && match[1].toLowerCase() === name.toLowerCase()) {
|
||||
return match[2]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Expand %VAR% references against an env map. REG_EXPAND_SZ values store
|
||||
// unexpanded references; plain REG_SZ paths have none, so this is a no-op for
|
||||
// the common F:\... case. Unknown references are left verbatim.
|
||||
function expandWindowsEnvRefs(value, env = process.env) {
|
||||
if (!value) return value
|
||||
return value.replace(/%([^%]+)%/g, (whole, name) => {
|
||||
const key = Object.keys(env).find(k => k.toUpperCase() === String(name).toUpperCase())
|
||||
return key != null && env[key] != null ? env[key] : whole
|
||||
})
|
||||
}
|
||||
|
||||
// Read a User-scoped env var from HKCU\Environment. Windows-only: returns null
|
||||
// off-Windows (without spawning), on any spawn error, when `reg` exits non-zero
|
||||
// (the value doesn't exist), or when the value is empty.
|
||||
function readWindowsUserEnvVar(
|
||||
name,
|
||||
{ platform = process.platform, env = process.env, exec = execFileSync } = {}
|
||||
) {
|
||||
if (platform !== 'win32' || !name) return null
|
||||
let stdout
|
||||
try {
|
||||
stdout = exec('reg', ['query', 'HKCU\\Environment', '/v', name], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
timeout: 5000
|
||||
})
|
||||
} catch {
|
||||
// `reg` missing, or value absent (reg exits 1) — caller falls back.
|
||||
return null
|
||||
}
|
||||
const raw = parseRegQueryValue(stdout, name)
|
||||
if (raw == null) return null
|
||||
const expanded = expandWindowsEnvRefs(raw, env).trim()
|
||||
return expanded || null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
expandWindowsEnvRefs,
|
||||
parseRegQueryValue,
|
||||
readWindowsUserEnvVar
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { test } = require('node:test')
|
||||
|
||||
const {
|
||||
expandWindowsEnvRefs,
|
||||
parseRegQueryValue,
|
||||
readWindowsUserEnvVar
|
||||
} = require('./windows-user-env.cjs')
|
||||
|
||||
// ── parseRegQueryValue ─────────────────────────────────────────────────────
|
||||
|
||||
test('parseRegQueryValue extracts a REG_SZ value', () => {
|
||||
const out = [
|
||||
'',
|
||||
'HKEY_CURRENT_USER\\Environment',
|
||||
' HERMES_HOME REG_SZ F:\\Hermes\\data',
|
||||
''
|
||||
].join('\r\n')
|
||||
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'F:\\Hermes\\data')
|
||||
})
|
||||
|
||||
test('parseRegQueryValue matches the name case-insensitively', () => {
|
||||
const out = 'HKEY_CURRENT_USER\\Environment\r\n Hermes_Home REG_EXPAND_SZ %USERPROFILE%\\h\r\n'
|
||||
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), '%USERPROFILE%\\h')
|
||||
})
|
||||
|
||||
test('parseRegQueryValue preserves spaces inside the value', () => {
|
||||
const out = ' HERMES_HOME REG_SZ C:\\Program Files\\Hermes\r\n'
|
||||
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'C:\\Program Files\\Hermes')
|
||||
})
|
||||
|
||||
test('parseRegQueryValue returns null when the value line is absent', () => {
|
||||
const out = 'HKEY_CURRENT_USER\\Environment\r\n Path REG_SZ C:\\x\r\n'
|
||||
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), null)
|
||||
assert.equal(parseRegQueryValue('', 'HERMES_HOME'), null)
|
||||
assert.equal(parseRegQueryValue('garbage', 'HERMES_HOME'), null)
|
||||
})
|
||||
|
||||
// ── expandWindowsEnvRefs ───────────────────────────────────────────────────
|
||||
|
||||
test('expandWindowsEnvRefs expands %VAR% case-insensitively', () => {
|
||||
assert.equal(
|
||||
expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }),
|
||||
'C:\\Users\\jeff\\h'
|
||||
)
|
||||
})
|
||||
|
||||
test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => {
|
||||
assert.equal(expandWindowsEnvRefs('F:\\Hermes\\data', {}), 'F:\\Hermes\\data')
|
||||
assert.equal(expandWindowsEnvRefs('%NOPE%\\x', {}), '%NOPE%\\x')
|
||||
})
|
||||
|
||||
// ── readWindowsUserEnvVar ──────────────────────────────────────────────────
|
||||
|
||||
test('readWindowsUserEnvVar returns null off Windows without spawning', () => {
|
||||
let spawned = false
|
||||
const exec = () => {
|
||||
spawned = true
|
||||
return ''
|
||||
}
|
||||
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'linux', exec }), null)
|
||||
assert.equal(spawned, false)
|
||||
})
|
||||
|
||||
test('readWindowsUserEnvVar queries HKCU\\Environment and expands the value', () => {
|
||||
const calls = []
|
||||
const exec = (cmd, args) => {
|
||||
calls.push([cmd, args])
|
||||
return 'HKEY_CURRENT_USER\\Environment\r\n HERMES_HOME REG_EXPAND_SZ %DRIVE%\\Hermes\r\n'
|
||||
}
|
||||
const value = readWindowsUserEnvVar('HERMES_HOME', {
|
||||
platform: 'win32',
|
||||
env: { DRIVE: 'F:' },
|
||||
exec
|
||||
})
|
||||
assert.equal(value, 'F:\\Hermes')
|
||||
assert.deepEqual(calls, [['reg', ['query', 'HKCU\\Environment', '/v', 'HERMES_HOME']]])
|
||||
})
|
||||
|
||||
test('readWindowsUserEnvVar returns null when reg exits non-zero (value missing)', () => {
|
||||
const exec = () => {
|
||||
throw new Error('reg exited 1')
|
||||
}
|
||||
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null)
|
||||
})
|
||||
|
||||
test('readWindowsUserEnvVar returns null for an empty value', () => {
|
||||
const exec = () => ' HERMES_HOME REG_SZ \r\n'
|
||||
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null)
|
||||
})
|
||||
@@ -9,28 +9,6 @@
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="shortcut icon" href="/apple-touch-icon.png" />
|
||||
<title>Hermes</title>
|
||||
<script>
|
||||
// Pre-paint the themed background before the app bundle loads. Without
|
||||
// this, the first frame (which is what `ready-to-show` waits for) is the
|
||||
// UA-default white page, and the real theme only lands once the whole
|
||||
// module graph has executed — i.e. the "white flash" on every new
|
||||
// window. applyTheme() in src/themes/context.tsx keeps these keys fresh.
|
||||
try {
|
||||
let bg = localStorage.getItem('hermes-boot-background')
|
||||
let scheme = localStorage.getItem('hermes-boot-color-scheme')
|
||||
if (!bg) {
|
||||
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
bg = dark ? '#111111' : '#f7f7f7'
|
||||
scheme = dark ? 'dark' : 'light'
|
||||
}
|
||||
document.documentElement.style.backgroundColor = bg
|
||||
if (scheme === 'dark' || scheme === 'light') {
|
||||
document.documentElement.style.colorScheme = scheme
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable — keep UA defaults.
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" class="scrollbar-dt"></div>
|
||||
|
||||
@@ -18,9 +18,7 @@
|
||||
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
|
||||
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
|
||||
"start": "npm run build && electron .",
|
||||
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
|
||||
"postbuild": "node scripts/assert-dist-built.cjs",
|
||||
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
|
||||
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/assert-dist-built.cjs",
|
||||
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
|
||||
"pack": "npm run build && npm run builder -- --dir",
|
||||
"dist": "npm run build && npm run builder",
|
||||
@@ -37,7 +35,7 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/windows-user-env.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
@@ -91,7 +89,6 @@
|
||||
"react-router-dom": "^7.17.0",
|
||||
"react-shiki": "^0.9.3",
|
||||
"remark-math": "^6.0.0",
|
||||
"remend": "^1.3.0",
|
||||
"shiki": "^4.0.2",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
@@ -100,7 +97,6 @@
|
||||
"unicode-animations": "^1.0.3",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit-parents": "^6.0.2",
|
||||
"use-stick-to-bottom": "^1.1.6",
|
||||
"vfile": "^6.0.3",
|
||||
"web-haptics": "^0.0.6"
|
||||
},
|
||||
@@ -117,7 +113,7 @@
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"concurrently": "^10.0.3",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "40.10.2",
|
||||
"electron": "^40.9.3",
|
||||
"electron-builder": "^26.8.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-perfectionist": "^5.9.0",
|
||||
@@ -134,8 +130,7 @@
|
||||
"wait-on": "^9.0.5"
|
||||
},
|
||||
"build": {
|
||||
"electronVersion": "40.10.2",
|
||||
"electronDist": "../../node_modules/electron/dist",
|
||||
"electronVersion": "40.9.3",
|
||||
"appId": "com.nousresearch.hermes",
|
||||
"productName": "Hermes",
|
||||
"executableName": "Hermes",
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const desktopRoot = path.resolve(__dirname, '..')
|
||||
const repoRoot = path.resolve(desktopRoot, '..', '..')
|
||||
const electronMacPath = path.join(repoRoot, 'node_modules', 'app-builder-lib', 'out', 'electron', 'electronMac.js')
|
||||
|
||||
const marker = 'hermes-macos-electron-binary-fallback'
|
||||
const needle = ` await Promise.all([
|
||||
doRename(path.join(contentsPath, "MacOS"), electronBranding.productName, appPlist.CFBundleExecutable),
|
||||
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")),
|
||||
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")),
|
||||
]);`
|
||||
const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes copy
|
||||
// Electron.app without its main MacOS/Electron binary before this rename.
|
||||
// Restore it from the installed Electron runtime so local desktop installs
|
||||
// do not fail with ENOENT during macOS arm64 packaging.
|
||||
const macosDir = path.join(contentsPath, "MacOS");
|
||||
const bundledElectronBinary = path.join(macosDir, electronBranding.productName);
|
||||
if (!fs.existsSync(bundledElectronBinary)) {
|
||||
const candidates = [
|
||||
path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName),
|
||||
path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
|
||||
];
|
||||
const sourceBinary = candidates.find(candidate => fs.existsSync(candidate));
|
||||
if (sourceBinary == null) {
|
||||
throw new Error("Electron binary missing from packaged app and Electron runtime: " + bundledElectronBinary);
|
||||
}
|
||||
await (0, promises_1.copyFile)(sourceBinary, bundledElectronBinary);
|
||||
await (0, promises_1.chmod)(bundledElectronBinary, 0o755);
|
||||
}
|
||||
await Promise.all([
|
||||
doRename(macosDir, electronBranding.productName, appPlist.CFBundleExecutable),
|
||||
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")),
|
||||
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")),
|
||||
]);`
|
||||
|
||||
if (!fs.existsSync(electronMacPath)) {
|
||||
console.warn(`[patch-electron-builder] skipped: ${electronMacPath} not found`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(electronMacPath, 'utf8')
|
||||
if (source.includes(marker)) {
|
||||
console.log('[patch-electron-builder] macOS Electron binary fallback already applied')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!source.includes(needle)) {
|
||||
console.warn('[patch-electron-builder] skipped: expected electronMac.js shape not found')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
fs.writeFileSync(electronMacPath, source.replace(needle, replacement))
|
||||
console.log('[patch-electron-builder] applied macOS Electron binary fallback')
|
||||
@@ -3,8 +3,8 @@ import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useElapsedSeconds } from '@/components/chat/activity-timer'
|
||||
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { FadeText } from '@/components/ui/fade-text'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
@@ -25,7 +25,7 @@ import { OverlayView } from '../overlays/overlay-view'
|
||||
function statusGlyph(status: SubagentStatus, a: Translations['agents']): ReactNode {
|
||||
if (status === 'running' || status === 'queued') {
|
||||
return (
|
||||
<GlyphSpinner
|
||||
<BrailleSpinner
|
||||
ariaLabel={a.running}
|
||||
className="size-3.5 shrink-0 text-[0.95rem] text-muted-foreground/80"
|
||||
spinner="breathe"
|
||||
@@ -290,7 +290,7 @@ function StreamLine({
|
||||
<span className={cn('min-w-0 flex-1 wrap-anywhere', tone, isMono && 'font-mono text-[0.69rem]')}>
|
||||
{entry.text}
|
||||
{active ? (
|
||||
<GlyphSpinner
|
||||
<BrailleSpinner
|
||||
ariaLabel={t.agents.streaming}
|
||||
className="ml-1 inline-block size-2.5 align-middle text-muted-foreground/70"
|
||||
spinner="breathe"
|
||||
@@ -372,9 +372,7 @@ function SubagentRow({ node, depth = 0, nowMs }: { node: SubagentNode; depth?: n
|
||||
|
||||
{open && fileLines.length > 0 ? (
|
||||
<div className="grid min-w-0 gap-0.5 pl-6">
|
||||
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">
|
||||
{t.agents.files}
|
||||
</p>
|
||||
<p className="text-[0.58rem] font-medium tracking-wider text-muted-foreground/60 uppercase">{t.agents.files}</p>
|
||||
{fileLines.slice(0, 8).map(line => (
|
||||
<p className="wrap-break-word font-mono text-[0.67rem] leading-relaxed text-muted-foreground/80" key={line}>
|
||||
{line}
|
||||
|
||||
@@ -18,12 +18,11 @@ import {
|
||||
} from '@/components/ui/pagination'
|
||||
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { getSessionMessages, listAllProfileSessions } from '@/hermes'
|
||||
import { getSessionMessages, listSessions } from '@/hermes'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
|
||||
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
|
||||
import { mediaExternalUrl } from '@/lib/media'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import type { SessionInfo, SessionMessage } from '@/types/hermes'
|
||||
@@ -125,12 +124,17 @@ function artifactKind(value: string): ArtifactKind {
|
||||
}
|
||||
|
||||
function artifactHref(value: string): string {
|
||||
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('data:')) {
|
||||
if (
|
||||
value.startsWith('http://') ||
|
||||
value.startsWith('https://') ||
|
||||
value.startsWith('file://') ||
|
||||
value.startsWith('data:')
|
||||
) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (value.startsWith('file://') || value.startsWith('/')) {
|
||||
return mediaExternalUrl(value)
|
||||
if (value.startsWith('/')) {
|
||||
return `file://${encodeURI(value)}`
|
||||
}
|
||||
|
||||
return value
|
||||
@@ -384,8 +388,8 @@ export function ArtifactsView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
|
||||
setRefreshing(true)
|
||||
|
||||
try {
|
||||
const sessions = (await listAllProfileSessions(30, 1)).sessions
|
||||
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id, session.profile)))
|
||||
const sessions = (await listSessions(30, 1)).sessions
|
||||
const results = await Promise.allSettled(sessions.map(session => getSessionMessages(session.id)))
|
||||
const nextArtifacts: ArtifactRecord[] = []
|
||||
|
||||
results.forEach((result, index) => {
|
||||
|
||||
@@ -2,21 +2,25 @@ import type { Unstable_TriggerAdapter } from '@assistant-ui/core'
|
||||
import { ComposerPrimitive } from '@assistant-ui/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { composerFusedDockCard } from '@/components/chat/composer-dock'
|
||||
import { cn } from '@/lib/utils'
|
||||
export const COMPLETION_DRAWER_CLASS = [
|
||||
'absolute bottom-[calc(100%+0.375rem)] left-0 z-50',
|
||||
'w-80 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-xl border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-lg',
|
||||
'backdrop-blur-md'
|
||||
].join(' ')
|
||||
|
||||
// Same docked chrome as the queue/status stack, but its own thing: a narrow,
|
||||
// left-aligned card (not full width) that fuses to the composer's edge instead
|
||||
// of floating above it. `left-1` matches the stack's `mx-1` inset; the negative
|
||||
// margin overlaps the seam so the composer's (now-transparent) edge border reads
|
||||
// as shared. Fused (opaque) fill — the composer surface swaps to the same fill
|
||||
// while a drawer is open, so the two paint as one panel.
|
||||
const DRAWER_SHELL =
|
||||
'absolute left-1 z-50 w-80 max-w-[calc(100%-0.5rem)] max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain p-1 text-xs text-popover-foreground'
|
||||
|
||||
export const COMPLETION_DRAWER_CLASS = cn(DRAWER_SHELL, 'bottom-full -mb-[9px]', composerFusedDockCard('top'))
|
||||
|
||||
export const COMPLETION_DRAWER_BELOW_CLASS = cn(DRAWER_SHELL, 'top-full -mt-[9px]', composerFusedDockCard('bottom'))
|
||||
export const COMPLETION_DRAWER_BELOW_CLASS = [
|
||||
'absolute left-0 top-[calc(100%+0.375rem)] z-50',
|
||||
'w-80 max-w-[calc(100vw-2rem)]',
|
||||
'max-h-[min(22rem,calc(100vh-8rem))] overflow-y-auto overscroll-contain',
|
||||
'rounded-xl border border-(--ui-stroke-secondary)',
|
||||
'bg-[color-mix(in_srgb,var(--ui-bg-elevated)_97%,transparent)]',
|
||||
'p-1 text-xs text-popover-foreground shadow-lg',
|
||||
'backdrop-blur-md'
|
||||
].join(' ')
|
||||
|
||||
export function ComposerCompletionDrawer({
|
||||
adapter,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -87,7 +86,7 @@ export function ContextMenu({
|
||||
|
||||
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
|
||||
{c.tipPre}
|
||||
<Kbd size="sm">@</Kbd>
|
||||
<kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd>
|
||||
{c.tipPost}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
@@ -9,7 +8,6 @@ import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
||||
import { ModelPill } from './model-pill'
|
||||
import type { ChatBarState, VoiceStatus } from './types'
|
||||
|
||||
export const ICON_BTN = 'size-(--composer-control-size) shrink-0 rounded-md'
|
||||
@@ -65,15 +63,7 @@ export function ComposerControls({
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const steerCombo = formatCombo('mod+enter')
|
||||
const steerLabel = `${c.steer} (${steerCombo})`
|
||||
|
||||
const steerTip = (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{c.steer}
|
||||
<KbdCombo combo="mod+enter" size="sm" variant="inverted" />
|
||||
</span>
|
||||
)
|
||||
const steerLabel = `${c.steer} (${formatCombo('mod+enter')})`
|
||||
|
||||
if (conversation.active) {
|
||||
return <ConversationPill {...conversation} disabled={disabled} />
|
||||
@@ -83,11 +73,9 @@ export function ComposerControls({
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<ModelPill disabled={disabled} model={state.model} />
|
||||
{/* While the agent runs and the user is typing, steer takes over the mic's
|
||||
slot rather than crowding the row with an extra button. */}
|
||||
{canSteer ? (
|
||||
<Tip label={steerTip}>
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
{canSteer && (
|
||||
<Tip label={steerLabel}>
|
||||
<Button
|
||||
aria-label={steerLabel}
|
||||
className={GHOST_ICON_BTN}
|
||||
@@ -100,8 +88,6 @@ export function ComposerControls({
|
||||
<SteeringWheel size={16} />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : (
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
)}
|
||||
{showVoicePrimary ? (
|
||||
<Tip label={c.startVoice}>
|
||||
|
||||
@@ -24,7 +24,6 @@ afterEach(cleanup)
|
||||
// state stays stale while the DOM already holds the text.
|
||||
function Harness({
|
||||
busy = false,
|
||||
disabled = false,
|
||||
queued = [],
|
||||
onSubmit,
|
||||
onQueue,
|
||||
@@ -32,7 +31,6 @@ function Harness({
|
||||
onDrain
|
||||
}: {
|
||||
busy?: boolean
|
||||
disabled?: boolean
|
||||
queued?: readonly string[]
|
||||
onSubmit: (text: string) => void
|
||||
onQueue: (text: string) => void
|
||||
@@ -54,10 +52,6 @@ function Harness({
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const editor = editorRef.current
|
||||
if (editor) {
|
||||
const domText = composerPlainText(editor)
|
||||
@@ -90,10 +84,6 @@ function Harness({
|
||||
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
|
||||
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
|
||||
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!busy && !hasLivePayload && queued.length > 0) {
|
||||
onDrain()
|
||||
|
||||
@@ -196,23 +186,4 @@ describe('composer Enter submit — live DOM vs stale composer state (#39630)',
|
||||
expect(onDrain).toHaveBeenCalledTimes(1)
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps reconnect drafts editable but blocks Enter submit until the gateway returns', async () => {
|
||||
const onSubmit = vi.fn()
|
||||
const onDrain = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
<Harness disabled onCancel={vi.fn()} onDrain={onDrain} onQueue={vi.fn()} onSubmit={onSubmit} queued={['queued-1']} />
|
||||
)
|
||||
const editor = getByTestId('editor')
|
||||
|
||||
await act(async () => {
|
||||
editor.textContent = 'draft while reconnecting'
|
||||
fireEvent.input(editor)
|
||||
fireEvent.keyDown(editor, { key: 'Enter' })
|
||||
})
|
||||
|
||||
expect(editor.textContent).toBe('draft while reconnecting')
|
||||
expect(onDrain).not.toHaveBeenCalled()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
* steal focus from the composer effect.
|
||||
*/
|
||||
|
||||
import { RICH_INPUT_SLOT } from './rich-editor'
|
||||
import type { InlineRefInput } from './inline-refs'
|
||||
|
||||
export type ComposerTarget = 'edit' | 'main'
|
||||
@@ -124,12 +123,3 @@ export const focusComposerInput = (el: HTMLElement | null) => {
|
||||
window.requestAnimationFrame(focus)
|
||||
window.setTimeout(focus, 0)
|
||||
}
|
||||
|
||||
/** Drop focus from the main composer input (status-stack chrome, sidebar, etc.). */
|
||||
export const blurComposerInput = () => {
|
||||
const el = document.querySelector(`[data-slot="${RICH_INPUT_SLOT}"]`) as HTMLElement | null
|
||||
|
||||
if (el && document.activeElement === el) {
|
||||
el.blur()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
import { COMPLETION_DRAWER_CLASS } from './completion-drawer'
|
||||
|
||||
const COMMON_COMMAND_KEYS = ['/help', '/clear', '/resume', '/details', '/copy', '/quit']
|
||||
|
||||
/** Stable ids → i18n `hotkeyDescs` keys. Combos resolve mod labels per OS. */
|
||||
const COMPOSER_HOTKEY_ROWS = [
|
||||
{ id: 'composer.mention', combos: ['@'] },
|
||||
{ id: 'composer.slash', combos: ['/'] },
|
||||
{ id: 'composer.help', combos: ['?'] },
|
||||
{ id: 'composer.sendNewline', combos: ['enter', 'shift+enter'] },
|
||||
{ id: 'composer.sendQueued', combos: ['mod+shift+k'] },
|
||||
{ id: 'keybinds.openPanel', combos: ['mod+/'] },
|
||||
{ id: 'composer.cancel', combos: ['escape'] },
|
||||
{ id: 'composer.history', combos: ['up', 'down'] }
|
||||
] as const
|
||||
const HOTKEY_KEYS = ['@', '/', '?', 'Enter', 'Cmd/Ctrl+Shift+K', 'Cmd/Ctrl+/', 'Esc', '↑ / ↓']
|
||||
|
||||
export function HelpHint() {
|
||||
const { t } = useI18n()
|
||||
@@ -32,8 +20,8 @@ export function HelpHint() {
|
||||
</Section>
|
||||
|
||||
<Section title={c.hotkeys}>
|
||||
{COMPOSER_HOTKEY_ROWS.map(row => (
|
||||
<HotkeyRow description={c.hotkeyDescs[row.id] ?? ''} combos={[...row.combos]} key={row.id} />
|
||||
{HOTKEY_KEYS.map(key => (
|
||||
<Row description={c.hotkeyDescs[key] ?? ''} key={key} keyLabel={key} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
@@ -69,16 +57,3 @@ function Row({ description, keyLabel, mono = false }: { description: string; key
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HotkeyRow({ combos, description }: { combos: string[]; description: string }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1 text-xs">
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{combos.map(combo => (
|
||||
<KbdCombo combo={combo} key={combo} size="sm" />
|
||||
))}
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-muted-foreground/80">{description}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from 'react'
|
||||
|
||||
import { hermesDirectiveFormatter, type SlashChipKind } from '@/components/assistant-ui/directive-text'
|
||||
import { composerFill, composerSurfaceGlass } from '@/components/chat/composer-dock'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
@@ -43,16 +42,12 @@ import {
|
||||
import {
|
||||
$queuedPromptsBySession,
|
||||
enqueueQueuedPrompt,
|
||||
MAX_AUTO_DRAIN_ATTEMPTS,
|
||||
migrateQueuedPrompts,
|
||||
promoteQueuedPrompt,
|
||||
type QueuedPromptEntry,
|
||||
removeQueuedPrompt,
|
||||
shouldAutoDrain,
|
||||
shouldAutoDrainOnSettle,
|
||||
updateQueuedPrompt
|
||||
} from '@/store/composer-queue'
|
||||
import { $statusItemsBySession } from '@/store/composer-status'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { useTheme } from '@/themes'
|
||||
@@ -85,16 +80,12 @@ import {
|
||||
import { QueuePanel } from './queue-panel'
|
||||
import {
|
||||
composerPlainText,
|
||||
deleteSelectionInEditor,
|
||||
insertPlainTextAtCaret,
|
||||
normalizeComposerEditorDom,
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT,
|
||||
slashChipElement
|
||||
} from './rich-editor'
|
||||
import { ComposerStatusStack } from './status-stack'
|
||||
import { detectTrigger, extractClipboardImageBlobs, textBeforeCaret, type TriggerState } from './text-utils'
|
||||
import { ComposerTriggerPopover } from './trigger-popover'
|
||||
import type { ChatBarProps } from './types'
|
||||
@@ -137,12 +128,6 @@ function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind {
|
||||
return 'command'
|
||||
}
|
||||
|
||||
/** A `/` query is at its arg stage once it's past the command name. */
|
||||
const slashArgStage = (query: string) => query.includes(' ')
|
||||
|
||||
/** The `/command` token of a slash query (`personality x` → `/personality`). */
|
||||
const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}`
|
||||
|
||||
interface QueueEditState {
|
||||
attachments: ComposerAttachment[]
|
||||
draft: string
|
||||
@@ -183,8 +168,8 @@ export function ChatBar({
|
||||
const draft = useAuiState(s => s.composer.text)
|
||||
const attachments = useStore($composerAttachments)
|
||||
const queuedPromptsBySession = useStore($queuedPromptsBySession)
|
||||
const statusItemsBySession = useStore($statusItemsBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const sessionMessages = useStore($messages)
|
||||
const activeQueueSessionKey = queueSessionKey || sessionId || null
|
||||
|
||||
const queuedPrompts = useMemo(
|
||||
@@ -192,29 +177,15 @@ export function ChatBar({
|
||||
[activeQueueSessionKey, queuedPromptsBySession]
|
||||
)
|
||||
|
||||
// Status items (subagents, background processes) are keyed by the RUNTIME
|
||||
// session id — gateway events and process.list both speak that id. Only the
|
||||
// queue uses the stored-session fallback key (prompts can queue pre-resume).
|
||||
const statusSessionId = sessionId ?? null
|
||||
|
||||
const statusStackVisible = useMemo(
|
||||
() =>
|
||||
queuedPrompts.length > 0 || (statusSessionId ? (statusItemsBySession[statusSessionId]?.length ?? 0) > 0 : false),
|
||||
[queuedPrompts.length, statusItemsBySession, statusSessionId]
|
||||
)
|
||||
|
||||
const composerRef = useRef<HTMLFormElement | null>(null)
|
||||
const composerSurfaceRef = useRef<HTMLDivElement | null>(null)
|
||||
const editorRef = useRef<HTMLDivElement | null>(null)
|
||||
const draftRef = useRef(draft)
|
||||
const previousBusyRef = useRef(busy)
|
||||
const pendingDraftPersistRef = useRef<{ scope: string | null; text: string } | null>(null)
|
||||
const activeQueueSessionKeyRef = useRef(activeQueueSessionKey)
|
||||
activeQueueSessionKeyRef.current = activeQueueSessionKey
|
||||
const prevQueueKeyRef = useRef(activeQueueSessionKey)
|
||||
const drainingQueueRef = useRef(false)
|
||||
// Per-entry auto-drain failure counts; bounds retries so a persistent 404
|
||||
// can't spin-loop. Cleared on success; reset naturally on remount/reconnect.
|
||||
const drainFailuresRef = useRef(new Map<string, number>())
|
||||
const urlInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [urlOpen, setUrlOpen] = useState(false)
|
||||
@@ -255,8 +226,6 @@ export function ChatBar({
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const newSessionPlaceholders = t.composer.newSessionPlaceholders
|
||||
const followUpPlaceholders = t.composer.followUpPlaceholders
|
||||
const reconnecting = gatewayState === 'closed' || gatewayState === 'error'
|
||||
const inputDisabled = disabled && !reconnecting
|
||||
|
||||
// Resting placeholder: a starter for brand-new sessions, a continuation for
|
||||
// existing ones. Picked once and only re-rolled when we genuinely move to a
|
||||
@@ -287,13 +256,11 @@ export function ChatBar({
|
||||
setRestingPlaceholder(pickPlaceholder(sessionId ? followUpPlaceholders : newSessionPlaceholders))
|
||||
}, [followUpPlaceholders, newSessionPlaceholders, sessionId])
|
||||
|
||||
// When the transport is disabled it's because the gateway isn't open.
|
||||
// Distinguish a cold start ("Starting Hermes...") from a dropped connection
|
||||
// we're trying to restore. During reconnect, keep the textbox editable so a
|
||||
// flaky network doesn't block drafting; only submit/backend actions stay
|
||||
// disabled until the gateway is open again.
|
||||
// When the bar is disabled it's because the gateway isn't open. Distinguish a
|
||||
// cold start ("Starting Hermes...") from a dropped connection we're trying to
|
||||
// restore (e.g. after the Mac slept) so the stuck state reads as recoverable.
|
||||
const placeholder = disabled
|
||||
? reconnecting
|
||||
? gatewayState === 'closed' || gatewayState === 'error'
|
||||
? t.composer.placeholderReconnecting
|
||||
: t.composer.placeholderStarting
|
||||
: restingPlaceholder
|
||||
@@ -335,13 +302,13 @@ export function ChatBar({
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!inputDisabled) {
|
||||
if (!disabled) {
|
||||
focusInput()
|
||||
}
|
||||
}, [focusInput, focusKey, focusRequestId, inputDisabled])
|
||||
}, [disabled, focusInput, focusKey, focusRequestId])
|
||||
|
||||
useEffect(() => {
|
||||
if (inputDisabled) {
|
||||
if (disabled) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -361,7 +328,7 @@ export function ChatBar({
|
||||
offFocus()
|
||||
offInsert()
|
||||
}
|
||||
}, [appendExternalText, inputDisabled])
|
||||
}, [appendExternalText, disabled])
|
||||
|
||||
// Keep draftRef in sync with the assistant-ui composer state for callers
|
||||
// that read the latest text outside the React render cycle. We don't push
|
||||
@@ -540,90 +507,6 @@ export function ChatBar({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
// Set synchronously in keydown when the open trigger popover consumes a
|
||||
// navigation/control key (Arrow/Enter/Tab/Escape). The subsequent keyup must
|
||||
// NOT run refreshTrigger for that keypress: it never edits text, and for
|
||||
// Escape the keydown has already set trigger=null, so a keyup refresh would
|
||||
// re-detect the still-present `/` and instantly reopen the menu. A ref is
|
||||
// used instead of reading `trigger` in keyup because by keyup time React has
|
||||
// re-rendered and the handler closure sees the post-keydown state.
|
||||
const triggerKeyConsumedRef = useRef(false)
|
||||
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fast-bail: if neither `@` nor `/` appears in the current draft, there's
|
||||
// nothing for `detectTrigger` to match. Use `textContent` (cheap browser-
|
||||
// native walk) for the precondition check rather than `composerPlainText`
|
||||
// (recursive child walk with chip-aware logic). Only when a trigger char
|
||||
// is present do we pay the cost of the full walk + DOM range work.
|
||||
const rawText = editor.textContent ?? ''
|
||||
|
||||
if (!rawText.includes('@') && !rawText.includes('/')) {
|
||||
if (trigger) {
|
||||
setTrigger(null)
|
||||
setTriggerActive(0)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const before = textBeforeCaret(editor)
|
||||
const found = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
||||
// The arg-stage popover is only useful for commands with an options screen.
|
||||
// For a no-arg command it would dead-end on "No matches", so drop it — the
|
||||
// directive is already complete.
|
||||
const detected =
|
||||
found?.kind === '/' && slashArgStage(found.query) && !desktopSlashCommandTakesArgs(slashCommandToken(found.query))
|
||||
? null
|
||||
: found
|
||||
|
||||
setTrigger(detected)
|
||||
|
||||
// Only reset the highlight when the trigger actually changed (opened, or
|
||||
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
|
||||
// caret move (mouseup) or a stray refresh — must preserve the user's
|
||||
// current selection instead of snapping back to the first item.
|
||||
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
|
||||
setTriggerActive(0)
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
// Pull the live contentEditable text into draftRef + the AUI composer state
|
||||
// (which drives `hasComposerPayload` → the send button). Shared by the input
|
||||
// and compositionend paths so committed IME text reaches state through either.
|
||||
const flushEditorToDraft = (editor: HTMLDivElement) => {
|
||||
normalizeComposerEditorDom(editor)
|
||||
|
||||
const nextDraft = composerPlainText(editor)
|
||||
|
||||
if (nextDraft !== draftRef.current) {
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
}
|
||||
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
// During IME composition the DOM contains uncommitted preedit text
|
||||
// mixed with real content. Skip state writes — compositionend flushes
|
||||
// the finalized text (see onCompositionEnd).
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
@@ -660,7 +543,87 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
insertPlainTextAtCaret(event.currentTarget, pastedText)
|
||||
document.execCommand('insertText', false, pastedText)
|
||||
const nextDraft = composerPlainText(event.currentTarget)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
}
|
||||
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
// Set synchronously in keydown when the open trigger popover consumes a
|
||||
// navigation/control key (Arrow/Enter/Tab/Escape). The subsequent keyup must
|
||||
// NOT run refreshTrigger for that keypress: it never edits text, and for
|
||||
// Escape the keydown has already set trigger=null, so a keyup refresh would
|
||||
// re-detect the still-present `/` and instantly reopen the menu. A ref is
|
||||
// used instead of reading `trigger` in keyup because by keyup time React has
|
||||
// re-rendered and the handler closure sees the post-keydown state.
|
||||
const triggerKeyConsumedRef = useRef(false)
|
||||
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fast-bail: if neither `@` nor `/` appears in the current draft, there's
|
||||
// nothing for `detectTrigger` to match. Use `textContent` (cheap browser-
|
||||
// native walk) for the precondition check rather than `composerPlainText`
|
||||
// (recursive child walk with chip-aware logic). Only when a trigger char
|
||||
// is present do we pay the cost of the full walk + DOM range work.
|
||||
const rawText = editor.textContent ?? ''
|
||||
|
||||
if (!rawText.includes('@') && !rawText.includes('/')) {
|
||||
if (trigger) {
|
||||
setTrigger(null)
|
||||
setTriggerActive(0)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const before = textBeforeCaret(editor)
|
||||
const detected = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
||||
setTrigger(detected)
|
||||
|
||||
// Only reset the highlight when the trigger actually changed (opened, or
|
||||
// the query/kind differs). Re-detecting the *same* trigger — e.g. on a
|
||||
// caret move (mouseup) or a stray refresh — must preserve the user's
|
||||
// current selection instead of snapping back to the first item.
|
||||
if (detected?.kind !== trigger?.kind || detected?.query !== trigger?.query) {
|
||||
setTriggerActive(0)
|
||||
}
|
||||
}, [trigger])
|
||||
|
||||
// Pull the live contentEditable text into draftRef + the AUI composer state
|
||||
// (which drives `hasComposerPayload` → the send button). Shared by the input
|
||||
// and compositionend paths so committed IME text reaches state through either.
|
||||
const flushEditorToDraft = (editor: HTMLDivElement) => {
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
editor.replaceChildren()
|
||||
}
|
||||
|
||||
const nextDraft = composerPlainText(editor)
|
||||
|
||||
if (nextDraft !== draftRef.current) {
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
}
|
||||
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
}
|
||||
|
||||
const handleEditorInput = (event: FormEvent<HTMLDivElement>) => {
|
||||
// During IME composition the DOM contains uncommitted preedit text
|
||||
// mixed with real content. Skip state writes — compositionend flushes
|
||||
// the finalized text (see onCompositionEnd).
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
@@ -679,12 +642,6 @@ export function ChatBar({
|
||||
|
||||
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
|
||||
|
||||
// Suppress the "No matches" empty state once a slash command is past its name:
|
||||
// a no-arg command has nothing to offer, and a fully-typed arg commits on
|
||||
// Space/Tab — neither should dead-end on a popover.
|
||||
const argStageEmpty =
|
||||
trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
|
||||
|
||||
const closeTrigger = () => {
|
||||
setTrigger(null)
|
||||
setTriggerItems([])
|
||||
@@ -695,25 +652,6 @@ export function ChatBar({
|
||||
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
|
||||
}, [triggerItems.length])
|
||||
|
||||
// Commit the literally-typed `/command arg` as a directive chip — used when
|
||||
// the completion list is empty because the arg is already fully typed (the
|
||||
// backend completer drops exact matches). Reuses the chip path via a
|
||||
// synthetic item whose serialized form is the verbatim text.
|
||||
const commitTypedSlashDirective = () => {
|
||||
if (trigger?.kind !== '/') {
|
||||
return
|
||||
}
|
||||
|
||||
const text = `/${trigger.query.trimEnd()}`
|
||||
|
||||
replaceTriggerWithChip({
|
||||
id: text,
|
||||
type: 'slash',
|
||||
label: text.slice(1),
|
||||
metadata: { command: slashCommandToken(trigger.query), display: text, meta: '', group: '', action: '', rawText: text }
|
||||
})
|
||||
}
|
||||
|
||||
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
@@ -750,7 +688,8 @@ export function ChatBar({
|
||||
// already an arg pick (`/personality alice`), so it commits normally.
|
||||
const command = (item.metadata as { command?: string } | undefined)?.command ?? ''
|
||||
|
||||
const expandsToArgs = trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
|
||||
const expandsToArgs =
|
||||
trigger.kind === '/' && !serialized.includes(' ') && desktopSlashCommandTakesArgs(command)
|
||||
|
||||
const text = starter || serialized.endsWith(' ') ? serialized : `${serialized} `
|
||||
const directive = !starter && serialized.match(/^@([^:]+):(.+)$/)
|
||||
@@ -832,18 +771,6 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large
|
||||
// drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through.
|
||||
if (
|
||||
(event.key === 'Backspace' || event.key === 'Delete') &&
|
||||
deleteSelectionInEditor(event.currentTarget)
|
||||
) {
|
||||
event.preventDefault()
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is
|
||||
// reserved for the global command palette.
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') {
|
||||
@@ -873,15 +800,7 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Enter / Tab / Space all accept the highlighted item: a no-arg command
|
||||
// commits its directive chip, an arg-taking command expands to its
|
||||
// options step, and an arg option commits the full `/cmd arg` chip. Space
|
||||
// is slash-only (an `@` mention takes a literal space) and gated to a
|
||||
// non-empty query so a bare `/ ` still types a space.
|
||||
const acceptOnSpace = event.key === ' ' && trigger.kind === '/' && Boolean(trigger.query.trim())
|
||||
const accept = event.key === 'Enter' || event.key === 'Tab' || acceptOnSpace
|
||||
|
||||
if (accept) {
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
const item = triggerItems[triggerActive]
|
||||
@@ -902,24 +821,6 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
// Arg stage with nothing left to suggest — a fully-typed arg the backend
|
||||
// completer no longer echoes (it drops the exact match), e.g.
|
||||
// `/personality creative`. Space/Tab still commit what's typed as a single
|
||||
// directive chip; Enter falls through to submit (send it as-is).
|
||||
if (
|
||||
trigger?.kind === '/' &&
|
||||
!triggerItems.length &&
|
||||
(event.key === ' ' || event.key === 'Tab') &&
|
||||
slashArgStage(trigger.query) &&
|
||||
trigger.query.trim()
|
||||
) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
commitTypedSlashDirective()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in
|
||||
// place) then sent-message history. The history ring is derived from live
|
||||
// session messages each press — single source of truth, no mirror.
|
||||
@@ -952,9 +853,7 @@ export function ChatBar({
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
// $messages is read imperatively (not subscribed) so the composer
|
||||
// doesn't re-render on every streaming delta flush.
|
||||
const history = deriveUserHistory($messages.get(), chatMessageText)
|
||||
const history = deriveUserHistory(sessionMessages, chatMessageText)
|
||||
const entry = browseBackward(sessionId, currentDraft, history)
|
||||
|
||||
if (entry !== null) {
|
||||
@@ -979,7 +878,7 @@ export function ChatBar({
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
|
||||
const history = deriveUserHistory($messages.get(), chatMessageText)
|
||||
const history = deriveUserHistory(sessionMessages, chatMessageText)
|
||||
const result = browseForward(sessionId, history)
|
||||
|
||||
if (result !== null) {
|
||||
@@ -1015,10 +914,6 @@ export function ChatBar({
|
||||
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
|
||||
const hasLivePayload = editorText.trim().length > 0 || attachments.length > 0
|
||||
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!busy && !hasLivePayload && queuedPrompts.length > 0) {
|
||||
void drainNextQueued()
|
||||
|
||||
@@ -1218,8 +1113,11 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
const stashAt = (scope: string | null, text = draftRef.current, attachments = $composerAttachments.get()) =>
|
||||
stashSessionDraft(scope, text, attachments)
|
||||
const stashAt = (
|
||||
scope: string | null,
|
||||
text = draftRef.current,
|
||||
attachments = $composerAttachments.get()
|
||||
) => stashSessionDraft(scope, text, attachments)
|
||||
|
||||
// Per-thread draft swap — the composer's only session coupling. Lifecycle
|
||||
// never clears composer state; this effect alone stashes on leave, restores
|
||||
@@ -1417,7 +1315,6 @@ export function ChatBar({
|
||||
return false
|
||||
}
|
||||
|
||||
drainFailuresRef.current.delete(entry.id)
|
||||
removeQueuedPrompt(activeQueueSessionKey, entry.id)
|
||||
resetBrowseState(sessionId)
|
||||
|
||||
@@ -1429,17 +1326,16 @@ export function ChatBar({
|
||||
[activeQueueSessionKey, onSubmit, queuedPrompts, sessionId]
|
||||
)
|
||||
|
||||
const pickDrainHead = useCallback(
|
||||
(entries: QueuedPromptEntry[]) => {
|
||||
const skip = queueEditRef.current?.entryId
|
||||
const drainNextQueued = useCallback(
|
||||
() =>
|
||||
runDrain(entries => {
|
||||
const skip = queueEdit?.entryId
|
||||
|
||||
return skip ? entries.find(e => e.id !== skip) : entries[0]
|
||||
},
|
||||
[] // reads the edit id off a ref so the lock-holder always sees the latest
|
||||
return skip ? entries.find(e => e.id !== skip) : entries[0]
|
||||
}),
|
||||
[queueEdit, runDrain]
|
||||
)
|
||||
|
||||
const drainNextQueued = useCallback(() => runDrain(pickDrainHead), [pickDrainHead, runDrain])
|
||||
|
||||
const sendQueuedNow = useCallback(
|
||||
(id: string) => {
|
||||
if (!activeQueueSessionKey || id === queueEdit?.entryId) {
|
||||
@@ -1457,76 +1353,30 @@ export function ChatBar({
|
||||
return true
|
||||
}
|
||||
|
||||
// A manual send clears the auto-drain backoff so a stuck entry the user
|
||||
// taps gets a fresh attempt (and re-enables auto-retry on success).
|
||||
drainFailuresRef.current.delete(id)
|
||||
|
||||
return runDrain(entries => entries.find(e => e.id === id))
|
||||
},
|
||||
[activeQueueSessionKey, busy, onCancel, queueEdit, runDrain]
|
||||
)
|
||||
|
||||
// Edge-independent auto-drain: send the head whenever the session is idle and
|
||||
// the queue is non-empty, bounding retries so a thrown/rejected onSubmit (e.g.
|
||||
// a stale-session 404) can't strand the entry permanently nor spin-loop. The
|
||||
// drain lock serializes sends; a remount/reconnect resets the failure counts.
|
||||
const autoDrainNext = useCallback(() => {
|
||||
if (busy || drainingQueueRef.current || !activeQueueSessionKey) {
|
||||
return
|
||||
}
|
||||
// Auto-drain on busy → false (turn settled). Queued turns always flow once
|
||||
// the session is idle again — whether the turn finished naturally or the
|
||||
// user interrupted it. Interrupting to reach a queued message is the whole
|
||||
// point of the queue, so we never suppress the drain. To cancel queued
|
||||
// turns, the user deletes them from the panel.
|
||||
useEffect(() => {
|
||||
const wasBusy = previousBusyRef.current
|
||||
previousBusyRef.current = busy
|
||||
|
||||
const entry = pickDrainHead(queuedPrompts)
|
||||
|
||||
if (!entry || (drainFailuresRef.current.get(entry.id) ?? 0) >= MAX_AUTO_DRAIN_ATTEMPTS) {
|
||||
return
|
||||
}
|
||||
|
||||
const onFail = () => {
|
||||
const fails = (drainFailuresRef.current.get(entry.id) ?? 0) + 1
|
||||
drainFailuresRef.current.set(entry.id, fails)
|
||||
|
||||
if (fails >= MAX_AUTO_DRAIN_ATTEMPTS) {
|
||||
notify({
|
||||
id: 'composer-queue-stuck',
|
||||
kind: 'error',
|
||||
title: t.composer.queueStuckTitle,
|
||||
message: t.composer.queueStuckBody
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
void runDrain(() => entry)
|
||||
.then(sent => {
|
||||
if (!sent) {
|
||||
onFail()
|
||||
}
|
||||
if (
|
||||
shouldAutoDrainOnSettle({
|
||||
isBusy: busy,
|
||||
queueLength: queuedPrompts.length,
|
||||
wasBusy
|
||||
})
|
||||
.catch(onFail)
|
||||
}, [activeQueueSessionKey, busy, pickDrainHead, queuedPrompts, runDrain, t])
|
||||
|
||||
// Re-key on a runtime session-id change. A stable stored id (queueSessionKey)
|
||||
// never churns, so a change there is a real session switch and must NOT
|
||||
// migrate; only the runtime-derived key (queueSessionKey falsy → key is
|
||||
// sessionId) churns on a backend bounce/resume of the same conversation.
|
||||
useEffect(() => {
|
||||
const prev = prevQueueKeyRef.current
|
||||
prevQueueKeyRef.current = activeQueueSessionKey
|
||||
|
||||
if (queueSessionKey || !prev || !activeQueueSessionKey || prev === activeQueueSessionKey) {
|
||||
return
|
||||
) {
|
||||
void drainNextQueued()
|
||||
}
|
||||
|
||||
migrateQueuedPrompts(prev, activeQueueSessionKey)
|
||||
}, [activeQueueSessionKey, queueSessionKey])
|
||||
|
||||
// Queued turns flow whenever the session is idle — on the busy→false settle
|
||||
// edge, on mount/reconnect, and after a re-key — so a swallowed edge can't
|
||||
// strand them. To cancel queued turns, the user deletes them from the panel.
|
||||
useEffect(() => {
|
||||
if (shouldAutoDrain({ isBusy: busy, queueLength: queuedPrompts.length })) {
|
||||
autoDrainNext()
|
||||
}
|
||||
}, [autoDrainNext, busy, queuedPrompts.length])
|
||||
}, [busy, drainNextQueued, queuedPrompts.length])
|
||||
|
||||
// Queue-edit cleanup: on session swap the scope effect already stashed the
|
||||
// edit snapshot; only restore into the composer when still on the same scope.
|
||||
@@ -1561,10 +1411,6 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
const submitDraft = () => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
// Source the text from the DOM editor, not React state. The AUI composer
|
||||
// state (`draft`) and the derived `hasComposerPayload` lag the DOM by a
|
||||
// render, so on fast typing or IME composition the final keystroke(s) may
|
||||
@@ -1745,7 +1591,6 @@ export function ChatBar({
|
||||
const input = (
|
||||
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
|
||||
<div
|
||||
aria-disabled={inputDisabled ? true : undefined}
|
||||
aria-label={t.composer.message}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
@@ -1756,7 +1601,7 @@ export function ChatBar({
|
||||
stacked && 'pl-3',
|
||||
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'
|
||||
)}
|
||||
contentEditable={!inputDisabled}
|
||||
contentEditable={!disabled}
|
||||
data-placeholder={placeholder}
|
||||
data-slot={RICH_INPUT_SLOT}
|
||||
onBlur={() => window.setTimeout(closeTrigger, 80)}
|
||||
@@ -1824,7 +1669,6 @@ export function ChatBar({
|
||||
className="group/composer absolute bottom-0 left-1/2 z-30 w-[min(var(--composer-width),calc(100%-2rem))] max-w-full -translate-x-1/2 rounded-2xl pt-2 pb-[var(--composer-shell-pad-block-end)]"
|
||||
data-drag-active={dragActive ? '' : undefined}
|
||||
data-slot="composer-root"
|
||||
data-status-stack={statusStackVisible ? '' : undefined}
|
||||
data-thread-scrolled-up={scrolledUp ? '' : undefined}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
@@ -1842,7 +1686,7 @@ export function ChatBar({
|
||||
ref={composerRef}
|
||||
>
|
||||
{showHelpHint && <HelpHint />}
|
||||
{trigger && !argStageEmpty && (
|
||||
{trigger && (
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={triggerActive}
|
||||
items={triggerItems}
|
||||
@@ -1852,30 +1696,26 @@ export function ChatBar({
|
||||
onPick={replaceTriggerWithChip}
|
||||
/>
|
||||
)}
|
||||
{/* Session-scoped status stack (todos, subagents, background tasks,
|
||||
queue). Out of flow so it never inflates the composer's measured
|
||||
height; it overlays the chat instead of pushing it, and publishes
|
||||
its own --status-stack-measured-height so the thread's clearance
|
||||
accounts for it. Collapses to nothing when every status is empty. */}
|
||||
<ComposerStatusStack
|
||||
queue={
|
||||
activeQueueSessionKey && queuedPrompts.length > 0 ? (
|
||||
<QueuePanel
|
||||
busy={busy}
|
||||
editingId={queueEdit?.entryId ?? null}
|
||||
entries={queuedPrompts}
|
||||
onDelete={id => {
|
||||
if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) {
|
||||
exitQueuedEdit('cancel')
|
||||
}
|
||||
}}
|
||||
onEdit={beginQueuedEdit}
|
||||
onSendNow={id => void sendQueuedNow(id)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
sessionId={statusSessionId}
|
||||
/>
|
||||
{activeQueueSessionKey && queuedPrompts.length > 0 && (
|
||||
// Out of flow so the queue never inflates the composer's measured
|
||||
// height (that drives thread bottom padding → chat resizes on
|
||||
// queue). Overlaps -mb-2 onto the surface's top border for a shared
|
||||
// edge; capped + scrollable. Overlays the chat instead of pushing it.
|
||||
<div className="absolute inset-x-0 bottom-full z-6 -mb-2 max-h-[40vh] overflow-y-auto">
|
||||
<QueuePanel
|
||||
busy={busy}
|
||||
editingId={queueEdit?.entryId ?? null}
|
||||
entries={queuedPrompts}
|
||||
onDelete={id => {
|
||||
if (removeQueuedPrompt(activeQueueSessionKey, id) && queueEdit?.entryId === id) {
|
||||
exitQueuedEdit('cancel')
|
||||
}
|
||||
}}
|
||||
onEdit={beginQueuedEdit}
|
||||
onSendNow={id => void sendQueuedNow(id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-[inherit]"
|
||||
style={{ background: COMPOSER_FADE_BACKGROUND }}
|
||||
@@ -1883,8 +1723,9 @@ export function ChatBar({
|
||||
<div className="relative w-full rounded-[inherit]">
|
||||
<div
|
||||
className={cn(
|
||||
'group/composer-surface relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out focus-within:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
|
||||
'relative z-4 isolate rounded-[inherit] border border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(18%*var(--composer-ring-strength)),var(--dt-input))] transition-[border-color] duration-200 ease-out',
|
||||
COMPOSER_DROP_FADE_CLASS,
|
||||
'group-focus-within/composer:border-[color-mix(in_srgb,var(--dt-composer-ring)_calc(45%*var(--composer-ring-strength)),transparent)]',
|
||||
'group-has-data-[state=open]/composer:border-t-transparent',
|
||||
dragActive && COMPOSER_DROP_ACTIVE_CLASS
|
||||
)}
|
||||
@@ -1895,14 +1736,20 @@ export function ChatBar({
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
|
||||
composerFill,
|
||||
composerSurfaceGlass
|
||||
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
|
||||
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
|
||||
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
|
||||
'transition-[background-color] duration-150 ease-out',
|
||||
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
|
||||
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer-surface:opacity-100' : 'opacity-100'
|
||||
scrolledUp
|
||||
? 'opacity-30 group-hover/composer:opacity-100 group-focus-within/composer:opacity-100'
|
||||
: 'opacity-100'
|
||||
)}
|
||||
data-slot="composer-fade"
|
||||
>
|
||||
@@ -1977,8 +1824,12 @@ export function ChatBarFallback() {
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 -z-10 rounded-[inherit]',
|
||||
composerFill,
|
||||
composerSurfaceGlass
|
||||
'bg-[color-mix(in_srgb,var(--dt-card)_72%,transparent)]',
|
||||
'backdrop-blur-[0.75rem] backdrop-saturate-[1.12]',
|
||||
'[-webkit-backdrop-filter:blur(0.75rem)_saturate(1.12)]',
|
||||
'transition-[background-color] duration-150 ease-out',
|
||||
'group-data-[thread-scrolled-up]/composer:bg-[color-mix(in_srgb,var(--dt-card)_48%,transparent)]',
|
||||
'group-focus-within/composer:bg-[color-mix(in_srgb,var(--dt-card)_85%,transparent)]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,7 @@ import { contextPath } from '@/lib/chat-runtime'
|
||||
|
||||
import type { DroppedFile } from '../hooks/use-composer-actions'
|
||||
|
||||
import {
|
||||
composerPlainText,
|
||||
normalizeComposerEditorDom,
|
||||
placeCaretEnd,
|
||||
refChipElement
|
||||
} from './rich-editor'
|
||||
import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } from './rich-editor'
|
||||
|
||||
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
|
||||
export type InlineRefInput = string | { kind: string; label?: string; value: string }
|
||||
@@ -94,102 +89,56 @@ export function droppedFileInlineRefs(candidates: DroppedFile[], cwd: string | n
|
||||
return candidates.map(candidate => droppedFileInlineRef(candidate, cwd)).filter((ref): ref is string => Boolean(ref))
|
||||
}
|
||||
|
||||
function parseInlineRef(ref: InlineRefInput): { kind: string; label?: string; rawValue: string } | null {
|
||||
if (typeof ref !== 'string') {
|
||||
return { kind: ref.kind, label: ref.label, rawValue: ref.value }
|
||||
}
|
||||
|
||||
const match = ref.match(/^@([^:]+):(.+)$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { kind: match[1] || 'file', rawValue: match[2] || '' }
|
||||
}
|
||||
|
||||
function plainTextInRange(editor: HTMLDivElement, range: Range, edge: 'after' | 'before') {
|
||||
const slice = range.cloneRange()
|
||||
slice.selectNodeContents(editor)
|
||||
|
||||
if (edge === 'before') {
|
||||
slice.setEnd(range.startContainer, range.startOffset)
|
||||
} else {
|
||||
slice.setStart(range.endContainer, range.endOffset)
|
||||
}
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.appendChild(slice.cloneContents())
|
||||
|
||||
return composerPlainText(container)
|
||||
}
|
||||
|
||||
function buildRefFragment(
|
||||
refs: readonly { kind: string; label?: string; rawValue: string }[],
|
||||
{ needsBeforeSpace, needsAfterSpace }: { needsAfterSpace: boolean; needsBeforeSpace: boolean }
|
||||
) {
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
if (needsBeforeSpace) {
|
||||
fragment.append(document.createTextNode(' '))
|
||||
}
|
||||
|
||||
refs.forEach((ref, index) => {
|
||||
if (index > 0) {
|
||||
fragment.append(document.createTextNode(' '))
|
||||
}
|
||||
|
||||
fragment.append(refChipElement(ref.kind, ref.rawValue, ref.label))
|
||||
})
|
||||
|
||||
if (needsAfterSpace) {
|
||||
fragment.append(document.createTextNode(' '))
|
||||
}
|
||||
|
||||
return fragment
|
||||
}
|
||||
|
||||
export function insertInlineRefsIntoEditor(editor: HTMLDivElement, refs: readonly InlineRefInput[]) {
|
||||
const parsed = refs.map(parseInlineRef).filter((ref): ref is NonNullable<typeof ref> => ref !== null)
|
||||
|
||||
if (!parsed.length) {
|
||||
if (!refs.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
editor.focus({ preventScroll: true })
|
||||
const refsHtml = refs
|
||||
.map(ref => {
|
||||
if (typeof ref !== 'string') {
|
||||
return refChipHtml(ref.kind, ref.value, ref.label)
|
||||
}
|
||||
|
||||
const match = ref.match(/^@([^:]+):(.+)$/)
|
||||
|
||||
return match ? refChipHtml(match[1], match[2]) : escapeHtml(ref)
|
||||
})
|
||||
.join(' ')
|
||||
|
||||
const selection = window.getSelection()
|
||||
|
||||
const range =
|
||||
selection?.rangeCount && editor.contains(selection.getRangeAt(0).commonAncestorContainer)
|
||||
? selection.getRangeAt(0)
|
||||
: null
|
||||
|
||||
if (range && selection) {
|
||||
const beforeText = plainTextInRange(editor, range, 'before')
|
||||
const afterText = plainTextInRange(editor, range, 'after')
|
||||
editor.focus({ preventScroll: true })
|
||||
|
||||
range.insertNode(
|
||||
buildRefFragment(parsed, {
|
||||
needsAfterSpace: afterText.length === 0 || !/^\s/.test(afterText),
|
||||
needsBeforeSpace: beforeText.length > 0 && !/\s$/.test(beforeText)
|
||||
})
|
||||
)
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
if (range) {
|
||||
const beforeRange = range.cloneRange()
|
||||
beforeRange.selectNodeContents(editor)
|
||||
beforeRange.setEnd(range.startContainer, range.startOffset)
|
||||
const beforeContainer = document.createElement('div')
|
||||
beforeContainer.appendChild(beforeRange.cloneContents())
|
||||
|
||||
const afterRange = range.cloneRange()
|
||||
afterRange.selectNodeContents(editor)
|
||||
afterRange.setStart(range.endContainer, range.endOffset)
|
||||
const afterContainer = document.createElement('div')
|
||||
afterContainer.appendChild(afterRange.cloneContents())
|
||||
|
||||
const beforeText = composerPlainText(beforeContainer)
|
||||
const afterText = composerPlainText(afterContainer)
|
||||
const needsBeforeSpace = beforeText.length > 0 && !/\s$/.test(beforeText)
|
||||
const needsAfterSpace = afterText.length === 0 || !/^\s/.test(afterText)
|
||||
|
||||
document.execCommand('insertHTML', false, `${needsBeforeSpace ? ' ' : ''}${refsHtml}${needsAfterSpace ? ' ' : ''}`)
|
||||
} else {
|
||||
const current = composerPlainText(editor)
|
||||
|
||||
editor.append(
|
||||
buildRefFragment(parsed, {
|
||||
needsAfterSpace: true,
|
||||
needsBeforeSpace: current.length > 0 && !/\s$/.test(current)
|
||||
})
|
||||
)
|
||||
placeCaretEnd(editor)
|
||||
document.execCommand('insertHTML', false, `${current && !/\s$/.test(current) ? ' ' : ''}${refsHtml} `)
|
||||
}
|
||||
|
||||
normalizeComposerEditorDom(editor)
|
||||
|
||||
return composerPlainText(editor)
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ChevronDown } from '@/lib/icons'
|
||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
setModelPickerOpen
|
||||
} from '@/store/session'
|
||||
|
||||
import type { ChatBarState } from './types'
|
||||
|
||||
const PILL = cn(
|
||||
'h-(--composer-control-size) max-w-40 shrink-0 gap-1 rounded-md px-2 text-xs font-normal',
|
||||
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)
|
||||
|
||||
/**
|
||||
* Composer model selector — the relocated status-bar pill. Reuses the live
|
||||
* `model.options` dropdown (`modelMenuContent`) verbatim; falls back to the
|
||||
* full picker when the gateway is closed and no live menu exists.
|
||||
*/
|
||||
export function ModelPill({ disabled, model }: { disabled: boolean; model: ChatBarState['model'] }) {
|
||||
const copy = useI18n().t.shell.statusbar
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const fastMode = useStore($currentFastMode)
|
||||
const reasoningEffort = useStore($currentReasoningEffort)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// The model resolves a beat after the gateway/session comes up. Rather than
|
||||
// flash a literal "No model", show a quiet loader (inherits the pill text
|
||||
// color at half opacity) until a model lands.
|
||||
const label = (
|
||||
<>
|
||||
{currentModel.trim() ? (
|
||||
<span className="truncate">{formatModelStatusLabel(currentModel, { fastMode, reasoningEffort })}</span>
|
||||
) : (
|
||||
<GlyphSpinner className="opacity-50" spinner="braille" />
|
||||
)}
|
||||
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
|
||||
</>
|
||||
)
|
||||
|
||||
const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel
|
||||
|
||||
if (!model.modelMenuContent) {
|
||||
return (
|
||||
<Button
|
||||
aria-label={copy.openModelPicker}
|
||||
className={PILL}
|
||||
disabled={disabled}
|
||||
onClick={() => setModelPickerOpen(true)}
|
||||
title={copy.openModelPicker}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-label={title} className={PILL} disabled={disabled} title={title} type="button" variant="ghost">
|
||||
{label}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64 p-0" side="top" sideOffset={8}>
|
||||
<ModelMenuCloseContext.Provider value={() => setOpen(false)}>
|
||||
{model.modelMenuContent}
|
||||
</ModelMenuCloseContext.Provider>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StatusRow } from '@/components/chat/status-row'
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { ArrowUp, Pencil, Trash2 } from '@/lib/icons'
|
||||
@@ -22,84 +23,108 @@ const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
|
||||
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onSendNow }: QueuePanelProps) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const [collapsed, setCollapsed] = useState(true)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusSection label={c.queued(entries.length)}>
|
||||
{entries.map(entry => {
|
||||
const isEditing = editingId === entry.id
|
||||
const attachmentsCount = entry.attachments.length
|
||||
<div className="rounded-t-2xl border border-b-0 border-border/65 bg-[color-mix(in_srgb,var(--dt-card)_70%,transparent)] pt-0.5 pb-1 mx-1">
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 px-2 text-left text-[0.6rem] font-medium text-muted-foreground/92 transition-colors hover:text-foreground/90"
|
||||
onClick={() => setCollapsed(open => !open)}
|
||||
type="button"
|
||||
>
|
||||
<DisclosureCaret className="shrink-0" open={!collapsed} size="1em" />
|
||||
<span className="truncate">{c.queued(entries.length)}</span>
|
||||
</button>
|
||||
|
||||
return (
|
||||
<StatusRow
|
||||
className={cn(
|
||||
'border border-transparent',
|
||||
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
|
||||
)}
|
||||
key={entry.id}
|
||||
trailing={
|
||||
<>
|
||||
<Tip label={c.queueEdit}>
|
||||
<Button
|
||||
aria-label={c.queueEdit}
|
||||
className="size-5 rounded-md"
|
||||
disabled={Boolean(editingId) && !isEditing}
|
||||
onClick={() => onEdit(entry)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={busy ? c.queueSendNext : c.queueSend}>
|
||||
<Button
|
||||
aria-label={busy ? c.queueSendNext : c.queueSend}
|
||||
className="size-5 rounded-md"
|
||||
disabled={isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowUp size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={c.queueDelete}>
|
||||
<Button
|
||||
aria-label={c.queueDelete}
|
||||
className="size-5 rounded-md"
|
||||
onClick={() => onDelete(entry.id)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
</>
|
||||
}
|
||||
trailingVisible={isEditing}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
|
||||
{(attachmentsCount > 0 || isEditing) && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
|
||||
{attachmentsCount > 0 && <span>{c.attachments(attachmentsCount)}</span>}
|
||||
{isEditing && (
|
||||
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
|
||||
{c.editingInComposer}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<div className="space-y-0.5 px-1 pb-0.5">
|
||||
{entries.map(entry => {
|
||||
const isEditing = editingId === entry.id
|
||||
const attachmentsCount = entry.attachments.length
|
||||
const sendLabel = busy ? c.sendQueuedNext : c.sendQueuedNow
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/queue-row flex items-center gap-1.5 rounded-lg border border-transparent px-1.5 py-0.5',
|
||||
'transition-colors duration-300 ease-out hover:bg-(--chrome-action-hover) hover:transition-none',
|
||||
isEditing && 'border-[color-mix(in_srgb,var(--dt-composer-ring)_40%,transparent)] bg-accent/25'
|
||||
)}
|
||||
key={entry.id}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-3.5 w-3.5 shrink-0 rounded-full border border-foreground/35 bg-transparent"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[0.73rem] leading-4 text-foreground/92">{entryPreview(entry, c)}</p>
|
||||
{(attachmentsCount > 0 || isEditing) && (
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-[0.64rem] text-muted-foreground/75">
|
||||
{attachmentsCount > 0 && <span>{c.attachments(attachmentsCount)}</span>}
|
||||
{isEditing && (
|
||||
<span className="text-[color-mix(in_srgb,var(--dt-composer-ring)_78%,var(--muted-foreground))]">
|
||||
{c.editingInComposer}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</StatusRow>
|
||||
)
|
||||
})}
|
||||
</StatusSection>
|
||||
<div
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-0 transition-opacity',
|
||||
isEditing
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 group-hover/queue-row:opacity-100 group-focus-within/queue-row:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Tip label={c.editQueued}>
|
||||
<Button
|
||||
aria-label={c.editQueued}
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={Boolean(editingId) && !isEditing}
|
||||
onClick={() => onEdit(entry)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={sendLabel}>
|
||||
<Button
|
||||
aria-label={sendLabel}
|
||||
className="h-5 w-5 rounded-md"
|
||||
disabled={isEditing}
|
||||
onClick={() => onSendNow(entry.id)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowUp size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={c.deleteQueued}>
|
||||
<Button
|
||||
aria-label={c.deleteQueued}
|
||||
className="h-5 w-5 rounded-md"
|
||||
onClick={() => onDelete(entry.id)}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,25 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { insertInlineRefsIntoEditor } from './inline-refs'
|
||||
import {
|
||||
composerPlainText,
|
||||
deleteSelectionInEditor,
|
||||
insertPlainTextAtCaret,
|
||||
normalizeComposerEditorDom,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
|
||||
const caretIn = (editor: HTMLElement) => {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()!
|
||||
|
||||
range.selectNodeContents(editor)
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
import { composerPlainText, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
|
||||
|
||||
describe('renderComposerContents', () => {
|
||||
it('renders refs and raw text without interpreting user text as HTML', () => {
|
||||
@@ -35,100 +16,3 @@ describe('renderComposerContents', () => {
|
||||
expect(composerPlainText(editor)).toBe('@file:`<img src=x onerror=alert(1)>` <b>raw</b>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeComposerEditorDom', () => {
|
||||
it('unwraps a single insertHTML wrapper div so plain text stays one line', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.innerHTML = '<div><span data-ref-text="@file:`src/foo.ts`" contenteditable="false">foo.ts</span> </div>'
|
||||
|
||||
normalizeComposerEditorDom(editor)
|
||||
|
||||
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
|
||||
expect(editor.querySelector(':scope > div')).toBeNull()
|
||||
})
|
||||
|
||||
it('removes a trailing br after a ref chip', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.append(refChipElement('file', '`src/foo.ts`'), document.createElement('br'))
|
||||
|
||||
normalizeComposerEditorDom(editor)
|
||||
|
||||
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts`')
|
||||
expect(editor.querySelector('br')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertInlineRefsIntoEditor', () => {
|
||||
it('inserts chips without wrapper divs or spurious newlines', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
|
||||
insertInlineRefsIntoEditor(editor, ['@file:`src/foo.ts`'])
|
||||
|
||||
expect(editor.querySelector(':scope > div')).toBeNull()
|
||||
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertPlainTextAtCaret', () => {
|
||||
it('inserts multiline text as text nodes + br', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
insertPlainTextAtCaret(editor, 'one\ntwo\nthree')
|
||||
|
||||
expect(editor.querySelectorAll('br').length).toBe(2)
|
||||
expect(composerPlainText(editor)).toBe('one\ntwo\nthree')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('replaces the selected span', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.textContent = 'abXYef'
|
||||
document.body.append(editor)
|
||||
|
||||
const text = editor.firstChild!
|
||||
const selection = window.getSelection()!
|
||||
const range = document.createRange()
|
||||
|
||||
range.setStart(text, 2)
|
||||
range.setEnd(text, 4)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
insertPlainTextAtCaret(editor, 'cd')
|
||||
|
||||
expect(composerPlainText(editor)).toBe('abcdef')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteSelectionInEditor', () => {
|
||||
it('clears a non-collapsed range and leaves a collapsed caret', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.textContent = 'hello world'
|
||||
document.body.append(editor)
|
||||
|
||||
const selection = window.getSelection()!
|
||||
const range = document.createRange()
|
||||
|
||||
range.selectNodeContents(editor)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
expect(deleteSelectionInEditor(editor)).toBe(true)
|
||||
expect(composerPlainText(editor)).toBe('')
|
||||
expect(selection.getRangeAt(0).collapsed).toBe(true)
|
||||
expect(deleteSelectionInEditor(editor)).toBe(false)
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -132,63 +132,6 @@ export function renderComposerContents(target: HTMLElement, text: string) {
|
||||
appendComposerContents(target, text)
|
||||
}
|
||||
|
||||
/** Caret range when the selection lives inside `editor`; else null. */
|
||||
function composerSelectionRange(editor: HTMLElement) {
|
||||
const selection = window.getSelection()
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null
|
||||
|
||||
if (!selection || !range || !editor.contains(range.commonAncestorContainer)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { range, selection }
|
||||
}
|
||||
|
||||
/** Insert plain text at the caret (replacing any selection). Pastes use this
|
||||
* instead of `execCommand('insertText')` — Chromium's editing pipeline is
|
||||
* ~O(n²) on large multiline blobs. */
|
||||
export function insertPlainTextAtCaret(editor: HTMLElement, text: string) {
|
||||
const hit = composerSelectionRange(editor)
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
appendTextWithBreaks(fragment, text)
|
||||
|
||||
const tail = fragment.lastChild
|
||||
|
||||
if (hit) {
|
||||
hit.range.deleteContents()
|
||||
hit.range.insertNode(fragment)
|
||||
} else {
|
||||
editor.append(fragment)
|
||||
}
|
||||
|
||||
if (tail) {
|
||||
const caret = document.createRange()
|
||||
caret.setStartAfter(tail)
|
||||
caret.collapse(true)
|
||||
const selection = hit?.selection ?? window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(caret)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a non-collapsed selection in-editor. Skips collapsed carets so word/
|
||||
* line delete (Opt/Cmd+Backspace) stays native. Returns whether anything ran. */
|
||||
export function deleteSelectionInEditor(editor: HTMLElement) {
|
||||
const hit = composerSelectionRange(editor)
|
||||
|
||||
if (!hit || hit.range.collapsed) {
|
||||
return false
|
||||
}
|
||||
|
||||
hit.range.deleteContents()
|
||||
hit.range.collapse(true)
|
||||
hit.selection.removeAllRanges()
|
||||
hit.selection.addRange(hit.range)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Serialize a draft string into chip-HTML for the contenteditable surface. */
|
||||
export function composerHtml(text: string) {
|
||||
let cursor = 0
|
||||
@@ -241,36 +184,3 @@ export function placeCaretEnd(element: HTMLElement) {
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(range)
|
||||
}
|
||||
|
||||
/** Drop contenteditable junk that serializes as `\n` and falsely expands the composer. */
|
||||
export function normalizeComposerEditorDom(editor: HTMLElement) {
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeName === 'BR') {
|
||||
editor.replaceChildren()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (editor.childNodes.length === 1 && editor.firstChild?.nodeType === Node.ELEMENT_NODE) {
|
||||
const wrapper = editor.firstChild as HTMLElement
|
||||
|
||||
if (wrapper.tagName === 'DIV' && wrapper.dataset.slot !== RICH_INPUT_SLOT) {
|
||||
editor.replaceChildren(...Array.from(wrapper.childNodes))
|
||||
}
|
||||
}
|
||||
|
||||
const last = editor.lastChild
|
||||
|
||||
if (last?.nodeName !== 'BR') {
|
||||
return
|
||||
}
|
||||
|
||||
let prev: ChildNode | null = last.previousSibling
|
||||
|
||||
while (prev?.nodeType === Node.TEXT_NODE && !(prev.textContent || '').trim()) {
|
||||
prev = prev.previousSibling
|
||||
}
|
||||
|
||||
if ((prev as HTMLElement | null)?.dataset.refText) {
|
||||
editor.removeChild(last)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { blurComposerInput } from '@/app/chat/composer/focus'
|
||||
import { AGENTS_ROUTE } from '@/app/routes'
|
||||
import { composerDockCard } from '@/components/chat/composer-dock'
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$statusItemsBySession,
|
||||
type ComposerStatusItem,
|
||||
dismissBackgroundProcess,
|
||||
groupStatusItems,
|
||||
refreshBackgroundProcesses,
|
||||
type StatusGroup,
|
||||
stopBackgroundProcess
|
||||
} from '@/store/composer-status'
|
||||
import { $threadScrolledUp } from '@/store/thread-scroll'
|
||||
import { openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { StatusItemRow } from './status-row'
|
||||
|
||||
// Slow safety-net poll for silent exits (processes without notify_on_complete
|
||||
// emit no event when they die). Only armed while a running row is on screen.
|
||||
const BACKGROUND_POLL_MS = 5_000
|
||||
|
||||
const groupLabel = (group: StatusGroup, s: Translations['statusStack']) => {
|
||||
if (group.type === 'todo') {
|
||||
return s.todos(group.items.filter(i => i.todoStatus === 'completed').length, group.items.length)
|
||||
}
|
||||
|
||||
return group.type === 'subagent' ? s.subagents(group.items.length) : s.background(group.items.length)
|
||||
}
|
||||
|
||||
interface ComposerStatusStackProps {
|
||||
/** The queue, built by the composer (it owns the queue's callbacks). Rendered
|
||||
* as the last group so it stays fused to the composer like before. */
|
||||
queue: ReactNode
|
||||
sessionId: null | string
|
||||
}
|
||||
|
||||
/**
|
||||
* The status "sink" above the composer: one card (the queue's chrome) holding
|
||||
* every session-scoped status — subagents, background tasks, queue — grouped by
|
||||
* type and separated by light dividers. Collapses to nothing when empty.
|
||||
*/
|
||||
export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackProps) {
|
||||
const { t } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const itemsBySession = useStore($statusItemsBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []),
|
||||
[itemsBySession, sessionId]
|
||||
)
|
||||
|
||||
// Seed from the registry on session open; event-driven refreshes (terminal /
|
||||
// process tool completions) live in use-message-stream.
|
||||
useEffect(() => {
|
||||
if (sessionId) {
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
const hasRunningBackground = groups.some(g => g.type === 'background' && g.items.some(i => i.state === 'running'))
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !hasRunningBackground) {
|
||||
return
|
||||
}
|
||||
|
||||
const timer = setInterval(() => void refreshBackgroundProcesses(sessionId), BACKGROUND_POLL_MS)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [hasRunningBackground, sessionId])
|
||||
|
||||
const openAgents = () => navigate(AGENTS_ROUTE)
|
||||
|
||||
const openSubagent = (item: ComposerStatusItem) =>
|
||||
item.sessionId ? void openSessionInNewWindow(item.sessionId, { watch: true }) : openAgents()
|
||||
|
||||
const sections: { key: string; node: ReactNode }[] = groups.map(group => ({
|
||||
key: group.type,
|
||||
node: (
|
||||
<StatusSection
|
||||
accessory={
|
||||
group.type === 'subagent' ? (
|
||||
<Button
|
||||
className="text-muted-foreground/75 hover:text-foreground/90"
|
||||
onClick={openAgents}
|
||||
size="micro"
|
||||
type="button"
|
||||
variant="text"
|
||||
>
|
||||
{t.statusStack.agents}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
defaultCollapsed={group.type !== 'todo'}
|
||||
icon={
|
||||
group.type === 'todo' ? (
|
||||
<Codicon className="text-muted-foreground/70" name="checklist" size="0.8rem" />
|
||||
) : undefined
|
||||
}
|
||||
label={groupLabel(group, t.statusStack)}
|
||||
>
|
||||
{group.items.map(item => (
|
||||
<StatusItemRow
|
||||
item={item}
|
||||
key={item.id}
|
||||
onDismiss={sessionId ? id => dismissBackgroundProcess(sessionId, id) : undefined}
|
||||
onOpen={() => openSubagent(item)}
|
||||
onStop={sessionId ? id => stopBackgroundProcess(sessionId, id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</StatusSection>
|
||||
)
|
||||
}))
|
||||
|
||||
if (queue) {
|
||||
sections.push({ key: 'queue', node: queue })
|
||||
}
|
||||
|
||||
const visible = sections.length > 0
|
||||
const stackRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
// The stack is out of flow (overlays the thread), so the composer's measured
|
||||
// height never sees it. Publish our own measured height — bucketed like the
|
||||
// composer's, to avoid style invalidation churn — so the thread's
|
||||
// last-message clearance can add it and the stack never hides messages.
|
||||
useLayoutEffect(() => {
|
||||
const root = document.documentElement
|
||||
const el = stackRef.current
|
||||
|
||||
if (!visible || !el) {
|
||||
root.style.removeProperty('--status-stack-measured-height')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let last = -1
|
||||
|
||||
const sync = () => {
|
||||
const bucket = Math.round(el.getBoundingClientRect().height / 8) * 8
|
||||
|
||||
if (bucket !== last) {
|
||||
last = bucket
|
||||
root.style.setProperty('--status-stack-measured-height', `${bucket}px`)
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(sync)
|
||||
observer.observe(el)
|
||||
sync()
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
root.style.removeProperty('--status-stack-measured-height')
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
if (!visible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
// Sits above the composer (bottom-full), nudged down by the shell's 0.5rem
|
||||
// top pad (pt-2 on composer-root) plus 1px so its bottom edge overlaps the
|
||||
// composer surface's top border. z BELOW the surface (z-4) so the surface's
|
||||
// top border paints over our transparent bottom border — one seam, no
|
||||
// double line.
|
||||
className="absolute inset-x-0 bottom-full z-3 max-h-[40vh] translate-y-[calc(0.5rem+1px)] overflow-y-auto"
|
||||
onPointerDownCapture={() => blurComposerInput()}
|
||||
ref={stackRef}
|
||||
>
|
||||
{/* The card paints the shared --composer-fill (rest / scrolled / focused
|
||||
all match the composer surface by construction); on scroll we only
|
||||
ghost the CONTENT — element opacity on the card would kill the blur.
|
||||
Rounded top, square bottom; the bottom border is TRANSPARENT — the
|
||||
composer surface's visible top border (which sits at a higher z) is the
|
||||
single shared seam, so the two read as one fused capsule. */}
|
||||
<div className={cn(composerDockCard('top'), 'mx-2 rounded-b-none border-b border-b-transparent pt-0.5 pb-1')}>
|
||||
<div
|
||||
className={cn(
|
||||
'transition-opacity duration-200 ease-out',
|
||||
scrolledUp ? 'opacity-30 group-hover/composer:opacity-100' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
{sections.map(section => (
|
||||
<div key={section.key}>{section.node}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { Fragment, memo, type ReactNode, useState } from 'react'
|
||||
|
||||
import { StatusRow } from '@/components/chat/status-row'
|
||||
import { TerminalOutput } from '@/components/chat/terminal-output'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { ArrowUpRight, X } from '@/lib/icons'
|
||||
import type { TodoStatus } from '@/lib/todos'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerStatusItem } from '@/store/composer-status'
|
||||
|
||||
const toolLabel = (name: string) =>
|
||||
name
|
||||
.split('_')
|
||||
.filter(Boolean)
|
||||
.map(part => part[0]!.toUpperCase() + part.slice(1))
|
||||
.join(' ') || name
|
||||
|
||||
// Todo rows speak checkbox, not spinner-and-dot: a dashed ring while the item
|
||||
// is still open (pending), codicons once it resolves, a live spinner only on
|
||||
// the in-progress item.
|
||||
const TODO_GLYPHS: Record<Exclude<TodoStatus, 'in_progress' | 'pending'>, { icon: string; tone: string }> = {
|
||||
cancelled: { icon: 'circle-slash', tone: 'text-muted-foreground/45' },
|
||||
completed: { icon: 'pass-filled', tone: 'text-emerald-500/80' }
|
||||
}
|
||||
|
||||
// Left slot: braille spinner while running, otherwise a small status dot
|
||||
// (green = done, red = failed) so the slot is always filled and rows align.
|
||||
function leadingGlyph(item: ComposerStatusItem, s: Translations['statusStack']): ReactNode {
|
||||
if (item.todoStatus === 'pending') {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="box-border size-[0.7rem] rounded-full border border-dashed border-muted-foreground/60"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (item.todoStatus && item.todoStatus !== 'in_progress') {
|
||||
const glyph = TODO_GLYPHS[item.todoStatus]
|
||||
|
||||
return <Codicon className={glyph.tone} name={glyph.icon} size="0.8rem" />
|
||||
}
|
||||
|
||||
if (item.state === 'running') {
|
||||
return (
|
||||
<GlyphSpinner
|
||||
ariaLabel={s.running}
|
||||
className="text-[0.9rem] leading-none text-muted-foreground/80"
|
||||
spinner="braille"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn('size-1.5 rounded-full', item.state === 'failed' ? 'bg-destructive/80' : 'bg-emerald-500/70')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface StatusItemRowProps {
|
||||
item: ComposerStatusItem
|
||||
/** Clear a finished background task from the stack. */
|
||||
onDismiss?: (id: string) => void
|
||||
/** Open the subagent's own session window, livestreamed by the gateway's
|
||||
* child-session mirror (Agents view fallback for older gateways). */
|
||||
onOpen?: () => void
|
||||
/** Cancel a running background task. */
|
||||
onStop?: (id: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one {@link ComposerStatusItem} into the shared {@link StatusRow}.
|
||||
* Memoised + keyed by id so parent re-renders never remount it (the spinner
|
||||
* keeps ticking instead of resetting).
|
||||
*/
|
||||
export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOpen, onStop }: StatusItemRowProps) {
|
||||
const { t } = useI18n()
|
||||
const s = t.statusStack
|
||||
const [outputOpen, setOutputOpen] = useState(false)
|
||||
const failed = item.state === 'failed'
|
||||
const running = item.state === 'running'
|
||||
|
||||
const action =
|
||||
item.type === 'background'
|
||||
? running
|
||||
? onStop && { label: s.stop, onClick: () => onStop(item.id) }
|
||||
: onDismiss && { label: s.dismiss, onClick: () => onDismiss(item.id) }
|
||||
: null
|
||||
|
||||
const canOpen = item.type === 'subagent' && !!onOpen
|
||||
const hasOutput = item.type === 'background' && !!item.output
|
||||
const onActivate = canOpen ? onOpen : hasOutput ? () => setOutputOpen(open => !open) : undefined
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<StatusRow
|
||||
leading={leadingGlyph(item, s)}
|
||||
onActivate={onActivate}
|
||||
trailing={
|
||||
action ? (
|
||||
<Tip label={action.label}>
|
||||
<Button
|
||||
aria-label={action.label}
|
||||
className="-my-1 size-4 rounded-md text-muted-foreground/60 hover:text-foreground/90"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
action.onClick()
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : canOpen ? (
|
||||
<ArrowUpRight aria-hidden className="size-3.5 text-muted-foreground/55" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 max-w-[18rem] truncate text-[0.73rem] leading-4',
|
||||
failed
|
||||
? 'text-destructive/90'
|
||||
: item.todoStatus && item.todoStatus !== 'in_progress'
|
||||
? 'text-muted-foreground/75'
|
||||
: 'text-foreground/92'
|
||||
)}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
{item.type === 'subagent' && item.currentTool && (
|
||||
<span className="shrink-0 truncate text-[0.62rem] leading-4 text-muted-foreground/70">
|
||||
{toolLabel(item.currentTool)}
|
||||
</span>
|
||||
)}
|
||||
{failed && typeof item.exitCode === 'number' && item.exitCode !== 0 && (
|
||||
<span className="shrink-0 rounded bg-destructive/15 px-1 text-[0.58rem] font-semibold text-destructive tabular-nums">
|
||||
{s.exit(item.exitCode)}
|
||||
</span>
|
||||
)}
|
||||
{hasOutput && <DisclosureCaret className="shrink-0 text-muted-foreground/45" open={outputOpen} size="0.8em" />}
|
||||
</StatusRow>
|
||||
{hasOutput && outputOpen && <TerminalOutput className="mx-auto mb-1 max-w-[90%]" text={item.output!} />}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
@@ -1,12 +1,16 @@
|
||||
import type { Unstable_TriggerItem } from '@assistant-ui/core'
|
||||
import { Fragment } from 'react'
|
||||
|
||||
import { BrailleSpinner } from '@/components/ui/braille-spinner'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { COMPLETION_DRAWER_BELOW_CLASS, COMPLETION_DRAWER_CLASS, CompletionDrawerEmpty } from './completion-drawer'
|
||||
import {
|
||||
COMPLETION_DRAWER_BELOW_CLASS,
|
||||
COMPLETION_DRAWER_CLASS,
|
||||
CompletionDrawerEmpty
|
||||
} from './completion-drawer'
|
||||
|
||||
const AT_ICON_BY_TYPE: Record<string, string> = {
|
||||
diff: 'diff',
|
||||
@@ -83,7 +87,7 @@ export function ComposerTriggerPopover({
|
||||
{items.length === 0 ? (
|
||||
loading ? (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-(--ui-text-tertiary)">
|
||||
<GlyphSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
|
||||
<BrailleSpinner ariaLabel={copy.lookupLoading} className="text-foreground/70" spinner="braille" />
|
||||
<span>{copy.lookupLoading}</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
|
||||
@@ -24,8 +22,6 @@ export interface ChatBarState {
|
||||
canSwitch: boolean
|
||||
loading?: boolean
|
||||
quickModels?: QuickModelOption[]
|
||||
/** Reused status-bar dropdown (built with gateway + selectModel upstream). */
|
||||
modelMenuContent?: ReactNode
|
||||
}
|
||||
tools: { enabled: boolean; label: string; suggestions?: ContextSuggestion[] }
|
||||
voice: { enabled: boolean; active: boolean }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { requestComposerFocus, requestComposerInsert, requestComposerInsertRefs } from '@/app/chat/composer/focus'
|
||||
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
|
||||
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
|
||||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime'
|
||||
@@ -287,26 +286,6 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
[currentCwd]
|
||||
)
|
||||
|
||||
const insertContextPathInlineRef = useCallback(
|
||||
(path: string, isDirectory = false) => {
|
||||
if (!path) {
|
||||
return false
|
||||
}
|
||||
|
||||
const ref = droppedFileInlineRef({ isDirectory, path }, currentCwd)
|
||||
|
||||
if (!ref) {
|
||||
return false
|
||||
}
|
||||
|
||||
requestComposerInsertRefs([ref])
|
||||
requestComposerFocus('main')
|
||||
|
||||
return true
|
||||
},
|
||||
[currentCwd]
|
||||
)
|
||||
|
||||
const attachContextFilePath = useCallback(
|
||||
(filePath: string) => {
|
||||
if (!filePath) {
|
||||
@@ -567,7 +546,6 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
|
||||
attachDroppedItems,
|
||||
attachImageBlob,
|
||||
attachImagePath,
|
||||
insertContextPathInlineRef,
|
||||
pasteClipboardImage,
|
||||
pickContextPaths,
|
||||
pickImages,
|
||||
|
||||
@@ -35,18 +35,15 @@ import {
|
||||
$gatewayState,
|
||||
$introPersonality,
|
||||
$introSeed,
|
||||
$lastVisibleMessageIsUser,
|
||||
$messages,
|
||||
$messagesEmpty,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
sessionPinId
|
||||
} from '@/store/session'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { routeSessionId } from '../routes'
|
||||
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass, titlebarHeaderTitleClass } from '../shell/titlebar'
|
||||
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
|
||||
|
||||
import { ChatDropOverlay } from './chat-drop-overlay'
|
||||
import { ChatSwapOverlay } from './chat-swap-overlay'
|
||||
@@ -56,13 +53,11 @@ import { droppedFileInlineRefs, type SessionDragPayload, sessionInlineRef } from
|
||||
import type { ChatBarState } from './composer/types'
|
||||
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
|
||||
import { useFileDropZone } from './hooks/use-file-drop-zone'
|
||||
import { ScrollToBottomButton } from './scroll-to-bottom-button'
|
||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||
import { threadLoadingState } from './thread-loading'
|
||||
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
|
||||
|
||||
interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
gateway: HermesGateway | null
|
||||
modelMenuContent?: React.ReactNode
|
||||
onToggleSelectedPin: () => void
|
||||
onDeleteSelectedSession: () => void
|
||||
onCancel: () => Promise<void> | void
|
||||
@@ -85,7 +80,6 @@ interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
|
||||
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
||||
onEdit: (message: AppendMessage) => Promise<void>
|
||||
onReload: (parentId: string | null) => Promise<void>
|
||||
onRestoreToMessage?: (messageId: string) => Promise<void>
|
||||
onTranscribeAudio?: (audio: Blob) => Promise<string>
|
||||
}
|
||||
|
||||
@@ -121,17 +115,17 @@ function ChatHeader({
|
||||
? pinnedSessionIds.includes(selectedSessionId)
|
||||
: false
|
||||
|
||||
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
|
||||
// are compact side panels — they drop the session-actions header + border
|
||||
// entirely. A brand-new draft has nothing to pin/delete/rename either.
|
||||
if (isSecondaryWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
|
||||
// A brand-new session has no session to pin/delete/rename, so the header is
|
||||
// just a dead "New session" label + chevron. Drop it (and its border)
|
||||
// entirely until there's a real session to act on.
|
||||
if (!selectedSessionId && !activeSessionId && !isRoutedSessionView) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div
|
||||
className={titlebarHeaderTitleClass}
|
||||
className="min-w-0 flex-1"
|
||||
style={{
|
||||
maxWidth:
|
||||
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
|
||||
@@ -147,7 +141,7 @@ function ChatHeader({
|
||||
title={title}
|
||||
>
|
||||
<Button
|
||||
className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 overflow-hidden border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
|
||||
className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
@@ -160,42 +154,104 @@ function ChatHeader({
|
||||
)
|
||||
}
|
||||
|
||||
interface ChatRuntimeBoundaryProps {
|
||||
busy: boolean
|
||||
children: React.ReactNode
|
||||
onCancel: () => Promise<void> | void
|
||||
onEdit: (message: AppendMessage) => Promise<void>
|
||||
onReload: (parentId: string | null) => Promise<void>
|
||||
onThreadMessagesChange: (messages: readonly ThreadMessage[]) => void
|
||||
/** Route points at an unloaded session — render empty until resume swaps in
|
||||
* the new transcript, so the previous session's messages don't linger. */
|
||||
suppressMessages: boolean
|
||||
}
|
||||
|
||||
const NO_MESSAGES: ChatMessage[] = []
|
||||
|
||||
/**
|
||||
* Owns the $messages subscription and the assistant-ui external-store runtime.
|
||||
*
|
||||
* Isolated from ChatView so the per-token delta flush (which replaces the
|
||||
* $messages atom ~30×/s during streaming) only re-renders this component and
|
||||
* the runtime provider. The children (Thread, ChatBar) are created by
|
||||
* ChatView, whose render output is stable across flushes — so React bails out
|
||||
* of re-rendering them by element identity and the stream's render cost stays
|
||||
* confined to the streaming message's own subtree.
|
||||
*/
|
||||
function ChatRuntimeBoundary({
|
||||
busy,
|
||||
children,
|
||||
export function ChatView({
|
||||
className,
|
||||
gateway,
|
||||
onToggleSelectedPin,
|
||||
onDeleteSelectedSession,
|
||||
onCancel,
|
||||
onAddContextRef,
|
||||
onAddUrl,
|
||||
onAttachImageBlob,
|
||||
onAttachDroppedItems,
|
||||
onBranchInNewChat,
|
||||
maxVoiceRecordingSeconds,
|
||||
onPasteClipboardImage,
|
||||
onPickFiles,
|
||||
onPickFolders,
|
||||
onPickImages,
|
||||
onRemoveAttachment,
|
||||
onSteer,
|
||||
onSubmit,
|
||||
onThreadMessagesChange,
|
||||
onEdit,
|
||||
onReload,
|
||||
onThreadMessagesChange,
|
||||
suppressMessages
|
||||
}: ChatRuntimeBoundaryProps) {
|
||||
const storeMessages = useStore($messages)
|
||||
const messages = suppressMessages ? NO_MESSAGES : storeMessages
|
||||
onTranscribeAudio
|
||||
}: ChatViewProps) {
|
||||
const location = useLocation()
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const awaitingResponse = useStore($awaitingResponse)
|
||||
const busy = useStore($busy)
|
||||
const contextSuggestions = useStore($contextSuggestions)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const freshDraftReady = useStore($freshDraftReady)
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const gatewaySwapTarget = useStore($gatewaySwapTarget)
|
||||
const gatewayOpen = gatewayState === 'open'
|
||||
const introPersonality = useStore($introPersonality)
|
||||
const introSeed = useStore($introSeed)
|
||||
const messages = useStore($messages)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
const runtimeMessageCacheRef = useRef(new WeakMap<ChatMessage, ThreadMessage>())
|
||||
const isRoutedSessionView = Boolean(routeSessionId(location.pathname))
|
||||
|
||||
const showIntro =
|
||||
freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messages.length === 0
|
||||
|
||||
// Session is still loading if the route references a session we haven't
|
||||
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
|
||||
// session exists — even if it has zero messages (a brand-new routed
|
||||
// session). The flicker where `busy` flips true briefly during hydrate
|
||||
// is handled by `threadLoadingState`'s last-visible-user gate.
|
||||
const loadingSession = isRoutedSessionView && messages.length === 0 && !activeSessionId
|
||||
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleMessageIsUser(messages))
|
||||
const showChatBar = !loadingSession
|
||||
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
|
||||
|
||||
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
|
||||
queryKey: ['model-options', activeSessionId || 'global'],
|
||||
queryFn: () => {
|
||||
if (!activeSessionId) {
|
||||
return getGlobalModelOptions()
|
||||
}
|
||||
|
||||
if (!gateway) {
|
||||
throw new Error('Hermes gateway unavailable')
|
||||
}
|
||||
|
||||
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
|
||||
},
|
||||
enabled: gatewayOpen
|
||||
})
|
||||
|
||||
const quickModels = useMemo(
|
||||
() => quickModelOptions(modelOptionsQuery.data, currentProvider, currentModel),
|
||||
[currentModel, currentProvider, modelOptionsQuery.data]
|
||||
)
|
||||
|
||||
const chatBarState = useMemo<ChatBarState>(
|
||||
() => ({
|
||||
model: {
|
||||
model: currentModel,
|
||||
provider: currentProvider,
|
||||
canSwitch: gatewayOpen,
|
||||
loading: !gatewayOpen || (!currentModel && !currentProvider),
|
||||
quickModels
|
||||
},
|
||||
tools: {
|
||||
enabled: true,
|
||||
label: 'Add context',
|
||||
suggestions: contextSuggestions
|
||||
},
|
||||
voice: {
|
||||
enabled: true,
|
||||
active: false
|
||||
}
|
||||
}),
|
||||
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
|
||||
)
|
||||
|
||||
const runtimeMessageRepository = useMemo(() => {
|
||||
const items: { message: ThreadMessage; parentId: string | null }[] = []
|
||||
@@ -245,125 +301,6 @@ function ChatRuntimeBoundary({
|
||||
onReload
|
||||
})
|
||||
|
||||
return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>
|
||||
}
|
||||
|
||||
export function ChatView({
|
||||
className,
|
||||
gateway,
|
||||
modelMenuContent,
|
||||
onToggleSelectedPin,
|
||||
onDeleteSelectedSession,
|
||||
onCancel,
|
||||
onAddContextRef,
|
||||
onAddUrl,
|
||||
onAttachImageBlob,
|
||||
onAttachDroppedItems,
|
||||
onBranchInNewChat,
|
||||
maxVoiceRecordingSeconds,
|
||||
onPasteClipboardImage,
|
||||
onPickFiles,
|
||||
onPickFolders,
|
||||
onPickImages,
|
||||
onRemoveAttachment,
|
||||
onSteer,
|
||||
onSubmit,
|
||||
onThreadMessagesChange,
|
||||
onEdit,
|
||||
onReload,
|
||||
onRestoreToMessage,
|
||||
onTranscribeAudio
|
||||
}: ChatViewProps) {
|
||||
const location = useLocation()
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const awaitingResponse = useStore($awaitingResponse)
|
||||
const busy = useStore($busy)
|
||||
const contextSuggestions = useStore($contextSuggestions)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const freshDraftReady = useStore($freshDraftReady)
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const gatewaySwapTarget = useStore($gatewaySwapTarget)
|
||||
const gatewayOpen = gatewayState === 'open'
|
||||
const introPersonality = useStore($introPersonality)
|
||||
const introSeed = useStore($introSeed)
|
||||
// PERF: ChatView must not subscribe to $messages — the atom is replaced on
|
||||
// every streaming delta flush (~30×/s) and a subscription here re-renders
|
||||
// the entire chat shell (header, chat bar, thread wrapper) per token. The
|
||||
// runtime that DOES need the messages lives in ChatRuntimeBoundary below;
|
||||
// this component only needs streaming-stable derivations.
|
||||
const messagesEmpty = useStore($messagesEmpty)
|
||||
const lastVisibleIsUser = useStore($lastVisibleMessageIsUser)
|
||||
const selectedSessionId = useStore($selectedStoredSessionId)
|
||||
const routedSessionId = routeSessionId(location.pathname)
|
||||
const isRoutedSessionView = Boolean(routedSessionId)
|
||||
|
||||
// The URL points at a session the store hasn't loaded yet (sidebar / cmd-K /
|
||||
// direct nav). Derived in render so the swap reads instantly: the same frame
|
||||
// the id changes we drop the old transcript and show the loader, instead of
|
||||
// waiting for the resume effect (which paints a frame later) to clear them.
|
||||
const routeSessionMismatch = isRoutedSessionView && routedSessionId !== selectedSessionId
|
||||
|
||||
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
|
||||
// scratch window, not the full-height empty state.
|
||||
const showIntro =
|
||||
!isSecondaryWindow() && freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
|
||||
|
||||
// Session is still loading if the route references a session we haven't
|
||||
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
|
||||
// session exists — even if it has zero messages (a brand-new routed
|
||||
// session). The flicker where `busy` flips true briefly during hydrate
|
||||
// is handled by `threadLoadingState`'s last-visible-user gate.
|
||||
const loadingSession = isRoutedSessionView && (routeSessionMismatch || (messagesEmpty && !activeSessionId))
|
||||
const threadLoading = threadLoadingState(loadingSession, busy, awaitingResponse, lastVisibleIsUser)
|
||||
const showChatBar = !loadingSession
|
||||
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
|
||||
|
||||
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
|
||||
queryKey: ['model-options', activeSessionId || 'global'],
|
||||
queryFn: () => {
|
||||
if (!activeSessionId) {
|
||||
return getGlobalModelOptions()
|
||||
}
|
||||
|
||||
if (!gateway) {
|
||||
throw new Error('Hermes gateway unavailable')
|
||||
}
|
||||
|
||||
return gateway.request<ModelOptionsResponse>('model.options', { session_id: activeSessionId })
|
||||
},
|
||||
enabled: gatewayOpen
|
||||
})
|
||||
|
||||
const quickModels = useMemo(
|
||||
() => quickModelOptions(modelOptionsQuery.data, currentProvider, currentModel),
|
||||
[currentModel, currentProvider, modelOptionsQuery.data]
|
||||
)
|
||||
|
||||
const chatBarState = useMemo<ChatBarState>(
|
||||
() => ({
|
||||
model: {
|
||||
model: currentModel,
|
||||
provider: currentProvider,
|
||||
canSwitch: gatewayOpen,
|
||||
loading: !gatewayOpen || (!currentModel && !currentProvider),
|
||||
modelMenuContent,
|
||||
quickModels
|
||||
},
|
||||
tools: {
|
||||
enabled: true,
|
||||
label: 'Add context',
|
||||
suggestions: contextSuggestions
|
||||
},
|
||||
voice: {
|
||||
enabled: true,
|
||||
active: false
|
||||
}
|
||||
}),
|
||||
[contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent, quickModels]
|
||||
)
|
||||
|
||||
// Drop files anywhere in the conversation area, not just on the composer
|
||||
// input. In-app drags (project tree / gutter) carry workspace-relative paths
|
||||
// the gateway resolves directly, so they stay inline `@file:` refs. OS/Finder
|
||||
@@ -416,14 +353,7 @@ export function ChatView({
|
||||
className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]"
|
||||
{...dropHandlers}
|
||||
>
|
||||
<ChatRuntimeBoundary
|
||||
busy={busy}
|
||||
onCancel={onCancel}
|
||||
onEdit={onEdit}
|
||||
onReload={onReload}
|
||||
onThreadMessagesChange={onThreadMessagesChange}
|
||||
suppressMessages={routeSessionMismatch}
|
||||
>
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread
|
||||
clampToComposer={showChatBar}
|
||||
cwd={currentCwd}
|
||||
@@ -432,7 +362,6 @@ export function ChatView({
|
||||
loading={threadLoading}
|
||||
onBranchInNewChat={onBranchInNewChat}
|
||||
onCancel={onCancel}
|
||||
onRestoreToMessage={onRestoreToMessage}
|
||||
sessionId={activeSessionId}
|
||||
sessionKey={threadKey}
|
||||
/>
|
||||
@@ -458,14 +387,13 @@ export function ChatView({
|
||||
onSteer={onSteer}
|
||||
onSubmit={onSubmit}
|
||||
onTranscribeAudio={onTranscribeAudio}
|
||||
queueSessionKey={selectedSessionId}
|
||||
queueSessionKey={selectedSessionId || activeSessionId}
|
||||
sessionId={activeSessionId}
|
||||
state={chatBarState}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</ChatRuntimeBoundary>
|
||||
{showChatBar && <ScrollToBottomButton />}
|
||||
</AssistantRuntimeProvider>
|
||||
<ChatDropOverlay kind={dragKind} />
|
||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||
</div>
|
||||
|
||||
@@ -10,16 +10,12 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import ShikiHighlighter from 'react-shiki'
|
||||
import { Streamdown } from 'streamdown'
|
||||
|
||||
import { requestComposerFocus, requestComposerInsertRefs } from '@/app/chat/composer/focus'
|
||||
import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs'
|
||||
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
|
||||
import { isAddSelectionShortcut } from '@/app/right-sidebar/terminal/selection'
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { translateNow, useI18n } from '@/i18n'
|
||||
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { PreviewTarget } from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
|
||||
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
|
||||
const TEXT_PREVIEW_MAX_BYTES = 512 * 1024
|
||||
@@ -291,7 +287,7 @@ const MARKDOWN_COMPONENTS = {
|
||||
|
||||
function MarkdownPreview({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground" data-selectable-text="true">
|
||||
<div className="preview-markdown mx-auto max-w-3xl px-4 py-3 text-sm text-foreground">
|
||||
<Streamdown components={MARKDOWN_COMPONENTS} controls={false} mode="static" parseIncompleteMarkdown={false}>
|
||||
{text}
|
||||
</Streamdown>
|
||||
@@ -361,38 +357,6 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
||||
startLineDrag(event, filePath, inSelection(line) && selection ? selection : { end: line, start: line })
|
||||
}
|
||||
|
||||
// ⌘/Ctrl+L with a line selection drops the same `@line:path:start-end` ref the
|
||||
// gutter drag produces — so the keyboard path mirrors dragging the lines into
|
||||
// the composer. Capture-phase + stopPropagation so it beats the terminal's
|
||||
// global ⌘L handler (which would otherwise grab the native text selection).
|
||||
useEffect(() => {
|
||||
if (!selection) {
|
||||
return
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isAddSelectionShortcut(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
const lineEnd = selection.end > selection.start ? selection.end : undefined
|
||||
const ref = droppedFileInlineRef({ line: selection.start, lineEnd, path: filePath }, $currentCwd.get())
|
||||
|
||||
if (!ref) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
requestComposerInsertRefs([ref])
|
||||
requestComposerFocus('main')
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true })
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [filePath, selection])
|
||||
|
||||
return (
|
||||
<div className="grid min-w-max grid-cols-[auto_minmax(0,1fr)] font-mono text-xs leading-relaxed">
|
||||
<div className="select-none py-3 text-right text-muted-foreground/55">
|
||||
@@ -419,10 +383,7 @@ function SourceView({ filePath, language, text }: { filePath: string; language:
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!"
|
||||
data-selectable-text="true"
|
||||
>
|
||||
<div className="relative [&_pre]:m-0 [&_pre]:px-3 [&_pre]:py-3 [&_pre]:bg-transparent!">
|
||||
{selection && (
|
||||
<div
|
||||
aria-hidden
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { clearAllPrompts, setApprovalRequest } from '@/store/prompts'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import { onScrollToBottomRequest, resetThreadScroll, setThreadAtBottom } from '@/store/thread-scroll'
|
||||
|
||||
import { ScrollToBottomButton } from './scroll-to-bottom-button'
|
||||
|
||||
function pendingApproval() {
|
||||
$activeSessionId.set('sess-1')
|
||||
setApprovalRequest({ command: 'rm -rf /tmp/x', description: 'dangerous command', sessionId: 'sess-1' })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearAllPrompts()
|
||||
resetThreadScroll()
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
// `getByRole('button')` excludes aria-hidden nodes, so "queryByRole null" is the
|
||||
// control's hidden (parked-at-bottom) state.
|
||||
describe('ScrollToBottomButton', () => {
|
||||
it('stays hidden while parked at the bottom', () => {
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('is a plain jump-to-bottom control when scrolled up with no approval', () => {
|
||||
setThreadAtBottom(false)
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Scroll to bottom' })).toBeTruthy()
|
||||
expect(screen.queryByText('Approval needed')).toBeNull()
|
||||
})
|
||||
|
||||
it('morphs into the approval pill when scrolled up with a pending approval', () => {
|
||||
pendingApproval()
|
||||
setThreadAtBottom(false)
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Approval needed' })).toBeTruthy()
|
||||
expect(screen.getByText('Approval needed')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not morph while a pending approval is still in view (at bottom)', () => {
|
||||
pendingApproval()
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
// Parked at bottom → control hidden, so it can't claim "approval needed".
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('re-arms sticky-bottom on click', () => {
|
||||
const handler = vi.fn()
|
||||
const stop = onScrollToBottomRequest(handler)
|
||||
setThreadAtBottom(false)
|
||||
render(<ScrollToBottomButton />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(1)
|
||||
stop()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user