mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 15:40:24 +08:00
Compare commits
242 Commits
fix/codex-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9901141d64 | ||
|
|
ca6542f602 | ||
|
|
fbaad3031a | ||
|
|
f48b312037 | ||
|
|
5e01a5dbf1 | ||
|
|
36ae958473 | ||
|
|
bd7fc8fdcd | ||
|
|
b7f0c9cd52 | ||
|
|
d1ecebcbfd | ||
|
|
db44af004c | ||
|
|
1b962f001e | ||
|
|
9137b86a52 | ||
|
|
7493de7fc3 | ||
|
|
1039e90b5e | ||
|
|
8ed16a7a0c | ||
|
|
3f80bcac56 | ||
|
|
01ae9b853e | ||
|
|
db01910e3a | ||
|
|
b7fa62c530 | ||
|
|
f4ef70f6fc | ||
|
|
bbc842d31e | ||
|
|
28f92478e3 | ||
|
|
e76e7b5073 | ||
|
|
8fa562a399 | ||
|
|
44e5848e74 | ||
|
|
6ebc449915 | ||
|
|
f6a42b1acf | ||
|
|
b2da39a0f3 | ||
|
|
17251e865b | ||
|
|
658ac1d866 | ||
|
|
c2c55c4443 | ||
|
|
e3adbb5ae9 | ||
|
|
e236bb87eb | ||
|
|
cf52370253 | ||
|
|
d7668aaff5 | ||
|
|
5094325140 | ||
|
|
c6e99ab375 | ||
|
|
80e4b8985e | ||
|
|
7d938cc5c9 | ||
|
|
cb6b4127e7 | ||
|
|
a68ac0c49a | ||
|
|
16fc717091 | ||
|
|
925b0d1ab5 | ||
|
|
e65d74bc6f | ||
|
|
4858942c55 | ||
|
|
4d470b3dbb | ||
|
|
2483200963 | ||
|
|
1ac76a9472 | ||
|
|
9a59ad73dd | ||
|
|
6373aba80f | ||
|
|
fc956b9db6 | ||
|
|
98ae28657f | ||
|
|
4cf9d80fba | ||
|
|
20b1f4f3fb | ||
|
|
a6364bfa08 | ||
|
|
5b3fa26366 | ||
|
|
ee7b8a4672 | ||
|
|
630b43892d | ||
|
|
dd0e3e0a05 | ||
|
|
a0ec4f52b9 | ||
|
|
0e81d2fb71 | ||
|
|
989d5d0cb7 | ||
|
|
c92a95a130 | ||
|
|
c6b0eb4de0 | ||
|
|
0441b7f19f | ||
|
|
7cd71de1f4 | ||
|
|
b1d6a57883 | ||
|
|
0b6b29a30c | ||
|
|
55cb4103be | ||
|
|
67233d1c2a | ||
|
|
0f75e9904a | ||
|
|
98c294126b | ||
|
|
0a8f3e21b8 | ||
|
|
2dbc3bd937 | ||
|
|
9d2ec8d35a | ||
|
|
423d24780b | ||
|
|
37d717054e | ||
|
|
1cb75b7971 | ||
|
|
5bfed0fe07 | ||
|
|
5a0e0d35b9 | ||
|
|
d2b34e89b0 | ||
|
|
6dde7d4657 | ||
|
|
c7513df4f9 | ||
|
|
062c17d34f | ||
|
|
e0492aa2dc | ||
|
|
cffd6e3c8d | ||
|
|
c66ecf0bc3 | ||
|
|
368fcf1ff0 | ||
|
|
39f479cba8 | ||
|
|
ed20f5ed06 | ||
|
|
2a08b8c86f | ||
|
|
b2a4766463 | ||
|
|
60cc42e38b | ||
|
|
9df1a1a8de | ||
|
|
c33e0457d7 | ||
|
|
f7c1cbe66f | ||
|
|
c23a2eec15 | ||
|
|
f3b32e9f52 | ||
|
|
5f6be7f31b | ||
|
|
0bbf325a8f | ||
|
|
0bbff1fc7e | ||
|
|
ae433634db | ||
|
|
9eb0bcd60f | ||
|
|
45e2f4fdcd | ||
|
|
30377e108c | ||
|
|
f02484feba | ||
|
|
eae3836eb6 | ||
|
|
3e7e9b24d4 | ||
|
|
ead38107a2 | ||
|
|
5035fa9029 | ||
|
|
5b2604df99 | ||
|
|
2f2e3616b4 | ||
|
|
bee13817f0 | ||
|
|
fbabf438a1 | ||
|
|
49e743985a | ||
|
|
ba3883cd18 | ||
|
|
be7c919bf9 | ||
|
|
733472952a | ||
|
|
e5b4cf7bea | ||
|
|
29c6985590 | ||
|
|
92a456f711 | ||
|
|
975b9f0a54 | ||
|
|
0d82060c74 | ||
|
|
ea49a79633 | ||
|
|
c17469cb19 | ||
|
|
febdddb41a | ||
|
|
aab2e99bae | ||
|
|
ad58dd51ac | ||
|
|
a688d2a1bd | ||
|
|
40699c3292 | ||
|
|
c1a70a5439 | ||
|
|
2cddc9c895 | ||
|
|
f79b109f4f | ||
|
|
ec05d2bc3e | ||
|
|
a376ca0081 | ||
|
|
8844e091c1 | ||
|
|
1227007aed | ||
|
|
497352bc4e | ||
|
|
f1d6f04362 | ||
|
|
dcc3216955 | ||
|
|
aca11c227e | ||
|
|
6cb88a0874 | ||
|
|
8fce54499f | ||
|
|
b0c99c12dd | ||
|
|
ddf7c7af81 | ||
|
|
d6a8d9dcab | ||
|
|
95715dcb03 | ||
|
|
80f8ffc74c | ||
|
|
c2b7669ad3 | ||
|
|
b770967263 | ||
|
|
61ee2dbfdb | ||
|
|
f795513782 | ||
|
|
8fe334b056 | ||
|
|
40d7c264f0 | ||
|
|
4eb0ff639b | ||
|
|
f3fe99863d | ||
|
|
a829e04d62 | ||
|
|
2a14e8957d | ||
|
|
bff78a34dc | ||
|
|
4e6d05c6a5 | ||
|
|
a1f51feb72 | ||
|
|
6c34088a17 | ||
|
|
fc2b8b3d31 | ||
|
|
3bc4a2ff78 | ||
|
|
ce19fdb7ce | ||
|
|
7f245b0035 | ||
|
|
7bbe7024c2 | ||
|
|
7433d5f0eb | ||
|
|
1436793051 | ||
|
|
08d89e7aba | ||
|
|
2c174bce24 | ||
|
|
5191c1c2ce | ||
|
|
0f3670ba79 | ||
|
|
288f7026e3 | ||
|
|
efbe1635dd | ||
|
|
a27d7e68cc | ||
|
|
13a1bd0f83 | ||
|
|
0e22bf6439 | ||
|
|
972a9885ee | ||
|
|
9459057d7f | ||
|
|
cf7d5932f8 | ||
|
|
04d4471d79 | ||
|
|
1db8f7ea80 | ||
|
|
5105c3651a | ||
|
|
293c04fef6 | ||
|
|
98205da008 | ||
|
|
a4ee1f223d | ||
|
|
10bad2faf1 | ||
|
|
2b4873f7fb | ||
|
|
723c2331bd | ||
|
|
b00060ce54 | ||
|
|
0428945b5b | ||
|
|
8f4a718f95 | ||
|
|
5e851bc6bc | ||
|
|
afc8615509 | ||
|
|
89bdb1e546 | ||
|
|
7b9dc7cd0a | ||
|
|
d76a58bd15 | ||
|
|
1f5eef8093 | ||
|
|
9f33d673e9 | ||
|
|
d842155da1 | ||
|
|
4936a49a0c | ||
|
|
85e6232a07 | ||
|
|
81e42335a1 | ||
|
|
526a1e24b5 | ||
|
|
1eb13744b4 | ||
|
|
49dd91d682 | ||
|
|
715b691723 | ||
|
|
9cbb91abd3 | ||
|
|
c8ad2ca997 | ||
|
|
10bd01972b | ||
|
|
12c84d6c77 | ||
|
|
ab26541b9a | ||
|
|
bb46bf8ce4 | ||
|
|
4b5ba112ad | ||
|
|
cdf30a7ac6 | ||
|
|
b0288ae9b6 | ||
|
|
630a4ef03c | ||
|
|
b4ba3f5e3b | ||
|
|
8f278403d1 | ||
|
|
1b16c48170 | ||
|
|
e986e3fc68 | ||
|
|
12682d96b9 | ||
|
|
8d5d36d793 | ||
|
|
7aaae7acd0 | ||
|
|
73d1357747 | ||
|
|
af1995a838 | ||
|
|
dc90ca4e17 | ||
|
|
af5b526472 | ||
|
|
b42c5bf652 | ||
|
|
a218a0f156 | ||
|
|
1106879147 | ||
|
|
6b76284c77 | ||
|
|
4026f526d5 | ||
|
|
9a2b976326 | ||
|
|
3581131e7d | ||
|
|
1544813bfe | ||
|
|
2708c33c75 | ||
|
|
23a7458acf | ||
|
|
99feb03607 | ||
|
|
d7dfeed6dc | ||
|
|
bb5cb32838 |
5
.github/workflows/contributor-check.yml
vendored
5
.github/workflows/contributor-check.yml
vendored
@@ -1,12 +1,11 @@
|
||||
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
|
||||
|
||||
|
||||
9
.github/workflows/docker-lint.yml
vendored
9
.github/workflows/docker-lint.yml
vendored
@@ -18,13 +18,12 @@ 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
|
||||
|
||||
13
.github/workflows/docker-publish.yml
vendored
13
.github/workflows/docker-publish.yml
vendored
@@ -11,16 +11,13 @@ 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]
|
||||
|
||||
|
||||
16
.github/workflows/docs-site-checks.yml
vendored
16
.github/workflows/docs-site-checks.yml
vendored
@@ -1,10 +1,12 @@
|
||||
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:
|
||||
paths:
|
||||
- 'website/**'
|
||||
- '.github/workflows/docs-site-checks.yml'
|
||||
branches: [main]
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -14,9 +16,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
|
||||
@@ -26,9 +28,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
|
||||
|
||||
7
.github/workflows/history-check.yml
vendored
7
.github/workflows/history-check.yml
vendored
@@ -14,6 +14,9 @@ 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]
|
||||
|
||||
@@ -24,9 +27,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,7 +154,6 @@ 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
255
.github/workflows/nix-lockfile-fix.yml
vendored
@@ -1,255 +0,0 @@
|
||||
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
105
.github/workflows/nix.yml
vendored
@@ -1,105 +0,0 @@
|
||||
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,29 +20,23 @@ 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:
|
||||
@@ -54,7 +48,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.
|
||||
|
||||
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,8 +29,10 @@ 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
|
||||
@@ -54,6 +56,14 @@ 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
|
||||
@@ -62,7 +72,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
|
||||
|
||||
@@ -197,7 +207,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
|
||||
|
||||
@@ -268,3 +278,50 @@ 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."
|
||||
|
||||
8
.github/workflows/tests.yml
vendored
8
.github/workflows/tests.yml
vendored
@@ -6,11 +6,11 @@ on:
|
||||
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).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -219,4 +219,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,6 +4,9 @@ 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]
|
||||
|
||||
@@ -23,3 +26,20 @@ 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'
|
||||
- "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).
|
||||
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.
|
||||
|
||||
@@ -78,7 +78,41 @@ 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) |
|
||||
|
||||
### Clone and install
|
||||
### 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.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
@@ -109,15 +143,19 @@ echo "OPENROUTER_API_KEY=***" >> ~/.hermes/.env
|
||||
### Run
|
||||
|
||||
```bash
|
||||
# Symlink for global access
|
||||
mkdir -p ~/.local/bin
|
||||
ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
|
||||
|
||||
# Verify
|
||||
# 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
|
||||
mkdir -p ~/.local/bin
|
||||
ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
|
||||
```
|
||||
|
||||
### Run tests
|
||||
|
||||
```bash
|
||||
|
||||
16
README.md
16
README.md
@@ -181,16 +181,20 @@ 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 — clone and go with `setup-hermes.sh`:
|
||||
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.
|
||||
|
||||
```bash
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
Manual path (equivalent to the above):
|
||||
Manual clone fallback (for throwaway clones/CI where you intentionally do not
|
||||
want the managed install layout):
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
@@ -164,16 +164,18 @@ hermes claw migrate --overwrite # 覆盖已有冲突
|
||||
|
||||
欢迎贡献!请参阅 [贡献指南](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) 了解开发设置、代码风格和 PR 流程。
|
||||
|
||||
贡献者快速开始——克隆并使用 `setup-hermes.sh`:
|
||||
贡献者快速开始——使用标准安装器,然后在它创建的完整 git checkout 中开发:
|
||||
`$HERMES_HOME/hermes-agent`(通常是 `~/.hermes/hermes-agent`)。这会匹配
|
||||
`hermes update`、托管 venv、lazy dependencies、gateway 和 docs tooling 使用的布局。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
cd hermes-agent
|
||||
./setup-hermes.sh # 安装 uv、创建 venv、安装 .[all]、创建符号链接 ~/.local/bin/hermes
|
||||
./hermes # 自动检测 venv,无需先 source
|
||||
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
|
||||
```
|
||||
|
||||
手动安装(等效于上述命令):
|
||||
手动克隆备用路径(用于一次性 clone / CI,或你明确不想使用 managed install layout 时):
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
@@ -27,7 +27,7 @@ import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from urllib.parse import urlparse, parse_qs, urlunparse
|
||||
|
||||
from agent.context_compressor import ContextCompressor
|
||||
@@ -195,6 +195,7 @@ 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,
|
||||
@@ -299,6 +300,7 @@ 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
|
||||
@@ -425,6 +427,7 @@ 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
|
||||
|
||||
|
||||
@@ -596,6 +599,7 @@ 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
|
||||
@@ -900,6 +904,9 @@ 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}")
|
||||
|
||||
@@ -1217,12 +1217,23 @@ 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"
|
||||
atomic_json_write(dump_file, dump_payload, default=str)
|
||||
|
||||
# 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)
|
||||
|
||||
agent._vprint(f"{agent.log_prefix}🧾 Request debug dump written to: {dump_file}")
|
||||
|
||||
if env_var_enabled("HERMES_DUMP_REQUEST_STDOUT"):
|
||||
print(json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str))
|
||||
print(json.dumps(_redacted_payload, ensure_ascii=False, indent=2, default=str))
|
||||
|
||||
return dump_file
|
||||
except Exception as dump_error:
|
||||
|
||||
@@ -751,6 +751,9 @@ 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),
|
||||
|
||||
@@ -1144,7 +1144,8 @@ def _endpoint_speaks_anthropic_messages(base_url: str) -> bool:
|
||||
normalized = (base_url or "").strip().lower().rstrip("/")
|
||||
if not normalized:
|
||||
return False
|
||||
if normalized.endswith("/anthropic"):
|
||||
path = urlparse(normalized).path.rstrip("/")
|
||||
if path.endswith("/anthropic") or path.endswith("/anthropic/v1"):
|
||||
return True
|
||||
hostname = base_url_hostname(normalized)
|
||||
if hostname == "api.anthropic.com":
|
||||
@@ -3078,23 +3079,20 @@ 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 = _resolve_single_provider(
|
||||
fb_provider, fb_model, fb_base_url, fb_api_key)
|
||||
fb_client, resolved_model = _resolve_fallback_entry(entry)
|
||||
except Exception:
|
||||
fb_client = None
|
||||
fb_client, resolved_model = None, None
|
||||
|
||||
if fb_client is not None:
|
||||
logger.info(
|
||||
"Auxiliary %s: %s on %s — configured fallback to %s (%s)",
|
||||
task, reason, failed_provider, label, fb_model or "default",
|
||||
task, reason, failed_provider, label, resolved_model or fb_model or "default",
|
||||
)
|
||||
return fb_client, fb_model, label
|
||||
return fb_client, resolved_model or fb_model, label
|
||||
tried.append(label)
|
||||
|
||||
if tried:
|
||||
@@ -3105,6 +3103,103 @@ 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,
|
||||
@@ -3115,16 +3210,19 @@ 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,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
explicit_base_url=base_url,
|
||||
explicit_api_key=api_key,
|
||||
)
|
||||
return client
|
||||
|
||||
def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
def _resolve_auto(
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
task: Optional[str] = None,
|
||||
) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
"""Full auto-detection chain.
|
||||
|
||||
Priority:
|
||||
@@ -3222,7 +3320,22 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
|
||||
main_provider, resolved or main_model)
|
||||
return client, resolved or main_model
|
||||
|
||||
# ── Step 2: aggregator / fallback chain ──────────────────────────────
|
||||
# ── 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 ──────────────────────────────
|
||||
tried = []
|
||||
for label, try_fn in _get_provider_chain():
|
||||
if _is_provider_unhealthy(label):
|
||||
@@ -3343,6 +3456,7 @@ 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.
|
||||
@@ -3463,7 +3577,7 @@ def resolve_provider_client(
|
||||
|
||||
# ── Auto: try all providers in priority order ────────────────────
|
||||
if provider == "auto":
|
||||
client, resolved = _resolve_auto(main_runtime=main_runtime)
|
||||
client, resolved = _resolve_auto(main_runtime=main_runtime, task=task)
|
||||
if client is None:
|
||||
return None, None
|
||||
# When auto-detection lands on a non-OpenRouter provider (e.g. a
|
||||
@@ -4356,11 +4470,16 @@ 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, pool_hint)
|
||||
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, task_key, pool_hint)
|
||||
|
||||
|
||||
def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:
|
||||
@@ -4553,6 +4672,7 @@ 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.
|
||||
|
||||
@@ -4590,6 +4710,7 @@ 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:
|
||||
@@ -4634,6 +4755,7 @@ 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
|
||||
@@ -5139,7 +5261,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)
|
||||
client, final_model = _get_cached_client("auto", main_runtime=main_runtime, task=task)
|
||||
if client is None:
|
||||
raise RuntimeError(
|
||||
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
||||
@@ -5465,14 +5587,19 @@ def call_llm(
|
||||
|
||||
# Fallback order (#26882, #26803):
|
||||
# 1. User-configured fallback_chain (per-task) if set
|
||||
# 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.
|
||||
# 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
|
||||
fb_client, fb_model, fb_label = (None, None, "")
|
||||
if is_auto:
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task, reason=reason)
|
||||
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)
|
||||
else:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
@@ -5635,7 +5762,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)
|
||||
client, final_model = _get_cached_client("auto", async_mode=True, main_runtime=main_runtime, task=task)
|
||||
if client is None:
|
||||
raise RuntimeError(
|
||||
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
||||
@@ -5903,13 +6030,19 @@ async def async_call_llm(
|
||||
|
||||
# Fallback order (#26882, #26803):
|
||||
# 1. User-configured fallback_chain (per-task) if set
|
||||
# 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.
|
||||
# 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
|
||||
fb_client, fb_model, fb_label = (None, None, "")
|
||||
if is_auto:
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task, reason=reason)
|
||||
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)
|
||||
else:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
|
||||
@@ -237,18 +237,25 @@ _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 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).
|
||||
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).
|
||||
|
||||
Matching is by ``tool_call_id`` when available, with a content-equality
|
||||
fallback for tool messages that lack one.
|
||||
``notification_mode`` controls display detail:
|
||||
- ``off``: return no actions.
|
||||
- ``on``: generic "Memory updated"/tool messages.
|
||||
- ``verbose``: include compact content previews from tool-call arguments.
|
||||
"""
|
||||
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 []:
|
||||
@@ -262,6 +269,42 @@ 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":
|
||||
@@ -273,6 +316,8 @@ 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):
|
||||
@@ -280,19 +325,75 @@ def summarize_background_review_actions(
|
||||
if not isinstance(data, dict) or not data.get("success"):
|
||||
continue
|
||||
message = data.get("message", "")
|
||||
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():
|
||||
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:
|
||||
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
|
||||
|
||||
@@ -522,6 +623,7 @@ def _run_review_in_thread(
|
||||
actions = summarize_background_review_actions(
|
||||
review_messages,
|
||||
messages_snapshot,
|
||||
notification_mode=getattr(agent, "memory_notifications", "on"),
|
||||
)
|
||||
|
||||
if actions:
|
||||
|
||||
@@ -58,17 +58,34 @@ _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."""
|
||||
"""Import boto3, raising a clear error if not installed or too old."""
|
||||
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):
|
||||
|
||||
@@ -70,21 +70,6 @@ _TOOL_CALL_LEAK_PATTERN = re.compile(
|
||||
r"(?:^|[\s>|])to=functions\.[A-Za-z_][\w.]*",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TOOL_CALL_LEAK_SCAN_LIMIT = 8192
|
||||
|
||||
|
||||
def _scan_for_leaked_tool_call(text: str) -> bool:
|
||||
"""Return True if Codex leaked a Harmony tool-call marker near the start.
|
||||
|
||||
Real leaked tool-call serializations begin with the Harmony marker. Bound
|
||||
the regex to a prefix window so multi-megabyte successful assistant output
|
||||
never spends unbounded GIL time proving it does not contain a leak.
|
||||
"""
|
||||
return bool(
|
||||
_TOOL_CALL_LEAK_PATTERN.search(
|
||||
text[:_TOOL_CALL_LEAK_SCAN_LIMIT + 64]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1242,7 +1227,7 @@ def _normalize_codex_response(
|
||||
# ``function_call`` item. The existing loop already handles message
|
||||
# append, dedup, and retry budget.
|
||||
leaked_tool_call_text = False
|
||||
if final_text and not tool_calls and _scan_for_leaked_tool_call(final_text):
|
||||
if final_text and not tool_calls and _TOOL_CALL_LEAK_PATTERN.search(final_text):
|
||||
leaked_tool_call_text = True
|
||||
logger.warning(
|
||||
"Codex response contains leaked tool-call text in assistant content "
|
||||
|
||||
@@ -40,6 +40,16 @@ 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.
|
||||
@@ -324,9 +334,7 @@ def compress_context(
|
||||
f"{approx_tokens:,}" if approx_tokens else "unknown", agent.model,
|
||||
focus_topic,
|
||||
)
|
||||
agent._emit_status(
|
||||
"🗜️ Compacting context — summarizing earlier conversation so I can continue..."
|
||||
)
|
||||
agent._emit_status(COMPACTION_STATUS)
|
||||
|
||||
# ── Compression lock ────────────────────────────────────────────────
|
||||
# Atomic, state.db-backed lock per session_id. Without this, two
|
||||
@@ -595,6 +603,20 @@ 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.
|
||||
@@ -631,7 +653,11 @@ def compress_context(
|
||||
return compressed, new_system_prompt
|
||||
|
||||
|
||||
def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
def try_shrink_image_parts_in_messages(
|
||||
api_messages: list,
|
||||
*,
|
||||
max_dimension: int = 8000,
|
||||
) -> bool:
|
||||
"""Re-encode all native image parts at a smaller size to recover from
|
||||
image-too-large errors (Anthropic 5 MB, unknown other providers).
|
||||
|
||||
@@ -642,7 +668,8 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
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), write the base64 to a tempfile, call
|
||||
ceiling with header overhead) or whose longest side exceeds
|
||||
``max_dimension``, write the base64 to a tempfile, call
|
||||
``vision_tools._resize_image_for_vision`` to produce a smaller data
|
||||
URL, and substitute it in place.
|
||||
|
||||
@@ -664,10 +691,9 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
# 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. 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
|
||||
# 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.
|
||||
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
|
||||
@@ -684,9 +710,9 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
# 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
|
||||
# Anthropic's 8000px cap. A tall image can be tiny in bytes
|
||||
# yet huge in pixels.
|
||||
# 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.
|
||||
try:
|
||||
import base64 as _b64_dim
|
||||
header_d, _, data_d = url.partition(",")
|
||||
@@ -795,6 +821,8 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"COMPACTION_STATUS",
|
||||
"COMPACTION_STATUS_MARKER",
|
||||
"check_compression_model_feasibility",
|
||||
"replay_compression_warning",
|
||||
"compress_context",
|
||||
|
||||
@@ -71,6 +71,35 @@ 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):
|
||||
@@ -271,11 +300,20 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history)
|
||||
agent.session_id, exc,
|
||||
)
|
||||
|
||||
if stored_prompt:
|
||||
if stored_prompt and _stored_prompt_matches_runtime(agent, 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
|
||||
@@ -337,6 +375,30 @@ 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])
|
||||
@@ -368,6 +430,42 @@ 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,
|
||||
@@ -376,6 +474,7 @@ 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.
|
||||
@@ -391,6 +490,8 @@ 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:
|
||||
@@ -412,6 +513,7 @@ 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,
|
||||
@@ -1319,6 +1421,106 @@ 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(
|
||||
@@ -2070,7 +2272,11 @@ def run_conversation(
|
||||
and not _retry.image_shrink_retry_attempted
|
||||
):
|
||||
_retry.image_shrink_retry_attempted = True
|
||||
if agent._try_shrink_image_parts_in_messages(api_messages):
|
||||
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,
|
||||
):
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}📐 Image(s) exceeded provider size limit — "
|
||||
f"shrank and retrying...",
|
||||
@@ -3086,20 +3292,17 @@ def run_conversation(
|
||||
if classified.reason == FailoverReason.content_policy_blocked:
|
||||
_summary = agent._summarize_api_error(api_error)
|
||||
_policy_response = (
|
||||
f"⚠️ The model provider's safety filter blocked this request "
|
||||
f"(not a Hermes/gateway failure).\n\n"
|
||||
"⚠️ The model provider's safety filter blocked this request "
|
||||
"(not a Hermes/gateway failure).\n\n"
|
||||
f"Provider message: {_summary}\n\n"
|
||||
f"Try rephrasing the request, narrowing the context, or "
|
||||
f"adding a fallback provider with `hermes fallback add`."
|
||||
f"{_CONTENT_POLICY_RECOVERY_HINT}"
|
||||
)
|
||||
return _content_policy_blocked_result(
|
||||
messages,
|
||||
api_call_count,
|
||||
final_response=_policy_response,
|
||||
error_detail=_summary,
|
||||
)
|
||||
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,16 +70,6 @@ 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
|
||||
@@ -105,7 +95,10 @@ def _resolve_home_dir() -> str:
|
||||
|
||||
def _build_subprocess_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
env["HOME"] = _resolve_home_dir()
|
||||
home = _resolve_home_dir()
|
||||
env["HOME"] = home
|
||||
from hermes_constants import apply_subprocess_home_env
|
||||
apply_subprocess_home_env(env)
|
||||
return env
|
||||
|
||||
|
||||
|
||||
@@ -454,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 lock.
|
||||
# Load and rewrite the live jobs under the scheduler's cross-process lock.
|
||||
try:
|
||||
from cron.jobs import load_jobs, save_jobs, _jobs_file_lock
|
||||
from cron.jobs import load_jobs, save_jobs, _jobs_lock
|
||||
except ImportError as e:
|
||||
report["error"] = f"cron module unavailable: {e}"
|
||||
return report
|
||||
|
||||
report["attempted"] = True
|
||||
try:
|
||||
with _jobs_file_lock:
|
||||
with _jobs_lock():
|
||||
live_jobs = load_jobs()
|
||||
changed = False
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
@@ -168,6 +169,27 @@ 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.
|
||||
|
||||
@@ -191,6 +213,22 @@ 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", "")
|
||||
@@ -858,20 +896,6 @@ 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:
|
||||
@@ -909,17 +933,15 @@ def get_cute_tool_message(
|
||||
return f"{line}{failure_suffix}"
|
||||
|
||||
if tool_name == "web_search":
|
||||
verb = "Parallel search" if _used_free_parallel(result) else "search"
|
||||
return _wrap(f"┊ 🔍 {verb:<9} {_trunc(args.get('query', ''), 42)} {dur}")
|
||||
return _wrap(f"┊ 🔍 search {_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"┊ 📄 {verb:<9} {_trunc(domain, 35)}{extra} {dur}")
|
||||
return _wrap(f"┊ 📄 {verb:<9} pages {dur}")
|
||||
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
|
||||
return _wrap(f"┊ 📄 fetch pages {dur}")
|
||||
if tool_name == "terminal":
|
||||
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
|
||||
if tool_name == "process":
|
||||
@@ -1035,7 +1057,10 @@ def get_cute_tool_message(
|
||||
if tool_name == "delegate_task":
|
||||
tasks = args.get("tasks")
|
||||
if tasks and isinstance(tasks, list):
|
||||
return _wrap(f"┊ 🔀 delegate {len(tasks)} parallel tasks {dur}")
|
||||
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 {_trunc(args.get('goal', ''), 35)} {dur}")
|
||||
|
||||
preview = build_tool_preview(tool_name, args) or ""
|
||||
|
||||
3
agent/errors.py
Normal file
3
agent/errors.py
Normal file
@@ -0,0 +1,3 @@
|
||||
class SSLConfigurationError(Exception):
|
||||
"""Raised when SSL/TLS certificate bundle configuration fails."""
|
||||
pass
|
||||
@@ -46,11 +46,6 @@ 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"),
|
||||
@@ -104,12 +99,6 @@ 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 = []
|
||||
@@ -122,12 +111,6 @@ 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):
|
||||
|
||||
@@ -33,6 +33,7 @@ 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__)
|
||||
@@ -430,16 +431,37 @@ 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(query, session_id=session_id)
|
||||
result = provider.prefetch(clean_query, session_id=session_id)
|
||||
if result and result.strip():
|
||||
parts.append(result)
|
||||
except Exception as e:
|
||||
@@ -460,10 +482,14 @@ 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(query, session_id=session_id)
|
||||
provider.queue_prefetch(clean_query, session_id=session_id)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Memory provider '%s' queue_prefetch failed (non-fatal): %s",
|
||||
@@ -515,6 +541,11 @@ 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,6 +5,7 @@ and run_agent.py for pre-flight context checks.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@@ -16,7 +17,7 @@ from urllib.parse import urlparse
|
||||
import requests
|
||||
import yaml
|
||||
|
||||
from utils import base_url_host_matches, base_url_hostname
|
||||
from utils import atomic_json_write, base_url_host_matches, base_url_hostname
|
||||
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
|
||||
@@ -111,6 +112,57 @@ _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
|
||||
@@ -209,7 +261,13 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
# https://platform.minimax.io/docs/api-reference/text-chat-openai
|
||||
"minimax-m3": 1000000,
|
||||
"minimax": 204800,
|
||||
# GLM
|
||||
# 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": 202752,
|
||||
# xAI Grok — xAI /v1/models does not return context_length metadata,
|
||||
# so these hardcoded fallbacks prevent Hermes from probing-down to
|
||||
@@ -627,6 +685,15 @@ 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()
|
||||
@@ -648,12 +715,24 @@ 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}")
|
||||
return _model_metadata_cache or {}
|
||||
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 {}
|
||||
|
||||
|
||||
def fetch_endpoint_model_metadata(
|
||||
|
||||
@@ -8,6 +8,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import contextvars
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
|
||||
@@ -958,6 +959,52 @@ CONTEXT_TRUNCATE_HEAD_RATIO = 0.7
|
||||
CONTEXT_TRUNCATE_TAIL_RATIO = 0.2
|
||||
|
||||
|
||||
def _get_context_file_max_chars() -> int:
|
||||
"""Return the configured context-file truncation limit.
|
||||
|
||||
``CONTEXT_FILE_MAX_CHARS`` remains the upstream-compatible default and
|
||||
fallback. Users with larger context windows can raise
|
||||
``context_file_max_chars`` in config.yaml without patching Hermes.
|
||||
"""
|
||||
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 CONTEXT_FILE_MAX_CHARS
|
||||
|
||||
# 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
|
||||
# =========================================================================
|
||||
@@ -1164,7 +1211,7 @@ def build_skills_system_prompt(
|
||||
or get_session_env("HERMES_SESSION_PLATFORM")
|
||||
or ""
|
||||
)
|
||||
disabled = get_disabled_skill_names()
|
||||
disabled = get_disabled_skill_names(_platform_hint or None)
|
||||
cache_key = (
|
||||
str(skills_dir.resolve()),
|
||||
tuple(str(d) for d in external_dirs),
|
||||
@@ -1463,10 +1510,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: int = CONTEXT_FILE_MAX_CHARS) -> str:
|
||||
def _truncate_content(content: str, filename: str, max_chars: Optional[int] = None) -> str:
|
||||
"""Head/tail truncation with a marker in the middle."""
|
||||
if max_chars is None:
|
||||
max_chars = _get_context_file_max_chars()
|
||||
if len(content) <= max_chars:
|
||||
return content
|
||||
msg = (
|
||||
f"⚠️ Context file {filename} TRUNCATED: "
|
||||
f"{len(content)} chars exceeds limit of {max_chars} — "
|
||||
f"increase context_file_max_chars or trim the file!"
|
||||
)
|
||||
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]
|
||||
|
||||
@@ -104,6 +104,7 @@ _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,6 +26,91 @@ _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,14 +43,20 @@ 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 any component of *path* is in EXCLUDED_SKILL_DIRS.
|
||||
"""True if *path* should be skipped by active skill scanners.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Accepts a Path or string.
|
||||
"""
|
||||
@@ -59,7 +65,36 @@ 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)
|
||||
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
|
||||
|
||||
|
||||
# ── Lazy YAML loader ─────────────────────────────────────────────────────
|
||||
@@ -272,27 +307,65 @@ 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. Falls back to the
|
||||
global disabled list when no platform is determined.
|
||||
``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).
|
||||
|
||||
Reads the config file directly (no CLI config imports) to stay
|
||||
lightweight.
|
||||
"""
|
||||
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):
|
||||
parsed = _load_raw_config()
|
||||
if not parsed:
|
||||
return set()
|
||||
|
||||
skills_cfg = parsed.get("skills")
|
||||
@@ -305,13 +378,14 @@ 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 _normalize_string_set(platform_disabled)
|
||||
return _normalize_string_set(skills_cfg.get("disabled"))
|
||||
return global_disabled | _normalize_string_set(platform_disabled)
|
||||
return global_disabled
|
||||
|
||||
|
||||
def _normalize_string_set(values) -> Set[str]:
|
||||
@@ -336,6 +410,7 @@ _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]:
|
||||
@@ -368,11 +443,8 @@ def get_external_skills_dirs() -> List[Path]:
|
||||
# Return a copy so callers can't mutate the cached list.
|
||||
return list(cached)
|
||||
|
||||
try:
|
||||
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = _load_raw_config()
|
||||
if not parsed:
|
||||
return []
|
||||
|
||||
skills_cfg = parsed.get("skills")
|
||||
@@ -584,15 +656,7 @@ 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_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
|
||||
config = _load_raw_config()
|
||||
|
||||
resolved: Dict[str, Any] = {}
|
||||
for var in config_vars:
|
||||
@@ -632,12 +696,21 @@ 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, and cache
|
||||
directories so dependencies cannot register nested skills.
|
||||
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.
|
||||
"""
|
||||
matches = []
|
||||
for root, dirs, files in os.walk(skills_dir, followlinks=True):
|
||||
dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS]
|
||||
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)
|
||||
]
|
||||
if filename in files:
|
||||
matches.append(Path(root) / filename)
|
||||
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
|
||||
|
||||
94
agent/ssl_guard.py
Normal file
94
agent/ssl_guard.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""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,6 +40,7 @@ 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
|
||||
|
||||
@@ -400,7 +401,14 @@ 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)
|
||||
return "\n\n".join(p for p in (parts["stable"], parts["context"], parts["volatile"]) if p)
|
||||
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
|
||||
|
||||
|
||||
def invalidate_system_prompt(agent: Any) -> None:
|
||||
|
||||
@@ -186,10 +186,21 @@ class AnthropicTransport(ProviderTransport):
|
||||
def validate_response(self, response: Any) -> bool:
|
||||
"""Check Anthropic response structure is valid.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
if response is None:
|
||||
return False
|
||||
@@ -197,7 +208,7 @@ class AnthropicTransport(ProviderTransport):
|
||||
if not isinstance(content_blocks, list):
|
||||
return False
|
||||
if not content_blocks:
|
||||
return getattr(response, "stop_reason", None) == "end_turn"
|
||||
return getattr(response, "stop_reason", None) in {"end_turn", "refusal"}
|
||||
return True
|
||||
|
||||
def extract_cache_stats(self, response: Any) -> Optional[Dict[str, int]]:
|
||||
|
||||
@@ -531,6 +531,7 @@ 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"),
|
||||
)
|
||||
@@ -664,8 +665,42 @@ 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=msg.content,
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
reasoning=reasoning,
|
||||
|
||||
@@ -69,6 +69,7 @@ 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,
|
||||
@@ -121,6 +122,7 @@ 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.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@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": "^5.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2023", "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/desktop).
|
||||
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -67,6 +67,16 @@ function buildDesktopBackendPath({
|
||||
)
|
||||
}
|
||||
|
||||
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 = [],
|
||||
@@ -97,5 +107,6 @@ module.exports = {
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
delimiterForPlatform,
|
||||
normalizeHermesHomeRoot,
|
||||
pathEnvKey
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
appendUniquePathEntries,
|
||||
buildDesktopBackendEnv,
|
||||
buildDesktopBackendPath,
|
||||
normalizeHermesHomeRoot,
|
||||
pathEnvKey
|
||||
} = require('./backend-env.cjs')
|
||||
|
||||
@@ -66,6 +67,21 @@ test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () =
|
||||
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',
|
||||
|
||||
@@ -166,6 +166,39 @@ 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 || '')
|
||||
|
||||
@@ -247,6 +280,7 @@ module.exports = {
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
|
||||
@@ -24,6 +24,7 @@ const {
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
@@ -90,6 +91,72 @@ 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', () => {
|
||||
|
||||
@@ -38,7 +38,8 @@ 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 } = require('./backend-env.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')
|
||||
@@ -62,6 +63,7 @@ const {
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
@@ -240,8 +242,18 @@ 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 path.resolve(process.env.HERMES_HOME)
|
||||
if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(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')
|
||||
@@ -5072,65 +5084,75 @@ 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: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
webviewTag: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
devTools: true
|
||||
}
|
||||
})
|
||||
|
||||
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, () => {
|
||||
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: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
webviewTag: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
devTools: true
|
||||
}
|
||||
})
|
||||
return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch }))
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
)
|
||||
|
||||
return win
|
||||
})
|
||||
// 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 })
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
@@ -5317,6 +5339,11 @@ ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:window:openNewSession', async () => {
|
||||
createNewSessionWindow()
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
// Renderer's "Reload and retry" path. Clear the latched failure and
|
||||
// reset connection state so the next startHermes() call restarts the
|
||||
@@ -5586,9 +5613,14 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
|
||||
await prepareProfileDeleteRequest(request)
|
||||
|
||||
const connection = await ensureBackend(request?.profile)
|
||||
const profile = request?.profile
|
||||
const connection = await ensureBackend(profile)
|
||||
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
const url = `${connection.baseUrl}${request.path}`
|
||||
const requestPath = pathWithGlobalRemoteProfile(request.path, profile, {
|
||||
globalRemote: globalRemoteActive(),
|
||||
profileRemoteOverride: profileHasRemoteOverride(profile)
|
||||
})
|
||||
const url = `${connection.baseUrl}${requestPath}`
|
||||
// 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
|
||||
@@ -5609,11 +5641,30 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
|
||||
ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
if (!Notification.isSupported()) return false
|
||||
new Notification({
|
||||
// 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({
|
||||
title: payload?.title || 'Hermes',
|
||||
body: payload?.body || '',
|
||||
silent: Boolean(payload?.silent)
|
||||
}).show()
|
||||
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()
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
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'),
|
||||
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),
|
||||
@@ -94,6 +95,16 @@ 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)
|
||||
|
||||
@@ -15,12 +15,13 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
|
||||
// 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. `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 } = {}) {
|
||||
const query = `?win=secondary${watch ? '&watch=1' : ''}`
|
||||
const route = `#/${encodeURIComponent(sessionId)}`
|
||||
// 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)}`
|
||||
|
||||
if (devServer) {
|
||||
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
|
||||
|
||||
@@ -82,6 +82,12 @@ test('buildSessionWindowUrl adds the watch flag for spectator windows, before th
|
||||
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
|
||||
|
||||
76
apps/desktop/electron/windows-user-env.cjs
Normal file
76
apps/desktop/electron/windows-user-env.cjs
Normal file
@@ -0,0 +1,76 @@
|
||||
// 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
|
||||
}
|
||||
90
apps/desktop/electron/windows-user-env.test.cjs
Normal file
90
apps/desktop/electron/windows-user-env.test.cjs
Normal file
@@ -0,0 +1,90 @@
|
||||
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)
|
||||
})
|
||||
@@ -20,6 +20,7 @@
|
||||
"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",
|
||||
"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",
|
||||
@@ -36,7 +37,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",
|
||||
"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",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
@@ -134,6 +135,7 @@
|
||||
},
|
||||
"build": {
|
||||
"electronVersion": "40.9.3",
|
||||
"electronDist": "../../node_modules/electron/dist",
|
||||
"appId": "com.nousresearch.hermes",
|
||||
"productName": "Hermes",
|
||||
"executableName": "Hermes",
|
||||
|
||||
59
apps/desktop/scripts/patch-electron-builder-mac-binary.cjs
Normal file
59
apps/desktop/scripts/patch-electron-builder-mac-binary.cjs
Normal file
@@ -0,0 +1,59 @@
|
||||
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')
|
||||
@@ -23,6 +23,7 @@ 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'
|
||||
@@ -124,17 +125,12 @@ function artifactKind(value: string): ArtifactKind {
|
||||
}
|
||||
|
||||
function artifactHref(value: string): string {
|
||||
if (
|
||||
value.startsWith('http://') ||
|
||||
value.startsWith('https://') ||
|
||||
value.startsWith('file://') ||
|
||||
value.startsWith('data:')
|
||||
) {
|
||||
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('data:')) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (value.startsWith('/')) {
|
||||
return `file://${encodeURI(value)}`
|
||||
if (value.startsWith('file://') || value.startsWith('/')) {
|
||||
return mediaExternalUrl(value)
|
||||
}
|
||||
|
||||
return value
|
||||
|
||||
@@ -9,6 +9,7 @@ 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'
|
||||
@@ -66,6 +67,7 @@ export function ComposerControls({
|
||||
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}
|
||||
@@ -81,8 +83,10 @@ export function ComposerControls({
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
{canSteer && (
|
||||
<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}>
|
||||
<Button
|
||||
aria-label={steerLabel}
|
||||
@@ -96,6 +100,8 @@ export function ComposerControls({
|
||||
<SteeringWheel size={16} />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : (
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
)}
|
||||
{showVoicePrimary ? (
|
||||
<Tip label={c.startVoice}>
|
||||
|
||||
86
apps/desktop/src/app/chat/composer/model-pill.tsx
Normal file
86
apps/desktop/src/app/chat/composer/model-pill.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
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,3 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
|
||||
@@ -22,6 +24,8 @@ 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 }
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
$sessions,
|
||||
sessionPinId
|
||||
} from '@/store/session'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { routeSessionId } from '../routes'
|
||||
@@ -61,6 +62,7 @@ import { 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
|
||||
@@ -119,10 +121,10 @@ function ChatHeader({
|
||||
? pinnedSessionIds.includes(selectedSessionId)
|
||||
: false
|
||||
|
||||
// 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) {
|
||||
// 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)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -249,6 +251,7 @@ function ChatRuntimeBoundary({
|
||||
export function ChatView({
|
||||
className,
|
||||
gateway,
|
||||
modelMenuContent,
|
||||
onToggleSelectedPin,
|
||||
onDeleteSelectedSession,
|
||||
onCancel,
|
||||
@@ -302,7 +305,10 @@ export function ChatView({
|
||||
// waiting for the resume effect (which paints a frame later) to clear them.
|
||||
const routeSessionMismatch = isRoutedSessionView && routedSessionId !== selectedSessionId
|
||||
|
||||
const showIntro = freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
|
||||
// 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
|
||||
@@ -342,6 +348,7 @@ export function ChatView({
|
||||
provider: currentProvider,
|
||||
canSwitch: gatewayOpen,
|
||||
loading: !gatewayOpen || (!currentModel && !currentProvider),
|
||||
modelMenuContent,
|
||||
quickModels
|
||||
},
|
||||
tools: {
|
||||
@@ -354,7 +361,7 @@ export function ChatView({
|
||||
active: false
|
||||
}
|
||||
}),
|
||||
[contextSuggestions, currentModel, currentProvider, gatewayOpen, quickModels]
|
||||
[contextSuggestions, currentModel, currentProvider, gatewayOpen, modelMenuContent, quickModels]
|
||||
)
|
||||
|
||||
// Drop files anywhere in the conversation area, not just on the composer
|
||||
|
||||
67
apps/desktop/src/app/chat/scroll-to-bottom-button.test.tsx
Normal file
67
apps/desktop/src/app/chat/scroll-to-bottom-button.test.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { Codicon } from '@/components/ui/codicon'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $approvalRequest } from '@/store/prompts'
|
||||
import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-scroll'
|
||||
|
||||
/**
|
||||
@@ -15,6 +16,13 @@ import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-
|
||||
* / background cards. Visible only while the user has scrolled meaningfully
|
||||
* away from the bottom; clicking re-arms sticky-bottom and pins the viewport.
|
||||
*
|
||||
* When the turn is BLOCKED on an approval, this same control morphs into an
|
||||
* "Approval needed" pill — the only response surface is the inline Run/Reject
|
||||
* bar on the parked tool row, which is always the bottom-most content, so the
|
||||
* existing scroll-to-bottom action lands the user right on it. One control, no
|
||||
* collision, no second scroll path (native scrollIntoView would scroll
|
||||
* overflow:hidden ancestors that can't scroll back and wreck the layout).
|
||||
*
|
||||
* Enter/exit motion lives in styles.css under `.thread-jump-button` — a
|
||||
* directional scale (contract in from 1.1, contract out to 0.9) keyed off
|
||||
* `data-state`. `idle` (never-shown) stays silent so it can't flash on mount;
|
||||
@@ -23,6 +31,11 @@ import { $threadJumpButtonVisible, requestScrollToBottom } from '@/store/thread-
|
||||
export function ScrollToBottomButton() {
|
||||
const { t } = useI18n()
|
||||
const visible = useStore($threadJumpButtonVisible)
|
||||
const request = useStore($approvalRequest)
|
||||
// Scrolled away while an approval is pending → the inline Run/Reject bar is
|
||||
// below the fold. Relabel so the user knows the session needs them, not just
|
||||
// that there's more to read.
|
||||
const approval = visible && Boolean(request)
|
||||
const hasShownRef = useRef(false)
|
||||
|
||||
if (visible) {
|
||||
@@ -30,15 +43,17 @@ export function ScrollToBottomButton() {
|
||||
}
|
||||
|
||||
const state = visible ? 'in' : hasShownRef.current ? 'out' : 'idle'
|
||||
const label = approval ? t.assistant.approval.jumpToApproval : t.assistant.thread.scrollToBottom
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-hidden={!visible}
|
||||
aria-label={t.assistant.thread.scrollToBottom}
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'thread-jump-button absolute left-1/2 z-20 grid size-8 place-items-center rounded-full',
|
||||
'border border-border/65 bg-(--composer-fill) text-muted-foreground hover:text-foreground',
|
||||
'backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
|
||||
'thread-jump-button absolute left-1/2 z-20 grid place-items-center backdrop-blur-[0.75rem] [-webkit-backdrop-filter:blur(0.75rem)]',
|
||||
approval
|
||||
? 'h-8 grid-flow-col gap-1.5 rounded-full border border-primary/40 bg-(--composer-fill) px-3 text-primary hover:bg-primary/10'
|
||||
: 'size-8 rounded-full border border-border/65 bg-(--composer-fill) text-muted-foreground hover:text-foreground',
|
||||
!visible && 'pointer-events-none'
|
||||
)}
|
||||
data-state={state}
|
||||
@@ -52,7 +67,8 @@ export function ScrollToBottomButton() {
|
||||
tabIndex={visible ? 0 : -1}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="arrow-down" size="1rem" />
|
||||
<Codicon name="arrow-down" size={approval ? '0.875rem' : '1rem'} />
|
||||
{approval && <span className="text-xs font-medium">{label}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { writeClipboardText } from '@/components/ui/copy-button'
|
||||
import { CopyButton } from '@/components/ui/copy-button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -49,26 +49,17 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
const r = t.sidebar.row
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
|
||||
const pinItem: ItemSpec = {
|
||||
disabled: !onPin,
|
||||
icon: 'pin',
|
||||
label: pinned ? r.unpin : r.pin,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
}
|
||||
}
|
||||
|
||||
const items: ItemSpec[] = [
|
||||
{
|
||||
disabled: !onPin,
|
||||
icon: 'pin',
|
||||
label: pinned ? r.unpin : r.pin,
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onPin?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
disabled: !sessionId,
|
||||
icon: 'copy',
|
||||
label: r.copyId,
|
||||
onSelect: event => {
|
||||
event.preventDefault()
|
||||
triggerHaptic('selection')
|
||||
void writeClipboardText(sessionId).catch(err => notifyError(err, r.copyIdFailed))
|
||||
}
|
||||
},
|
||||
...(canOpenSessionWindow()
|
||||
? [
|
||||
{
|
||||
@@ -122,13 +113,28 @@ function useSessionActions({ sessionId, title, pinned = false, profile, onPin, o
|
||||
}
|
||||
]
|
||||
|
||||
const renderItems = (Item: MenuItem) =>
|
||||
items.map(({ className, disabled, icon, label, onSelect, variant }) => (
|
||||
<Item className={className} disabled={disabled} key={label} onSelect={onSelect} variant={variant}>
|
||||
<Codicon name={icon} size="0.875rem" />
|
||||
<span>{label}</span>
|
||||
</Item>
|
||||
))
|
||||
const renderMenuItem = (Item: MenuItem, { className, disabled, icon, label, onSelect, variant }: ItemSpec) => (
|
||||
<Item className={className} disabled={disabled} key={label} onSelect={onSelect} variant={variant}>
|
||||
<Codicon name={icon} size="0.875rem" />
|
||||
<span>{label}</span>
|
||||
</Item>
|
||||
)
|
||||
|
||||
const renderItems = (Item: MenuItem) => (
|
||||
<>
|
||||
{renderMenuItem(Item, pinItem)}
|
||||
<CopyButton
|
||||
appearance={Item === DropdownMenuItem ? 'menu-item' : 'context-menu-item'}
|
||||
disabled={!sessionId}
|
||||
errorMessage={r.copyIdFailed}
|
||||
key={r.copyId}
|
||||
label={r.copyId}
|
||||
onCopyError={err => notifyError(err, r.copyIdFailed)}
|
||||
text={sessionId}
|
||||
/>
|
||||
{items.map(spec => renderMenuItem(Item, spec))}
|
||||
</>
|
||||
)
|
||||
|
||||
const renameDialog = (
|
||||
<RenameSessionDialog
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
SIDEBAR_SESSIONS_PAGE_SIZE,
|
||||
unpinSession
|
||||
} from '../store/layout'
|
||||
import { respondToApprovalAction } from '../store/native-notifications'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
@@ -76,6 +77,7 @@ import {
|
||||
setSessionsLoading,
|
||||
setSessionsTotal
|
||||
} from '../store/session'
|
||||
import { onSessionsChanged } from '../store/session-sync'
|
||||
import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos'
|
||||
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
|
||||
import { isSecondaryWindow } from '../store/windows'
|
||||
@@ -269,6 +271,26 @@ export function DesktopController() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Notification click: the main process already focused the window; jump to its session.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onFocusSession?.(sessionId => {
|
||||
if (sessionId) {
|
||||
navigate(sessionRoute(sessionId))
|
||||
}
|
||||
})
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [navigate])
|
||||
|
||||
// Notification action button (Approve/Reject) — resolve in place, no navigation.
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.hermesDesktop?.onNotificationAction?.(({ actionId, sessionId }) => {
|
||||
void respondToApprovalAction(sessionId ?? null, actionId)
|
||||
})
|
||||
|
||||
return () => unsubscribe?.()
|
||||
}, [])
|
||||
|
||||
// hermes:// deep links (e.g. a docs "Send to App" button for an automation blueprint).
|
||||
// Build the equivalent /blueprint slash command from the payload and drop
|
||||
// it into the composer — the user reviews/edits, then sends; the agent (or
|
||||
@@ -443,6 +465,17 @@ export function DesktopController() {
|
||||
void refreshSessions()
|
||||
}, [refreshSessions])
|
||||
|
||||
// Another window mutated the shared session list (e.g. a chat started in the
|
||||
// pop-out). Re-pull so the sidebar reflects it. Pop-outs have no sidebar, so
|
||||
// only real windows bother.
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
return onSessionsChanged(() => void refreshSessions().catch(() => undefined))
|
||||
}, [refreshSessions])
|
||||
|
||||
// ALL-profiles view pages one profile at a time: fetch that profile's next
|
||||
// page and merge it in place, leaving every other profile's rows untouched.
|
||||
const loadMoreSessionsForProfile = useCallback(async (profile: string) => {
|
||||
@@ -678,7 +711,9 @@ export function DesktopController() {
|
||||
}
|
||||
|
||||
lastGatewayProfileRef.current = activeGatewayProfile
|
||||
void refreshCurrentModel()
|
||||
// Force: the new profile has its own default, so reseed even if the composer
|
||||
// already shows the previous profile's model.
|
||||
void refreshCurrentModel(true)
|
||||
void refreshActiveProfile()
|
||||
}, [activeGatewayProfile, refreshCurrentModel])
|
||||
|
||||
@@ -826,7 +861,6 @@ export function DesktopController() {
|
||||
gatewayLogLines,
|
||||
gatewayState,
|
||||
inferenceStatus,
|
||||
modelMenuContent,
|
||||
openAgents,
|
||||
freshDraftReady,
|
||||
openCommandCenterSection,
|
||||
@@ -948,6 +982,7 @@ export function DesktopController() {
|
||||
<ChatView
|
||||
gateway={gatewayRef.current}
|
||||
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
|
||||
modelMenuContent={modelMenuContent}
|
||||
onAddContextRef={composer.addContextRefAttachment}
|
||||
onAddUrl={url => composer.addContextRefAttachment(`@url:${formatRefValue(url)}`, url)}
|
||||
onAttachDroppedItems={composer.attachDroppedItems}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
switcherActive,
|
||||
switcherJustClosed
|
||||
} from '@/store/session-switcher'
|
||||
import { openNewSessionInNewWindow } from '@/store/windows'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { requestComposerFocus } from '../chat/composer/focus'
|
||||
@@ -132,6 +133,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
deps.startFreshSession()
|
||||
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
|
||||
},
|
||||
'session.newWindow': () => void openNewSessionInNewWindow(),
|
||||
'session.next': () => stepSession(1),
|
||||
'session.prev': () => stepSession(-1),
|
||||
...sessionSlotHandlers,
|
||||
|
||||
@@ -527,7 +527,7 @@ const PLATFORM_INTRO: Record<string, string> = {
|
||||
wecom_callback:
|
||||
'Set up a WeCom self-built app, expose its callback URL, and provide the corp ID, secret, agent ID, and AES key.',
|
||||
weixin:
|
||||
'Sign in to the WeChat Official Account platform, copy the AppID and Token, and point the message callback URL at Hermes.',
|
||||
'Run `hermes gateway setup`, select Weixin, then scan and confirm the QR code with a personal WeChat account. Hermes connects through Tencent\'s iLink Bot API and saves the credentials.',
|
||||
qqbot: 'Register an app on the QQ Open Platform (q.qq.com) and copy the App ID and Client Secret.',
|
||||
api_server:
|
||||
'Expose Hermes as an OpenAI-compatible API. Set an auth key, then point Open WebUI / LobeChat / etc. at the host:port.',
|
||||
|
||||
75
apps/desktop/src/app/right-sidebar/index.test.tsx
Normal file
75
apps/desktop/src/app/right-sidebar/index.test.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesReadDirResult } from '@/global'
|
||||
import { $connection, setCurrentCwd } from '@/store/session'
|
||||
|
||||
import { resetProjectTreeState } from './files/use-project-tree'
|
||||
|
||||
import { RightSidebarPane } from './index'
|
||||
|
||||
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
|
||||
const selectPaths = vi.fn()
|
||||
|
||||
function ok(entries: { name: string; path: string; isDirectory: boolean }[]): HermesReadDirResult {
|
||||
return { entries }
|
||||
}
|
||||
|
||||
function installBridge() {
|
||||
;(
|
||||
window as unknown as {
|
||||
hermesDesktop: {
|
||||
readDir: typeof readDir
|
||||
selectPaths: typeof selectPaths
|
||||
}
|
||||
}
|
||||
).hermesDesktop = { readDir, selectPaths }
|
||||
}
|
||||
|
||||
describe('RightSidebarPane', () => {
|
||||
beforeEach(() => {
|
||||
$connection.set(null)
|
||||
resetProjectTreeState()
|
||||
setCurrentCwd('/repo')
|
||||
readDir.mockReset()
|
||||
selectPaths.mockReset()
|
||||
readDir.mockResolvedValue(ok([{ name: 'README.md', path: '/repo/README.md', isDirectory: false }]))
|
||||
selectPaths.mockResolvedValue(['/repo-next'])
|
||||
installBridge()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$connection.set(null)
|
||||
setCurrentCwd('')
|
||||
resetProjectTreeState()
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
it('refreshes the current tree without opening the folder picker', async () => {
|
||||
const onChangeCwd = vi.fn()
|
||||
|
||||
render(<RightSidebarPane onActivateFile={vi.fn()} onActivateFolder={vi.fn()} onChangeCwd={onChangeCwd} />)
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh tree' }).hasAttribute('disabled')).toBe(false))
|
||||
|
||||
readDir.mockClear()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh tree' }))
|
||||
|
||||
await waitFor(() => expect(readDir).toHaveBeenCalledWith('/repo'))
|
||||
expect(selectPaths).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open folder' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(selectPaths).toHaveBeenCalledWith({
|
||||
defaultPath: '/repo',
|
||||
directories: true,
|
||||
multiple: false,
|
||||
title: 'Change working directory'
|
||||
})
|
||||
)
|
||||
await waitFor(() => expect(onChangeCwd).toHaveBeenCalledWith('/repo-next'))
|
||||
})
|
||||
})
|
||||
@@ -126,12 +126,12 @@ interface FilesystemTabProps extends FileTreeBodyProps {
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
// Sidebar palette + hover-reveal: refresh tracks label hover; collapse-all
|
||||
// stays visible while any folder is expanded.
|
||||
// Sidebar palette + hover-reveal: header actions stay reachable while moving
|
||||
// from the project label to the action buttons.
|
||||
const HEADER_ACTION_CLASS =
|
||||
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
|
||||
|
||||
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 peer-focus-visible/project-label:pointer-events-auto peer-focus-visible/project-label:opacity-100 peer-hover/project-label:pointer-events-auto peer-hover/project-label:opacity-100`
|
||||
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
|
||||
|
||||
function FilesystemTab({
|
||||
canCollapse,
|
||||
@@ -158,7 +158,7 @@ function FilesystemTab({
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<RightSidebarSectionHeader>
|
||||
<div className="peer/project-label flex min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-1">
|
||||
<button
|
||||
className="flex w-full min-w-0 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => void onChangeFolder()}
|
||||
@@ -216,7 +216,7 @@ function FilesystemTab({
|
||||
}
|
||||
|
||||
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
|
||||
return <div className="flex h-7 shrink-0 items-center px-2.5">{children}</div>
|
||||
return <div className="group/project-header flex h-7 shrink-0 items-center px-2.5">{children}</div>
|
||||
}
|
||||
|
||||
interface FileTreeBodyProps {
|
||||
|
||||
@@ -9,3 +9,22 @@ export const $terminalTakeover = atom(storedBoolean(TAKEOVER_KEY, false))
|
||||
$terminalTakeover.subscribe(active => persistBoolean(TAKEOVER_KEY, active))
|
||||
|
||||
export const setTerminalTakeover = (active: boolean) => $terminalTakeover.set(active)
|
||||
|
||||
/** A command queued to run in the embedded terminal. The terminal pane flushes
|
||||
* (and clears) it once its session is live, so a value set before the pane
|
||||
* mounts still runs. Cleared after flush so a later remount can't replay it. */
|
||||
export const $terminalInjection = atom<null | string>(null)
|
||||
|
||||
/** Open the terminal pane and run a command in it. Used to disconnect external
|
||||
* (CLI-managed) providers, which Hermes can't clear via the API — the user
|
||||
* sees exactly what runs instead of Hermes silently deleting their creds. */
|
||||
export const runInTerminal = (command: string) => {
|
||||
const trimmed = command.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
return
|
||||
}
|
||||
|
||||
setTerminalTakeover(true)
|
||||
$terminalInjection.set(trimmed)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { triggerHaptic } from '@/lib/haptics'
|
||||
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { $terminalInjection } from '../store'
|
||||
|
||||
import { makeTerminalReader, setActiveTerminalReader } from './buffer'
|
||||
import {
|
||||
isAddSelectionShortcut,
|
||||
@@ -675,6 +677,28 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [activeTheme, themeName])
|
||||
|
||||
// Flush a queued command (e.g. a provider-disconnect) into the live session.
|
||||
// Only active while open; the subscribe fires immediately, so a command set
|
||||
// before this pane mounted runs as soon as the session is ready. Clearing the
|
||||
// atom after writing stops a later remount from replaying a stale command.
|
||||
useEffect(() => {
|
||||
if (status !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
return $terminalInjection.subscribe(command => {
|
||||
const id = sessionIdRef.current
|
||||
|
||||
if (!command || !id) {
|
||||
return
|
||||
}
|
||||
|
||||
void window.hermesDesktop?.terminal?.write(id, `${command}\r`)
|
||||
$terminalInjection.set(null)
|
||||
termRef.current?.focus()
|
||||
})
|
||||
}, [status])
|
||||
|
||||
return {
|
||||
addSelectionToChat,
|
||||
hostRef,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { QueryClient } from '@tanstack/react-query'
|
||||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
|
||||
import { translateNow } from '@/i18n'
|
||||
import {
|
||||
appendAssistantTextPart,
|
||||
appendReasoningPart,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
upsertToolPart
|
||||
} from '@/lib/chat-messages'
|
||||
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { playCompletionSound } from '@/lib/completion-sound'
|
||||
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
|
||||
import {
|
||||
dedupeGeneratedImageEchoesInParts,
|
||||
@@ -25,8 +27,10 @@ import { triggerHaptic } from '@/lib/haptics'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { parseTodos } from '@/lib/todos'
|
||||
import { setClarifyRequest } from '@/store/clarify'
|
||||
import { setSessionCompacting } from '@/store/compaction'
|
||||
import { refreshBackgroundProcesses } from '@/store/composer-status'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { dispatchNativeNotification } from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||
@@ -43,6 +47,7 @@ import {
|
||||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { broadcastSessionsChanged } from '@/store/session-sync'
|
||||
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
|
||||
import { setSessionTodos } from '@/store/todos'
|
||||
import { recordToolDiff } from '@/store/tool-diffs'
|
||||
@@ -330,6 +335,8 @@ export function useMessageStream({
|
||||
const flushHandleRef = useRef<number | null>(null)
|
||||
const lastFlushAtRef = useRef<number>(0)
|
||||
const nativeSubagentSessionsRef = useRef<Set<string>>(new Set())
|
||||
// Turns that auto-compacted: skip post-turn hydrate so live scrollback survives.
|
||||
const compactedTurnRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const flushQueuedDeltas = useCallback(
|
||||
(sessionId?: string) => {
|
||||
@@ -635,19 +642,26 @@ export function useMessageStream({
|
||||
})
|
||||
|
||||
void refreshSessions().catch(() => undefined)
|
||||
// Sync the freshly-titled row to other windows (e.g. main, when the turn
|
||||
// ran in the pop-out).
|
||||
broadcastSessionsChanged()
|
||||
|
||||
if (compactedTurnRef.current.delete(sessionId)) {
|
||||
shouldHydrate = false
|
||||
}
|
||||
|
||||
if (shouldHydrate) {
|
||||
void hydrateFromStoredSession(3, completedState.storedSessionId, sessionId)
|
||||
}
|
||||
|
||||
if (document.hidden && sessionId === activeSessionIdRef.current) {
|
||||
void window.hermesDesktop?.notify({
|
||||
title: 'Hermes finished',
|
||||
body: text.slice(0, 140) || 'The response is ready.'
|
||||
})
|
||||
}
|
||||
dispatchNativeNotification({
|
||||
body: text.slice(0, 140) || translateNow('notifications.native.turnDoneBody'),
|
||||
kind: 'turnDone',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.turnDoneTitle')
|
||||
})
|
||||
},
|
||||
[activeSessionIdRef, hydrateFromStoredSession, refreshSessions, updateSessionState]
|
||||
[hydrateFromStoredSession, refreshSessions, updateSessionState]
|
||||
)
|
||||
|
||||
const failAssistantMessage = useCallback(
|
||||
@@ -822,6 +836,8 @@ export function useMessageStream({
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
clearSessionSubagents(sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
nativeSubagentSessionsRef.current.delete(sessionId)
|
||||
|
||||
if (isActiveEvent) {
|
||||
@@ -867,12 +883,11 @@ export function useMessageStream({
|
||||
// session so a background turn finishing can't wipe the active chat's
|
||||
// prompt, and vice versa.
|
||||
clearAllPrompts(sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
if (isActiveEvent) {
|
||||
triggerHaptic('streamDone')
|
||||
}
|
||||
playCompletionSound()
|
||||
|
||||
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
|
||||
completeAssistantMessage(sessionId, finalText)
|
||||
@@ -903,10 +918,7 @@ export function useMessageStream({
|
||||
|
||||
// terminal/process tool calls are the only things that spawn or reap
|
||||
// background processes — sync the composer status stack right after.
|
||||
if (
|
||||
!sessionInterrupted(sessionId) &&
|
||||
(payload?.name === 'terminal' || payload?.name === 'process')
|
||||
) {
|
||||
if (!sessionInterrupted(sessionId) && (payload?.name === 'terminal' || payload?.name === 'process')) {
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
}
|
||||
}
|
||||
@@ -958,6 +970,13 @@ export function useMessageStream({
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: question,
|
||||
kind: 'input',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.inputTitle')
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'approval.request') {
|
||||
// Dangerous-command / execute_code approval. The Python side is blocked
|
||||
@@ -966,17 +985,31 @@ export function useMessageStream({
|
||||
// Park it per-session (like clarify) so a *background* profile's turn can
|
||||
// raise it and wait — the sidebar flags "needs input" and the inline bar
|
||||
// surfaces once the user focuses that chat.
|
||||
const command = typeof payload?.command === 'string' ? payload.command : ''
|
||||
const description = typeof payload?.description === 'string' ? payload.description : 'dangerous command'
|
||||
|
||||
setApprovalRequest({
|
||||
// false only when a tirith warning forbids it; backend omits the field otherwise.
|
||||
allowPermanent: payload?.allow_permanent !== false,
|
||||
command: typeof payload?.command === 'string' ? payload.command : '',
|
||||
description: typeof payload?.description === 'string' ? payload.description : 'dangerous command',
|
||||
command,
|
||||
description,
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
actions: [
|
||||
{ id: 'approve', text: translateNow('notifications.native.approveAction') },
|
||||
{ id: 'reject', text: translateNow('notifications.native.rejectAction') }
|
||||
],
|
||||
body: command || description,
|
||||
kind: 'approval',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.approvalTitle')
|
||||
})
|
||||
} else if (event.type === 'sudo.request') {
|
||||
// Sudo password capture (tools/terminal_tool.py). Blocked on
|
||||
// sudo.respond {request_id, password}.
|
||||
@@ -988,6 +1021,13 @@ export function useMessageStream({
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: translateNow('notifications.native.inputBody'),
|
||||
kind: 'input',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.inputTitle')
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'secret.request') {
|
||||
// Skill credential capture (tools/skills_tool.py). Blocked on
|
||||
@@ -995,16 +1035,26 @@ export function useMessageStream({
|
||||
const requestId = typeof payload?.request_id === 'string' ? payload.request_id : ''
|
||||
|
||||
if (requestId) {
|
||||
const envVar = typeof payload?.env_var === 'string' ? payload.env_var : ''
|
||||
const promptText = typeof payload?.prompt === 'string' ? payload.prompt : ''
|
||||
|
||||
setSecretRequest({
|
||||
requestId,
|
||||
envVar: typeof payload?.env_var === 'string' ? payload.env_var : '',
|
||||
prompt: typeof payload?.prompt === 'string' ? payload.prompt : '',
|
||||
envVar,
|
||||
prompt: promptText,
|
||||
sessionId: sessionId ?? null
|
||||
})
|
||||
|
||||
if (sessionId) {
|
||||
updateSessionState(sessionId, state => ({ ...state, needsInput: true }))
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: promptText || envVar || translateNow('notifications.native.inputBody'),
|
||||
kind: 'input',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.inputTitle')
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'terminal.read.request') {
|
||||
// read_terminal tool: serialize the renderer's xterm buffer and answer
|
||||
@@ -1022,9 +1072,12 @@ export function useMessageStream({
|
||||
})
|
||||
}
|
||||
} else if (event.type === 'status.update') {
|
||||
// The gateway's notification poller announces background process
|
||||
// completions / watch matches here — re-sync the status stack.
|
||||
if (sessionId && payload?.kind === 'process') {
|
||||
if (sessionId && payload?.kind === 'compacting') {
|
||||
setSessionCompacting(sessionId, true)
|
||||
compactedTurnRef.current.add(sessionId)
|
||||
} else if (sessionId && payload?.kind === 'process') {
|
||||
// The gateway's notification poller announces background process
|
||||
// completions / watch matches here — re-sync the status stack.
|
||||
void refreshBackgroundProcesses(sessionId)
|
||||
}
|
||||
} else if (event.type === 'error') {
|
||||
@@ -1036,8 +1089,17 @@ export function useMessageStream({
|
||||
// the failed turn (same intent as the message.complete clear).
|
||||
if (sessionId) {
|
||||
clearAllPrompts(sessionId)
|
||||
setSessionCompacting(sessionId, false)
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: errorMessage,
|
||||
kind: 'turnError',
|
||||
sessionId,
|
||||
title: translateNow('notifications.native.turnErrorTitle')
|
||||
})
|
||||
|
||||
if (looksLikeProviderSetup) {
|
||||
requestDesktopOnboarding(errorMessage)
|
||||
} else if (isActiveEvent) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { cleanup, render, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getGlobalModelInfo } from '@/hermes'
|
||||
@@ -13,12 +13,51 @@ import {
|
||||
|
||||
import { useModelControls } from './use-model-controls'
|
||||
|
||||
const setGlobalModel = vi.fn()
|
||||
const notifyError = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getGlobalModelInfo: vi.fn(),
|
||||
setGlobalModel: vi.fn()
|
||||
setGlobalModel: (...args: Parameters<typeof setGlobalModel>) => setGlobalModel(...args)
|
||||
}))
|
||||
|
||||
describe('useModelControls.refreshCurrentModel', () => {
|
||||
vi.mock('@/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: {
|
||||
desktop: {
|
||||
modelSwitchFailed: 'Model switch failed'
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
notifyError: (...args: Parameters<typeof notifyError>) => notifyError(...args)
|
||||
}))
|
||||
|
||||
type Controls = ReturnType<typeof useModelControls>
|
||||
|
||||
function Harness({
|
||||
activeSessionId,
|
||||
onReady,
|
||||
requestGateway
|
||||
}: {
|
||||
activeSessionId: string | null
|
||||
onReady: (controls: Controls) => void
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}) {
|
||||
const controls = useModelControls({
|
||||
activeSessionId,
|
||||
queryClient: new QueryClient(),
|
||||
requestGateway
|
||||
})
|
||||
|
||||
onReady(controls)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
describe('useModelControls', () => {
|
||||
beforeEach(() => {
|
||||
$activeSessionId.set(null)
|
||||
setCurrentModel('')
|
||||
@@ -26,6 +65,7 @@ describe('useModelControls.refreshCurrentModel', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
$activeSessionId.set(null)
|
||||
setCurrentModel('')
|
||||
@@ -74,4 +114,85 @@ describe('useModelControls.refreshCurrentModel', () => {
|
||||
expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro')
|
||||
expect($currentProvider.get()).toBe('deepseek')
|
||||
})
|
||||
|
||||
it('routes active-session picker changes through config.set with an explicit provider', async () => {
|
||||
const requestGateway = vi.fn(async () => ({ key: 'model', value: 'claude-sonnet-4.6' }) as never)
|
||||
let controls!: Controls
|
||||
|
||||
render(
|
||||
<Harness
|
||||
activeSessionId="session-1"
|
||||
onReady={value => (controls = value)}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
await expect(
|
||||
controls.selectModel({
|
||||
model: 'claude-sonnet-4.6',
|
||||
provider: 'anthropic'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith('config.set', {
|
||||
session_id: 'session-1',
|
||||
key: 'model',
|
||||
value: 'claude-sonnet-4.6 --provider anthropic'
|
||||
})
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
|
||||
})
|
||||
|
||||
it('stores a no-session pick as UI state with no gateway or global write', async () => {
|
||||
const requestGateway = vi.fn()
|
||||
let controls!: Controls
|
||||
|
||||
render(
|
||||
<Harness
|
||||
activeSessionId={null}
|
||||
onReady={value => (controls = value)}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
await expect(
|
||||
controls.selectModel({
|
||||
model: 'claude-sonnet-4.6',
|
||||
provider: 'anthropic'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
// The pick is plain UI state; session.create ships it later. Nothing touches
|
||||
// the gateway or the profile default here.
|
||||
expect($currentModel.get()).toBe('claude-sonnet-4.6')
|
||||
expect($currentProvider.get()).toBe('anthropic')
|
||||
expect(requestGateway).not.toHaveBeenCalled()
|
||||
expect(setGlobalModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('seeds an empty composer model from global but never clobbers a pick', async () => {
|
||||
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' })
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useModelControls({
|
||||
activeSessionId: null,
|
||||
queryClient: new QueryClient(),
|
||||
requestGateway: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
// Empty → seeds the default.
|
||||
await result.current.refreshCurrentModel()
|
||||
expect($currentModel.get()).toBe('openai/gpt-5.5')
|
||||
|
||||
// A user pick must survive the lifecycle refreshes that fire on boot / fresh
|
||||
// draft / session events.
|
||||
setCurrentModel('anthropic/claude-sonnet-4.6')
|
||||
setCurrentProvider('anthropic')
|
||||
await result.current.refreshCurrentModel()
|
||||
expect($currentModel.get()).toBe('anthropic/claude-sonnet-4.6')
|
||||
|
||||
// A profile swap forces a reseed to the new profile's default.
|
||||
await result.current.refreshCurrentModel(true)
|
||||
expect($currentModel.get()).toBe('openai/gpt-5.5')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type QueryClient } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { getGlobalModelInfo, setGlobalModel } from '@/hermes'
|
||||
import { getGlobalModelInfo } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
@@ -15,7 +15,6 @@ import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
interface ModelSelection {
|
||||
model: string
|
||||
persistGlobal: boolean
|
||||
provider: string
|
||||
}
|
||||
|
||||
@@ -28,6 +27,7 @@ interface ModelControlsOptions {
|
||||
export function useModelControls({ activeSessionId, queryClient, requestGateway }: ModelControlsOptions) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.desktop
|
||||
|
||||
const updateModelOptionsCache = useCallback(
|
||||
(provider: string, model: string, includeGlobal: boolean) => {
|
||||
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
|
||||
@@ -41,14 +41,24 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
||||
[activeSessionId, queryClient]
|
||||
)
|
||||
|
||||
const refreshCurrentModel = useCallback(async () => {
|
||||
// Seed the composer's model state from the profile default. `force` reseeds
|
||||
// for a profile swap (the new profile has its own default); otherwise this
|
||||
// only fills an EMPTY selection so a user's pick (plain UI state in
|
||||
// $currentModel) survives the lifecycle refreshes that fire on boot / fresh
|
||||
// draft / session events. A live session owns the footer, so skip entirely.
|
||||
const refreshCurrentModel = useCallback(async (force = false) => {
|
||||
try {
|
||||
if ($activeSessionId.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && $currentModel.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await getGlobalModelInfo()
|
||||
|
||||
// A resumed/live session owns the footer model state. Global config
|
||||
// refreshes (gateway boot, profile swap, settings save) must not clobber
|
||||
// the active chat's runtime model/provider in the status bar.
|
||||
if ($activeSessionId.get()) {
|
||||
if ($activeSessionId.get() || (!force && $currentModel.get())) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -64,12 +74,14 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Returns whether the switch succeeded so callers can await it before
|
||||
// applying follow-up changes (e.g. editing a model's reasoning/fast must land
|
||||
// on the right active model — bail rather than write to the previous one).
|
||||
// Returns whether the switch succeeded so callers can await it before applying
|
||||
// follow-up changes. The composer model is plain UI state: with no live
|
||||
// session it's just stored (and shipped on the next session.create); with one
|
||||
// it's scoped to that session via config.set. It NEVER writes the profile
|
||||
// default — that lives in Settings → Model — so picking a model here can't
|
||||
// silently mutate global config.
|
||||
const selectModel = useCallback(
|
||||
async (selection: ModelSelection): Promise<boolean> => {
|
||||
const includeGlobal = selection.persistGlobal || !activeSessionId
|
||||
// Snapshot for rollback: the switch is applied optimistically, so a
|
||||
// failure must restore the prior model/provider (store + query cache)
|
||||
// rather than leave the UI showing a model the backend never selected.
|
||||
@@ -78,41 +90,34 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
||||
|
||||
setCurrentModel(selection.model)
|
||||
setCurrentProvider(selection.provider)
|
||||
updateModelOptionsCache(selection.provider, selection.model, includeGlobal)
|
||||
updateModelOptionsCache(selection.provider, selection.model, !activeSessionId)
|
||||
|
||||
// No live session yet: the pick is pure UI state. session.create reads
|
||||
// $currentModel/$currentProvider and applies it as that session's override.
|
||||
if (!activeSessionId) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
if (activeSessionId) {
|
||||
await requestGateway('slash.exec', {
|
||||
session_id: activeSessionId,
|
||||
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
|
||||
})
|
||||
await requestGateway('config.set', {
|
||||
session_id: activeSessionId,
|
||||
key: 'model',
|
||||
value: `${selection.model} --provider ${selection.provider}`
|
||||
})
|
||||
|
||||
if (selection.persistGlobal) {
|
||||
void refreshCurrentModel()
|
||||
}
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: selection.persistGlobal ? ['model-options'] : ['model-options', activeSessionId]
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
await setGlobalModel(selection.provider, selection.model)
|
||||
void refreshCurrentModel()
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['model-options', activeSessionId] })
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
setCurrentModel(prevModel)
|
||||
setCurrentProvider(prevProvider)
|
||||
updateModelOptionsCache(prevProvider, prevModel, includeGlobal)
|
||||
updateModelOptionsCache(prevProvider, prevModel, !activeSessionId)
|
||||
notifyError(err, copy.modelSwitchFailed)
|
||||
|
||||
return false
|
||||
}
|
||||
},
|
||||
[activeSessionId, copy.modelSwitchFailed, queryClient, refreshCurrentModel, requestGateway, updateModelOptionsCache]
|
||||
[activeSessionId, copy.modelSwitchFailed, queryClient, requestGateway, updateModelOptionsCache]
|
||||
)
|
||||
|
||||
return { refreshCurrentModel, selectModel, updateModelOptionsCache }
|
||||
|
||||
@@ -58,6 +58,7 @@ import { clearSessionTodos } from '@/store/todos'
|
||||
|
||||
import type {
|
||||
ClientSessionState,
|
||||
BrowserManageResponse,
|
||||
FileAttachResponse,
|
||||
HandoffFailResponse,
|
||||
HandoffRequestResponse,
|
||||
@@ -1141,6 +1142,81 @@ export function usePromptActions({
|
||||
} catch (err) {
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
},
|
||||
// /browser connect|disconnect|status manages the live CDP connection on
|
||||
// the gateway host, mirroring the TUI's browser.manage RPC. It mutates
|
||||
// BROWSER_CDP_URL (and may launch Chrome) in the gateway process — only
|
||||
// meaningful when that process runs on this machine, so it's gated to
|
||||
// local connections. A remote gateway would act on the wrong host.
|
||||
browser: async ctx => {
|
||||
const resolved = await withSlashOutput(ctx)
|
||||
|
||||
if (!resolved) {
|
||||
return
|
||||
}
|
||||
|
||||
const { render: renderSlashOutput, sessionId } = resolved
|
||||
|
||||
if ($connection.get()?.mode === 'remote') {
|
||||
renderSlashOutput(
|
||||
'/browser manages a Chromium-family browser on the gateway host — only available when connected to a local gateway.'
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const [rawAction = 'status', ...rest] = ctx.arg.trim().split(/\s+/).filter(Boolean)
|
||||
const cmdAction = rawAction.toLowerCase()
|
||||
|
||||
if (!['connect', 'disconnect', 'status'].includes(cmdAction)) {
|
||||
renderSlashOutput(
|
||||
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml'
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const url = cmdAction === 'connect' ? rest.join(' ').trim() || 'http://127.0.0.1:9222' : undefined
|
||||
|
||||
if (url) {
|
||||
renderSlashOutput(`checking Chromium-family browser remote debugging at ${url}...`)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestGateway<BrowserManageResponse>('browser.manage', {
|
||||
action: cmdAction,
|
||||
session_id: sessionId,
|
||||
...(url && { url })
|
||||
})
|
||||
|
||||
// Without a streamed session subscription, the gateway bundles its
|
||||
// progress lines into `messages` — flush them inline.
|
||||
result?.messages?.forEach(message => renderSlashOutput(message))
|
||||
|
||||
if (cmdAction === 'status') {
|
||||
renderSlashOutput(
|
||||
result?.connected
|
||||
? `browser connected: ${result.url || '(url unavailable)'}`
|
||||
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in config.yaml)'
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (cmdAction === 'disconnect') {
|
||||
renderSlashOutput('browser disconnected')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (result?.connected) {
|
||||
renderSlashOutput('Browser connected to live Chromium-family browser via CDP')
|
||||
renderSlashOutput(`Endpoint: ${result.url || '(url unavailable)'}`)
|
||||
renderSlashOutput('next browser tool call will use this CDP endpoint')
|
||||
}
|
||||
} catch (err) {
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { $activeGatewayProfile, $newChatProfile, $profiles, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$currentCwd,
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
$messages,
|
||||
$sessions,
|
||||
$yoloActive,
|
||||
@@ -42,6 +46,7 @@ import {
|
||||
setYoloActive,
|
||||
workspaceCwdForNewSession
|
||||
} from '@/store/session'
|
||||
import { broadcastSessionsChanged } from '@/store/session-sync'
|
||||
import { reportBackendContract } from '@/store/updates'
|
||||
import { isWatchWindow } from '@/store/windows'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
|
||||
@@ -406,13 +411,13 @@ export function useSessionActions({
|
||||
})
|
||||
setSessionStartedAt(null)
|
||||
setTurnStartedAt(null)
|
||||
// New chats start in the configured default project dir when set,
|
||||
// otherwise the sticky last-used workspace (PR #37586).
|
||||
setCurrentModel('')
|
||||
setCurrentProvider('')
|
||||
setCurrentReasoningEffort('')
|
||||
// The composer's model/effort/fast is sticky UI state (persisted in
|
||||
// localStorage) — a new chat FOLLOWS your last pick instead of snapping
|
||||
// back to the profile default, so we deliberately don't reset it here. The
|
||||
// profile default still owns first-run seeding and profile switches (see
|
||||
// refreshCurrentModel). Only $currentServiceTier (a live-session mirror)
|
||||
// is cleared.
|
||||
setCurrentServiceTier('')
|
||||
setCurrentFastMode(false)
|
||||
setYoloActive(false)
|
||||
setCurrentCwd(workspaceCwdForNewSession())
|
||||
setCurrentBranch('')
|
||||
@@ -442,11 +447,23 @@ export function useSessionActions({
|
||||
const newChatProfile = $newChatProfile.get() ?? normalizeProfileKey($activeGatewayProfile.get())
|
||||
await ensureGatewayProfile(newChatProfile)
|
||||
const cwd = $currentCwd.get().trim() || workspaceCwdForNewSession()
|
||||
// The composer's model/effort/fast is sticky UI state ($currentModel,
|
||||
// $currentProvider, $currentReasoningEffort, $currentFastMode). Ship it
|
||||
// with every session.create so the new chat opens on whatever the picker
|
||||
// shows — applied as per-session overrides, never written to the profile
|
||||
// default (that lives in Settings → Model).
|
||||
const uiModel = $currentModel.get().trim()
|
||||
const uiProvider = $currentProvider.get().trim()
|
||||
const uiEffort = $currentReasoningEffort.get().trim()
|
||||
const uiFast = $currentFastMode.get()
|
||||
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', {
|
||||
cols: 96,
|
||||
...(cwd && { cwd }),
|
||||
...(newChatProfile ? { profile: newChatProfile } : {})
|
||||
...(newChatProfile ? { profile: newChatProfile } : {}),
|
||||
...(uiModel ? { model: uiModel, ...(uiProvider ? { provider: uiProvider } : {}) } : {}),
|
||||
...(uiEffort ? { reasoning_effort: uiEffort } : {}),
|
||||
...(uiFast ? { fast: true } : {})
|
||||
})
|
||||
|
||||
const stored = created.stored_session_id ?? null
|
||||
@@ -472,6 +489,9 @@ export function useSessionActions({
|
||||
// server later returns its own preview/title and supersedes this.
|
||||
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
|
||||
navigate(sessionRoute(stored), { replace: true })
|
||||
// Other windows (e.g. the main window when this is the pop-out) can't
|
||||
// see this session until they re-pull the shared list.
|
||||
broadcastSessionsChanged()
|
||||
}
|
||||
|
||||
setFreshDraftReady(false)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tip } from '@/components/ui/tooltip'
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
|
||||
import { Archive, Bell, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
@@ -20,6 +20,7 @@ import { SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings'
|
||||
import { McpSettings } from './mcp-settings'
|
||||
import { NotificationsSettings } from './notifications-settings'
|
||||
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
|
||||
@@ -30,6 +31,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
'gateway',
|
||||
'keys',
|
||||
'mcp',
|
||||
'notifications',
|
||||
'sessions',
|
||||
'about'
|
||||
]
|
||||
@@ -101,6 +103,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<OverlayNavItem
|
||||
active={activeView === 'notifications'}
|
||||
icon={Bell}
|
||||
label={t.settings.nav.notifications}
|
||||
onClick={() => setActiveView('notifications')}
|
||||
/>
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'providers'}
|
||||
@@ -220,11 +228,13 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
|
||||
onMainModelChanged={onMainModelChanged}
|
||||
/>
|
||||
) : activeView === 'providers' ? (
|
||||
<ProvidersSettings onViewChange={setProviderView} view={providerView} />
|
||||
<ProvidersSettings onClose={onClose} onViewChange={setProviderView} view={providerView} />
|
||||
) : activeView === 'keys' ? (
|
||||
<KeysSettings view={keysView} />
|
||||
) : activeView === 'mcp' ? (
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
|
||||
) : activeView === 'notifications' ? (
|
||||
<NotificationsSettings />
|
||||
) : (
|
||||
<SessionsSettings />
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,8 @@ const getAuxiliaryModels = vi.fn()
|
||||
const setModelAssignment = vi.fn()
|
||||
const getRecommendedDefaultModel = vi.fn()
|
||||
const setEnvVar = vi.fn()
|
||||
const getHermesConfigRecord = vi.fn()
|
||||
const saveHermesConfig = vi.fn()
|
||||
const startManualProviderOAuth = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
@@ -24,7 +26,9 @@ vi.mock('@/hermes', () => ({
|
||||
getAuxiliaryModels: () => getAuxiliaryModels(),
|
||||
setModelAssignment: (body: unknown) => setModelAssignment(body),
|
||||
getRecommendedDefaultModel: (slug: string) => getRecommendedDefaultModel(slug),
|
||||
setEnvVar: (key: string, value: string) => setEnvVar(key, value)
|
||||
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
|
||||
getHermesConfigRecord: () => getHermesConfigRecord(),
|
||||
saveHermesConfig: (config: unknown) => saveHermesConfig(config)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/onboarding', () => ({
|
||||
@@ -35,7 +39,13 @@ beforeEach(() => {
|
||||
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
|
||||
getGlobalModelOptions.mockResolvedValue({
|
||||
providers: [
|
||||
{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'], authenticated: true },
|
||||
{
|
||||
name: 'Nous',
|
||||
slug: 'nous',
|
||||
models: ['hermes-4', 'hermes-4-mini'],
|
||||
authenticated: true,
|
||||
capabilities: { 'hermes-4': { reasoning: true, fast: true } }
|
||||
},
|
||||
// An unconfigured api_key provider — surfaced by the full-universe payload.
|
||||
{ name: 'DeepSeek', slug: 'deepseek', models: [], authenticated: false, auth_type: 'api_key', key_env: 'DEEPSEEK_API_KEY' }
|
||||
]
|
||||
@@ -47,6 +57,8 @@ beforeEach(() => {
|
||||
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
|
||||
getRecommendedDefaultModel.mockResolvedValue({ provider: 'deepseek', model: 'deepseek-chat', free_tier: null })
|
||||
setEnvVar.mockResolvedValue({ ok: true })
|
||||
getHermesConfigRecord.mockResolvedValue({ agent: { reasoning_effort: 'medium', service_tier: 'normal' } })
|
||||
saveHermesConfig.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -100,6 +112,31 @@ describe('ModelSettings', () => {
|
||||
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('DEEPSEEK_API_KEY', 'sk-test-123'))
|
||||
})
|
||||
|
||||
it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => {
|
||||
await renderModelSettings()
|
||||
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
|
||||
|
||||
const fastSwitch = await screen.findByRole('switch')
|
||||
fireEvent.click(fastSwitch)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saveHermesConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agent: expect.objectContaining({ service_tier: 'fast' }) })
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the reasoning/speed defaults when the main model reports no capabilities', async () => {
|
||||
getGlobalModelOptions.mockResolvedValueOnce({
|
||||
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4'], authenticated: true, capabilities: { 'hermes-4': { reasoning: false, fast: false } } }]
|
||||
})
|
||||
|
||||
await renderModelSettings()
|
||||
await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled())
|
||||
|
||||
expect(screen.queryByRole('switch')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders the auxiliary task rows', async () => {
|
||||
await renderModelSettings()
|
||||
|
||||
|
||||
@@ -3,11 +3,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
getAuxiliaryModels,
|
||||
getGlobalModelInfo,
|
||||
getGlobalModelOptions,
|
||||
getHermesConfigRecord,
|
||||
getRecommendedDefaultModel,
|
||||
saveHermesConfig,
|
||||
setEnvVar,
|
||||
setModelAssignment
|
||||
} from '@/hermes'
|
||||
@@ -15,11 +18,26 @@ import type { AuxiliaryModelsResponse, ModelOptionProvider, StaleAuxAssignment }
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle, Cpu, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { startManualLocalEndpoint, startManualProviderOAuth } from '@/store/onboarding'
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { getNested, setNested } from './helpers'
|
||||
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
|
||||
|
||||
// Hermes' reasoning levels (VALID_REASONING_EFFORTS); `none` = thinking off.
|
||||
// Empty config = Hermes default (medium), shown as Medium.
|
||||
const EFFORT_VALUES = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh'] as const
|
||||
|
||||
// agent.service_tier stores "fast"/"priority"/"on" for fast; anything else is
|
||||
// normal (mirrors tui_gateway _load_service_tier).
|
||||
const isFastTier = (tier: unknown): boolean =>
|
||||
['fast', 'priority', 'on'].includes(String(tier ?? '').trim().toLowerCase())
|
||||
|
||||
// Reuse the composer's effort labels (`xhigh` shows as "Max", else 1:1).
|
||||
const effortLabelKey = (v: string) => (v === 'xhigh' ? 'max' : v) as 'high' | 'low' | 'max' | 'medium' | 'minimal'
|
||||
|
||||
// A provider row is "ready" to pick a model from when it reports models. The
|
||||
// backend now surfaces the full `hermes model` universe (every canonical
|
||||
// provider), so unconfigured providers come back with `authenticated:false`
|
||||
@@ -97,6 +115,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
const [selectedProvider, setSelectedProvider] = useState('')
|
||||
const [selectedModel, setSelectedModel] = useState('')
|
||||
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
|
||||
// Full profile config, kept so the reasoning/speed defaults round-trip
|
||||
// (read agent.* → write back the whole record) like the generic config page.
|
||||
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
|
||||
const [applying, setApplying] = useState(false)
|
||||
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
|
||||
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
|
||||
@@ -113,10 +134,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
|
||||
const [modelInfo, modelOptions, auxiliaryModels, cfg] = await Promise.all([
|
||||
getGlobalModelInfo(),
|
||||
getGlobalModelOptions(),
|
||||
getAuxiliaryModels()
|
||||
getAuxiliaryModels(),
|
||||
getHermesConfigRecord()
|
||||
])
|
||||
|
||||
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
|
||||
@@ -124,6 +146,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
setSelectedProvider(prev => prev || modelInfo.provider)
|
||||
setSelectedModel(prev => prev || modelInfo.model)
|
||||
setAuxiliary(auxiliaryModels)
|
||||
setConfig(cfg)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
@@ -181,6 +204,42 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
.map(entry => ({ task: entry.task, provider: entry.provider, model: entry.model }))
|
||||
}, [auxiliary, mainModel])
|
||||
|
||||
// Capabilities of the APPLIED main model — gates the profile-default
|
||||
// reasoning/speed controls the same way the composer picker gates per-model
|
||||
// edits (reasoning defaults on, fast defaults off when unreported).
|
||||
const mainCaps = useMemo(() => {
|
||||
const row = providers.find(provider => provider.slug === mainModel?.provider)
|
||||
|
||||
return mainModel ? row?.capabilities?.[mainModel.model] : undefined
|
||||
}, [providers, mainModel])
|
||||
|
||||
const reasoningSupported = mainCaps?.reasoning ?? true
|
||||
const fastSupported = mainCaps?.fast ?? false
|
||||
const effortValue = String(getNested(config ?? {}, 'agent.reasoning_effort') ?? '').trim().toLowerCase() || 'medium'
|
||||
const fastOn = isFastTier(getNested(config ?? {}, 'agent.service_tier'))
|
||||
|
||||
// Persist a single agent.* default by round-tripping the whole config record
|
||||
// (PUT /api/config replaces it) — optimistic, with rollback on failure.
|
||||
const writeAgentDefault = useCallback(
|
||||
async (key: string, value: string) => {
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
const prev = config
|
||||
const next = setNested(config, key, value)
|
||||
setConfig(next)
|
||||
|
||||
try {
|
||||
await saveHermesConfig(next)
|
||||
} catch (err) {
|
||||
setConfig(prev)
|
||||
notifyError(err, m.defaultsFailed)
|
||||
}
|
||||
},
|
||||
[config, m.defaultsFailed]
|
||||
)
|
||||
|
||||
// Paste an API key for the selected `api_key` provider, persist it, then
|
||||
// refresh so the now-authenticated provider's models populate. Auto-selects
|
||||
// the recommended default model so the user can Apply in one more click.
|
||||
@@ -433,6 +492,38 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
: `${selectedProviderRow?.name} signs in through your browser — Hermes runs the flow for you.`}
|
||||
</p>
|
||||
)}
|
||||
{config && mainModel && (reasoningSupported || fastSupported) && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
<span className="text-xs text-muted-foreground">{m.defaultsLabel}</span>
|
||||
{reasoningSupported && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{m.reasoning}
|
||||
<Select onValueChange={value => void writeAgentDefault('agent.reasoning_effort', value)} value={effortValue}>
|
||||
<SelectTrigger className={cn('min-w-28', CONTROL_TEXT)}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EFFORT_VALUES.map(value => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value === 'none' ? m.reasoningOff : t.shell.modelOptions[effortLabelKey(value)]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{fastSupported && (
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
{t.shell.modelOptions.fast}
|
||||
<Switch
|
||||
checked={fastOn}
|
||||
onCheckedChange={checked => void writeAgentDefault('agent.service_tier', checked ? 'fast' : 'normal')}
|
||||
size="xs"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
|
||||
{switchStaleAux.length > 0 && (
|
||||
<div className="mt-2">
|
||||
|
||||
150
apps/desktop/src/app/settings/notifications-settings.tsx
Normal file
150
apps/desktop/src/app/settings/notifications-settings.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { COMPLETION_SOUND_VARIANTS, previewCompletionSound } from '@/lib/completion-sound'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Bell, Play } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $completionSoundVariantId, setCompletionSoundVariantId } from '@/store/completion-sound'
|
||||
import {
|
||||
$nativeNotifyPrefs,
|
||||
NATIVE_NOTIFICATION_KINDS,
|
||||
sendTestNativeNotification,
|
||||
setNativeNotifyEnabled,
|
||||
setNativeNotifyKind
|
||||
} from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
|
||||
const CAPTION = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
|
||||
|
||||
function Caption({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <p className={cn(CAPTION, className)}>{children}</p>
|
||||
}
|
||||
|
||||
function ToggleRow(props: {
|
||||
checked: boolean
|
||||
description: string
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onChange: (on: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<Switch
|
||||
aria-label={props.label}
|
||||
checked={props.checked}
|
||||
disabled={props.disabled}
|
||||
onCheckedChange={on => {
|
||||
triggerHaptic('selection')
|
||||
props.onChange(on)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
description={props.description}
|
||||
title={props.label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotificationsSettings() {
|
||||
const { t } = useI18n()
|
||||
const prefs = useStore($nativeNotifyPrefs)
|
||||
const completionSoundVariantId = useStore($completionSoundVariantId)
|
||||
const copy = t.settings.notifications
|
||||
|
||||
const runTest = async () => {
|
||||
triggerHaptic('open')
|
||||
const ok = await sendTestNativeNotification(copy.testTitle, copy.testBody)
|
||||
notify({ kind: ok ? 'info' : 'error', message: ok ? copy.testSent : copy.testUnsupported })
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<SectionHeading icon={Bell} title={copy.title} />
|
||||
<Caption className="mb-2 leading-(--conversation-caption-line-height)">{copy.intro}</Caption>
|
||||
|
||||
<ToggleRow
|
||||
checked={prefs.enabled}
|
||||
description={copy.enableAllDesc}
|
||||
label={copy.enableAll}
|
||||
onChange={setNativeNotifyEnabled}
|
||||
/>
|
||||
|
||||
<div className="my-1 h-px bg-border/30" />
|
||||
|
||||
{NATIVE_NOTIFICATION_KINDS.map(kind => (
|
||||
<ToggleRow
|
||||
checked={prefs.enabled && prefs.kinds[kind]}
|
||||
description={copy.kinds[kind].description}
|
||||
disabled={!prefs.enabled}
|
||||
key={kind}
|
||||
label={copy.kinds[kind].label}
|
||||
onChange={on => setNativeNotifyKind(kind, on)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="my-1 h-px bg-border/30" />
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Select
|
||||
onValueChange={value => {
|
||||
const variantId = Number.parseInt(value, 10)
|
||||
|
||||
setCompletionSoundVariantId(variantId)
|
||||
previewCompletionSound(variantId)
|
||||
triggerHaptic('selection')
|
||||
}}
|
||||
value={String(completionSoundVariantId)}
|
||||
>
|
||||
<SelectTrigger className={cn('min-w-56', CONTROL_TEXT)}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{COMPLETION_SOUND_VARIANTS.map(variant => (
|
||||
<SelectItem key={variant.id} value={String(variant.id)}>
|
||||
{variant.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
className="gap-1.5"
|
||||
onClick={() => {
|
||||
previewCompletionSound()
|
||||
triggerHaptic('crisp')
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
{copy.completionSoundPreview}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
description={copy.completionSoundDesc}
|
||||
title={copy.completionSoundTitle}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<Button className="self-start" onClick={() => void runTest()} size="sm" type="button" variant="outline">
|
||||
<Bell />
|
||||
{copy.test}
|
||||
</Button>
|
||||
<Caption>{copy.focusedHint}</Caption>
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
100
apps/desktop/src/app/settings/providers-settings.test.tsx
Normal file
100
apps/desktop/src/app/settings/providers-settings.test.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { OAuthProvider } from '@/types/hermes'
|
||||
|
||||
const listOAuthProviders = vi.fn()
|
||||
const disconnectOAuthProvider = vi.fn()
|
||||
const getEnvVars = vi.fn()
|
||||
const startManualProviderOAuth = vi.fn()
|
||||
const onboarding = atom({ manual: false })
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
disconnectOAuthProvider: (providerId: string) => disconnectOAuthProvider(providerId),
|
||||
getEnvVars: () => getEnvVars(),
|
||||
listOAuthProviders: () => listOAuthProviders()
|
||||
}))
|
||||
|
||||
vi.mock('@/store/onboarding', () => ({
|
||||
$desktopOnboarding: onboarding,
|
||||
startManualProviderOAuth: (providerId: string) => startManualProviderOAuth(providerId)
|
||||
}))
|
||||
|
||||
function provider(id: string, loggedIn: boolean, patch: Partial<OAuthProvider> = {}): OAuthProvider {
|
||||
return {
|
||||
cli_command: `hermes auth add ${id}`,
|
||||
disconnectable: true,
|
||||
docs_url: '',
|
||||
flow: 'device_code',
|
||||
id,
|
||||
name: id === 'nous' ? 'Nous Portal' : 'MiniMax',
|
||||
status: {
|
||||
logged_in: loggedIn
|
||||
},
|
||||
...patch
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
onboarding.set({ manual: false })
|
||||
getEnvVars.mockResolvedValue({})
|
||||
disconnectOAuthProvider.mockResolvedValue({ ok: true, provider: 'nous' })
|
||||
listOAuthProviders.mockResolvedValue({
|
||||
providers: [provider('nous', true), provider('minimax-oauth', false)]
|
||||
})
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function renderProvidersSettings() {
|
||||
const { ProvidersSettings } = await import('./providers-settings')
|
||||
|
||||
return render(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="accounts" />)
|
||||
}
|
||||
|
||||
describe('ProvidersSettings', () => {
|
||||
it('disconnects a connected provider account and refreshes the accounts list', async () => {
|
||||
await renderProvidersSettings()
|
||||
|
||||
const remove = await screen.findByRole('button', { name: 'Remove Nous Portal' })
|
||||
fireEvent.click(remove)
|
||||
|
||||
await waitFor(() => expect(disconnectOAuthProvider).toHaveBeenCalledWith('nous'))
|
||||
expect(listOAuthProviders).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps provider selection separate from account removal', async () => {
|
||||
await renderProvidersSettings()
|
||||
|
||||
fireEvent.click(await screen.findByText('Nous Portal'))
|
||||
|
||||
expect(startManualProviderOAuth).toHaveBeenCalledWith('nous')
|
||||
expect(disconnectOAuthProvider).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not offer removal for externally managed providers', async () => {
|
||||
listOAuthProviders.mockResolvedValue({
|
||||
providers: [
|
||||
provider('qwen-oauth', true, {
|
||||
cli_command: 'hermes auth add qwen-oauth',
|
||||
disconnect_hint: 'Use `hermes auth add qwen-oauth` or that provider\'s CLI to remove it.',
|
||||
disconnectable: false,
|
||||
flow: 'external',
|
||||
name: 'Qwen (via Qwen CLI)'
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
await renderProvidersSettings()
|
||||
|
||||
expect(await screen.findByText('Qwen Code')).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: 'Remove Qwen Code' })).toBeNull()
|
||||
expect(screen.getByText(/managed by its own CLI/)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,18 +1,22 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { runInTerminal } from '@/app/right-sidebar/store'
|
||||
import {
|
||||
FEATURED_ID,
|
||||
FeaturedProviderRow,
|
||||
KeyProviderRow,
|
||||
ProviderRow,
|
||||
providerTitle,
|
||||
sortProviders
|
||||
} from '@/components/desktop-onboarding-overlay'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { listOAuthProviders } from '@/hermes'
|
||||
import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ChevronDown, KeyRound } from '@/lib/icons'
|
||||
import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding'
|
||||
import type { EnvVarInfo, OAuthProvider } from '@/types/hermes'
|
||||
|
||||
@@ -21,6 +25,20 @@ import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials'
|
||||
import { providerGroup, providerMeta, providerPriority } from './helpers'
|
||||
import { LoadingState, SettingsContent } from './primitives'
|
||||
|
||||
// The embedded terminal (and thus the "run disconnect command" path) only
|
||||
// exists in the Electron desktop shell, not the web dashboard.
|
||||
const canRunInTerminal = () => typeof window !== 'undefined' && Boolean(window.hermesDesktop?.terminal)
|
||||
|
||||
// Parallel group headers ("Connected", "Other providers") so the expanded list
|
||||
// reads as its own section instead of bleeding into the connected group.
|
||||
function GroupLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className="mt-3 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys.
|
||||
export const PROVIDER_VIEWS = ['accounts', 'keys'] as const
|
||||
|
||||
@@ -85,7 +103,19 @@ function buildProviderKeyGroups(vars: Record<string, EnvVarInfo>): ProviderKeyGr
|
||||
// Selecting a provider hands off to the shared onboarding overlay, which runs
|
||||
// that provider's real sign-in flow; the key affordances open the API-key
|
||||
// catalog below.
|
||||
function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) {
|
||||
function OAuthPicker({
|
||||
disconnecting,
|
||||
onDisconnect,
|
||||
onTerminalDisconnect,
|
||||
onWantApiKey,
|
||||
providers
|
||||
}: {
|
||||
disconnecting: null | string
|
||||
onDisconnect: (provider: OAuthProvider) => void
|
||||
onTerminalDisconnect: (provider: OAuthProvider) => void
|
||||
onWantApiKey: () => void
|
||||
providers: OAuthProvider[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.settings.providers
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
@@ -97,7 +127,7 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
|
||||
|
||||
const select = (p: OAuthProvider) => startManualProviderOAuth(p.id)
|
||||
|
||||
const featured = ordered.find(p => p.id === FEATURED_ID) ?? null
|
||||
const featured = ordered.find(p => p.id === FEATURED_ID && !p.status?.logged_in) ?? null
|
||||
const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
|
||||
// Keep connected accounts grouped and always visible; only the unconnected
|
||||
// providers hide behind the disclosure, so the page leads with what's set up.
|
||||
@@ -126,16 +156,22 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
|
||||
{featured && <FeaturedProviderRow onSelect={select} provider={featured} />}
|
||||
{connected.length > 0 && (
|
||||
<>
|
||||
<p className="mt-1 px-0.5 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||
{p.connected}
|
||||
</p>
|
||||
<GroupLabel>{p.connected}</GroupLabel>
|
||||
{connected.map(p => (
|
||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||
<ConnectedProviderRow
|
||||
disconnecting={disconnecting === p.id}
|
||||
key={p.id}
|
||||
onDisconnect={onDisconnect}
|
||||
onSelect={select}
|
||||
onTerminalDisconnect={onTerminalDisconnect}
|
||||
provider={p}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{showOthers && (
|
||||
<>
|
||||
{connected.length > 0 && <GroupLabel>{p.otherProviders}</GroupLabel>}
|
||||
{others.map(p => (
|
||||
<ProviderRow key={p.id} onSelect={select} provider={p} />
|
||||
))}
|
||||
@@ -158,6 +194,80 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectedProviderRow({
|
||||
disconnecting,
|
||||
onDisconnect,
|
||||
onSelect,
|
||||
onTerminalDisconnect,
|
||||
provider
|
||||
}: {
|
||||
disconnecting: boolean
|
||||
onDisconnect: (provider: OAuthProvider) => void
|
||||
onSelect: (provider: OAuthProvider) => void
|
||||
onTerminalDisconnect: (provider: OAuthProvider) => void
|
||||
provider: OAuthProvider
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.providers
|
||||
const title = providerTitle(provider)
|
||||
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
|
||||
// Hermes can clear this provider's creds via the API.
|
||||
const canDisconnect = provider.disconnectable ?? provider.flow !== 'external'
|
||||
// External (CLI-managed) provider Hermes can't clear via the API, but ships a
|
||||
// command we can run in the embedded terminal (Electron shell only).
|
||||
const terminalDisconnect = !canDisconnect && Boolean(provider.disconnect_command) && canRunInTerminal()
|
||||
// Only fall back to a static "remove it elsewhere" hint when we offer no button.
|
||||
const showHint = !canDisconnect && !terminalDisconnect
|
||||
|
||||
return (
|
||||
<div className="group grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded-[6px] transition-colors hover:bg-(--ui-control-hover-background)">
|
||||
<button className="min-w-0 px-3 py-2.5 text-left" onClick={() => onSelect(provider)} type="button">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-[length:var(--conversation-text-font-size)] font-semibold">{title}</span>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<Check className="size-3" />
|
||||
{copy.connected}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.flowSubtitles[provider.flow]}</p>
|
||||
{showHint && (
|
||||
<p className="mt-0.5 truncate text-[0.68rem] leading-5 text-muted-foreground/70">
|
||||
{provider.flow === 'external' ? copy.removeExternalGeneric(title) : copy.removeKeyManaged(title)}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-1 pr-2">
|
||||
<Trail className="size-4 text-muted-foreground transition group-hover:text-foreground" />
|
||||
{canDisconnect && (
|
||||
<Button
|
||||
aria-label={`${t.common.remove} ${title}`}
|
||||
disabled={disconnecting}
|
||||
onClick={() => onDisconnect(provider)}
|
||||
size="icon-xs"
|
||||
title={`${t.common.remove} ${title}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{disconnecting ? <Loader2 className="size-3 animate-spin" /> : <Trash2 className="size-3" />}
|
||||
</Button>
|
||||
)}
|
||||
{terminalDisconnect && (
|
||||
<Button
|
||||
aria-label={`${copy.disconnect} ${title}`}
|
||||
onClick={() => onTerminalDisconnect(provider)}
|
||||
size="icon-xs"
|
||||
title={copy.disconnectInTerminal}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NoProviderKeys() {
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -168,25 +278,31 @@ function NoProviderKeys() {
|
||||
)
|
||||
}
|
||||
|
||||
export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) {
|
||||
export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSettingsProps) {
|
||||
const { t } = useI18n()
|
||||
const { rowProps, vars } = useEnvCredentials()
|
||||
const [oauthProviders, setOauthProviders] = useState<OAuthProvider[]>([])
|
||||
const [openProvider, setOpenProvider] = useState<null | string>(null)
|
||||
const [disconnecting, setDisconnecting] = useState<null | string>(null)
|
||||
// The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we
|
||||
// re-read connection state when the user finishes (or dismisses) a sign-in
|
||||
// they launched from this page — otherwise the cards keep their stale status.
|
||||
const onboardingActive = useStore($desktopOnboarding).manual
|
||||
|
||||
useEffect(() => {
|
||||
if (onboardingActive) {
|
||||
return
|
||||
}
|
||||
const refreshOAuthProviders = useCallback(async () => {
|
||||
// OAuth providers are best-effort — a failure here just hides the panel.
|
||||
const { providers } = await listOAuthProviders()
|
||||
setOauthProviders(providers)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
// OAuth providers are best-effort — a failure here just hides the panel.
|
||||
void (async () => {
|
||||
if (onboardingActive) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const { providers } = await listOAuthProviders()
|
||||
|
||||
@@ -201,6 +317,49 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
||||
return () => void (cancelled = true)
|
||||
}, [onboardingActive])
|
||||
|
||||
// External (CLI-managed) providers can't be cleared via the API by design —
|
||||
// Hermes never deletes creds another tool owns behind a silent API call.
|
||||
// Instead we run the documented removal command in the embedded terminal so
|
||||
// the user sees exactly what executes, then return them to chat to watch it.
|
||||
function handleTerminalDisconnect(provider: OAuthProvider) {
|
||||
const command = provider.disconnect_command
|
||||
|
||||
if (!command) {
|
||||
return
|
||||
}
|
||||
|
||||
const name = providerTitle(provider)
|
||||
|
||||
if (!window.confirm(t.settings.providers.removeTerminalConfirm(name, command))) {
|
||||
return
|
||||
}
|
||||
|
||||
// Leave the settings overlay so the terminal pane (chat-only) is visible.
|
||||
onClose()
|
||||
runInTerminal(command)
|
||||
notify({ kind: 'info', title: t.settings.providers.removedTitle, message: t.settings.providers.removeTerminalRunning(name) })
|
||||
}
|
||||
|
||||
async function handleDisconnect(provider: OAuthProvider) {
|
||||
const name = providerTitle(provider)
|
||||
|
||||
if (!window.confirm(t.settings.providers.removeConfirm(name))) {
|
||||
return
|
||||
}
|
||||
|
||||
setDisconnecting(provider.id)
|
||||
|
||||
try {
|
||||
await disconnectOAuthProvider(provider.id)
|
||||
notify({ durationMs: 3_000, kind: 'success', title: t.settings.providers.removedTitle, message: t.settings.providers.removedMessage(name) })
|
||||
await refreshOAuthProviders().catch(() => undefined)
|
||||
} catch (err) {
|
||||
notifyError(err, t.settings.providers.failedRemove(name))
|
||||
} finally {
|
||||
setDisconnecting(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!vars) {
|
||||
return <LoadingState label={t.settings.providers.loading} />
|
||||
}
|
||||
@@ -237,7 +396,13 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<OAuthPicker onWantApiKey={() => onViewChange('keys')} providers={oauthProviders} />
|
||||
<OAuthPicker
|
||||
disconnecting={disconnecting}
|
||||
onDisconnect={provider => void handleDisconnect(provider)}
|
||||
onTerminalDisconnect={handleTerminalDisconnect}
|
||||
onWantApiKey={() => onViewChange('keys')}
|
||||
providers={oauthProviders}
|
||||
/>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
@@ -253,6 +418,7 @@ interface ProviderKeyGroup {
|
||||
}
|
||||
|
||||
interface ProvidersSettingsProps {
|
||||
onClose: () => void
|
||||
onViewChange: (view: ProviderView) => void
|
||||
view: ProviderView
|
||||
}
|
||||
|
||||
@@ -4,7 +4,15 @@ import type { HermesGateway } from '@/hermes'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'providers' | 'sessions' | `config:${string}`
|
||||
export type SettingsView =
|
||||
| 'about'
|
||||
| 'gateway'
|
||||
| 'keys'
|
||||
| 'mcp'
|
||||
| 'notifications'
|
||||
| 'providers'
|
||||
| 'sessions'
|
||||
| `config:${string}`
|
||||
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
||||
|
||||
export interface SettingsPageProps {
|
||||
|
||||
@@ -80,6 +80,10 @@ export function AppShell({
|
||||
const connection = useStore($connection)
|
||||
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
|
||||
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
|
||||
// Every secondary window (new-session scratch, subagent watch, cmd-click
|
||||
// pop-out) is a compact side panel — none of them carry the full titlebar
|
||||
// tool cluster. Gate on isSecondaryWindow, never the narrower new-session flag.
|
||||
const hideTitlebarControls = isSecondaryWindow()
|
||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
|
||||
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
|
||||
// on macOS, where window controls sit on the left and are reported via
|
||||
@@ -162,7 +166,9 @@ export function AppShell({
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
|
||||
{!hideTitlebarControls && (
|
||||
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
|
||||
)}
|
||||
|
||||
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
|
||||
<PaneShell className="min-h-0 flex-1">
|
||||
@@ -183,7 +189,9 @@ export function AppShell({
|
||||
the panes' z-20 resize handles, keeping every pane resizable. */}
|
||||
{mainOverlays}
|
||||
|
||||
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
|
||||
{/* The compact pop-out drops the statusbar — it's a scratch window, not
|
||||
the full shell. */}
|
||||
{!isSecondaryWindow() && <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />}
|
||||
</main>
|
||||
|
||||
{overlays}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
@@ -9,7 +8,6 @@ import { useI18n } from '@/i18n'
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Command,
|
||||
Hash,
|
||||
@@ -19,7 +17,6 @@ import {
|
||||
Zap,
|
||||
ZapFilled
|
||||
} from '@/lib/icons'
|
||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -30,16 +27,11 @@ import {
|
||||
$activeSessionId,
|
||||
$busy,
|
||||
$connection,
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
$currentUsage,
|
||||
$sessionStartedAt,
|
||||
$turnStartedAt,
|
||||
$workingSessionIds,
|
||||
$yoloActive,
|
||||
setModelPickerOpen,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
|
||||
@@ -65,7 +57,6 @@ interface StatusbarItemsOptions {
|
||||
gatewayLogLines: readonly string[]
|
||||
gatewayState: string
|
||||
inferenceStatus: RuntimeReadinessResult | null
|
||||
modelMenuContent?: ReactNode
|
||||
openAgents: () => void
|
||||
openCommandCenterSection: (section: CommandCenterSection) => void
|
||||
freshDraftReady: boolean
|
||||
@@ -83,7 +74,6 @@ export function useStatusbarItems({
|
||||
gatewayLogLines,
|
||||
gatewayState,
|
||||
inferenceStatus,
|
||||
modelMenuContent,
|
||||
openAgents,
|
||||
openCommandCenterSection,
|
||||
freshDraftReady,
|
||||
@@ -97,10 +87,6 @@ export function useStatusbarItems({
|
||||
const terminalTakeover = useStore($terminalTakeover)
|
||||
const yoloActive = useStore($yoloActive)
|
||||
const busy = useStore($busy)
|
||||
const currentFastMode = useStore($currentFastMode)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const currentReasoningEffort = useStore($currentReasoningEffort)
|
||||
const currentUsage = useStore($currentUsage)
|
||||
const desktopActionTasks = useStore($desktopActionTasks)
|
||||
const previewServerRestartStatus = useStore($previewServerRestartStatus)
|
||||
@@ -416,37 +402,6 @@ export function useStatusbarItems({
|
||||
title: yoloActive ? copy.yoloOn : copy.yoloOff,
|
||||
variant: 'action'
|
||||
},
|
||||
{
|
||||
id: 'model-summary',
|
||||
label: (
|
||||
<span className="inline-flex min-w-0 items-center gap-0.5">
|
||||
<span className="truncate">
|
||||
{formatModelStatusLabel(currentModel, {
|
||||
fastMode: currentFastMode,
|
||||
reasoningEffort: currentReasoningEffort
|
||||
})}
|
||||
</span>
|
||||
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
|
||||
</span>
|
||||
),
|
||||
...(modelMenuContent
|
||||
? {
|
||||
menuAlign: 'end' as const,
|
||||
menuClassName: 'w-64',
|
||||
menuContent: modelMenuContent,
|
||||
title: currentProvider
|
||||
? copy.modelTitle(currentProvider, currentModel || copy.modelNone)
|
||||
: copy.switchModel,
|
||||
variant: 'menu' as const
|
||||
}
|
||||
: {
|
||||
onSelect: () => setModelPickerOpen(true),
|
||||
title: currentProvider
|
||||
? copy.providerModelTitle(currentProvider, currentModel || copy.noModel)
|
||||
: copy.openModelPicker,
|
||||
variant: 'action' as const
|
||||
})
|
||||
},
|
||||
{
|
||||
className: `w-7 justify-center px-0${terminalTakeover ? ' bg-accent/55 text-foreground' : ''}`,
|
||||
hidden: !chatOpen,
|
||||
@@ -465,11 +420,6 @@ export function useStatusbarItems({
|
||||
contextBar,
|
||||
contextUsage,
|
||||
copy,
|
||||
currentFastMode,
|
||||
currentModel,
|
||||
currentProvider,
|
||||
currentReasoningEffort,
|
||||
modelMenuContent,
|
||||
sessionStartedAt,
|
||||
showYoloToggle,
|
||||
terminalTakeover,
|
||||
|
||||
84
apps/desktop/src/app/shell/model-edit-submenu.test.tsx
Normal file
84
apps/desktop/src/app/shell/model-edit-submenu.test.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuSub, DropdownMenuSubTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { $modelPresets, getModelPreset } from '@/store/model-presets'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
|
||||
import { type FastControl, ModelEditSubmenu } from './model-edit-submenu'
|
||||
|
||||
// Radix calls these on open; jsdom doesn't implement them.
|
||||
beforeAll(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
Element.prototype.hasPointerCapture = vi.fn(() => false)
|
||||
Element.prototype.releasePointerCapture = vi.fn()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
$modelPresets.set({})
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Render the submenu inside an open menu/sub so its content (switches) mounts.
|
||||
function renderSubmenu(opts: { fastControl: FastControl; reasoning: boolean; requestGateway: () => Promise<unknown> }) {
|
||||
return render(
|
||||
<DropdownMenu open>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuSub open>
|
||||
<DropdownMenuSubTrigger>edit</DropdownMenuSubTrigger>
|
||||
<ModelEditSubmenu
|
||||
effort="medium"
|
||||
fastControl={opts.fastControl}
|
||||
isActive
|
||||
model="m1"
|
||||
onSelectModel={vi.fn()}
|
||||
provider="p1"
|
||||
reasoning={opts.reasoning}
|
||||
requestGateway={opts.requestGateway as never}
|
||||
/>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// Regression: editing the active row before a live session exists must stay
|
||||
// preset-only — the gateway's config.set falls back to global config when no
|
||||
// session matches, so it must not be called. (Caught in the second review.)
|
||||
describe('ModelEditSubmenu no-session guard', () => {
|
||||
it('param fast: records the preset but skips the gateway without a session', () => {
|
||||
const requestGateway = vi.fn().mockResolvedValue({})
|
||||
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(getModelPreset('p1', 'm1').fast).toBe(true)
|
||||
expect(requestGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reasoning: records the preset but skips the gateway without a session', () => {
|
||||
const requestGateway = vi.fn().mockResolvedValue({})
|
||||
renderSubmenu({ fastControl: { kind: 'none' }, reasoning: true, requestGateway })
|
||||
|
||||
// Thinking starts on (medium); toggling it off routes through patchReasoning.
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(getModelPreset('p1', 'm1').effort).toBe('none')
|
||||
expect(requestGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('param fast: pushes to the gateway once a session is active', async () => {
|
||||
const requestGateway = vi.fn().mockResolvedValue({})
|
||||
$activeSessionId.set('sess1')
|
||||
renderSubmenu({ fastControl: { kind: 'param', on: false }, reasoning: false, requestGateway })
|
||||
|
||||
fireEvent.click(screen.getByRole('switch'))
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith('config.set', { key: 'fast', session_id: 'sess1', value: 'fast' })
|
||||
})
|
||||
})
|
||||
@@ -12,13 +12,9 @@ import {
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { setModelPreset } from '@/store/model-presets'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentReasoningEffort,
|
||||
setCurrentFastMode,
|
||||
setCurrentReasoningEffort
|
||||
} from '@/store/session'
|
||||
import { $activeSessionId, setCurrentFastMode, setCurrentReasoningEffort } from '@/store/session'
|
||||
|
||||
// Hermes' real reasoning levels (see VALID_REASONING_EFFORTS); `none` is owned
|
||||
// by the Thinking toggle, not the radio.
|
||||
@@ -76,96 +72,104 @@ export function resolveFastControl(
|
||||
}
|
||||
|
||||
interface ModelEditSubmenuProps {
|
||||
/** This row's effective reasoning effort (live for the active model, else its
|
||||
* preset) — the submenu shows and edits from this, never the raw session. */
|
||||
effort: string
|
||||
/** How fast mode is offered for this model (param toggle vs. variant swap). */
|
||||
fastControl: FastControl
|
||||
/** Whether this row's model is the active one. */
|
||||
isActive: boolean
|
||||
/** Switch to this model (resolves false on failure). Awaited before applying
|
||||
* edits when not active so a failed switch doesn't write to the old model. */
|
||||
onActivate: () => Promise<boolean> | void
|
||||
/** This row's model id — edits persist as its global preset. */
|
||||
model: string
|
||||
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
|
||||
onSelectModel: (model: string) => Promise<boolean> | void
|
||||
/** This row's provider slug — edits persist as its global preset. */
|
||||
provider: string
|
||||
/** Whether this model supports reasoning effort. */
|
||||
reasoning: boolean
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
export function ModelEditSubmenu({
|
||||
effort,
|
||||
fastControl,
|
||||
isActive,
|
||||
onActivate,
|
||||
model,
|
||||
onSelectModel,
|
||||
provider,
|
||||
reasoning,
|
||||
requestGateway
|
||||
}: ModelEditSubmenuProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.shell.modelOptions
|
||||
// Reactive session state comes straight from the stores rather than being
|
||||
// drilled through the panel, so editing it re-renders only this submenu.
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const currentReasoningEffort = useStore($currentReasoningEffort)
|
||||
|
||||
const effort = normalizeEffort(currentReasoningEffort)
|
||||
const thinkingOn = isThinkingEnabled(currentReasoningEffort)
|
||||
const effortValue = normalizeEffort(effort)
|
||||
const thinkingOn = isThinkingEnabled(effort)
|
||||
|
||||
// Reasoning/fast are session-scoped (they apply to the active model), so
|
||||
// editing a non-active model first switches to it. Returns false if the
|
||||
// switch failed, so callers skip applying to the wrong (previous) model.
|
||||
const ensureActive = async (): Promise<boolean> => {
|
||||
if (isActive) {
|
||||
return true
|
||||
// Editing always records the model's global preset; the active model also gets
|
||||
// it pushed onto the live session. Non-active edits stay preset-only — they do
|
||||
// not switch you to that model.
|
||||
const patchReasoning = async (next: string) => {
|
||||
setModelPreset(provider, model, { effort: next })
|
||||
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
return (await onActivate()) !== false
|
||||
}
|
||||
|
||||
const patchReasoning = async (next: string, rollback: string) => {
|
||||
setCurrentReasoningEffort(next)
|
||||
|
||||
// Preset-only without a session: `isActive` holds for the global/default
|
||||
// row pre-session, and the gateway's `config.set` falls back to global
|
||||
// config when none matches — so don't reach it (preset + optimistic store
|
||||
// are the whole effect). Same guard in applyModelPreset / toggleFast.
|
||||
if (!activeSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!(await ensureActive())) {
|
||||
setCurrentReasoningEffort(rollback)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await requestGateway('config.set', {
|
||||
key: 'reasoning',
|
||||
session_id: activeSessionId ?? '',
|
||||
value: next
|
||||
})
|
||||
await requestGateway('config.set', { key: 'reasoning', session_id: activeSessionId, value: next })
|
||||
} catch (err) {
|
||||
setCurrentReasoningEffort(rollback)
|
||||
setCurrentReasoningEffort(effort)
|
||||
setModelPreset(provider, model, { effort })
|
||||
notifyError(err, copy.updateFailed)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFast = (enabled: boolean) => {
|
||||
if (fastControl.kind === 'variant') {
|
||||
// Fast is a separate model id — swap to it (or back to the base).
|
||||
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
|
||||
// Fast is a separate model id. Record the choice on the base model's
|
||||
// preset (selectFamily picks the `-fast` sibling later when set), and
|
||||
// only swap models now if this is the active row — inactive edits must
|
||||
// stay preset-only, same as the param path below.
|
||||
setModelPreset(provider, fastControl.baseId, { fast: enabled })
|
||||
|
||||
if (isActive) {
|
||||
void onSelectModel(enabled ? fastControl.fastId : fastControl.baseId)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (fastControl.kind === 'param') {
|
||||
setModelPreset(provider, model, { fast: enabled })
|
||||
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentFastMode(enabled)
|
||||
|
||||
// Preset-only without a session (see patchReasoning).
|
||||
if (!activeSessionId) {
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
if (!(await ensureActive())) {
|
||||
setCurrentFastMode(!enabled)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await requestGateway('config.set', {
|
||||
key: 'fast',
|
||||
session_id: activeSessionId ?? '',
|
||||
value: enabled ? 'fast' : 'normal'
|
||||
})
|
||||
await requestGateway('config.set', { key: 'fast', session_id: activeSessionId, value: enabled ? 'fast' : 'normal' })
|
||||
} catch (err) {
|
||||
setCurrentFastMode(!enabled)
|
||||
setModelPreset(provider, model, { fast: !enabled })
|
||||
notifyError(err, copy.fastFailed)
|
||||
}
|
||||
})()
|
||||
@@ -188,9 +192,7 @@ export function ModelEditSubmenu({
|
||||
<Switch
|
||||
checked={thinkingOn}
|
||||
className="ml-auto"
|
||||
onCheckedChange={checked =>
|
||||
void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)
|
||||
}
|
||||
onCheckedChange={checked => void patchReasoning(checked ? effortValue || 'medium' : 'none')}
|
||||
size="xs"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
@@ -205,10 +207,7 @@ export function ModelEditSubmenu({
|
||||
<>
|
||||
<DropdownMenuSeparator className="mx-0" />
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.effort}</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={value => void patchReasoning(value, currentReasoningEffort)}
|
||||
value={effort}
|
||||
>
|
||||
<DropdownMenuRadioGroup onValueChange={value => void patchReasoning(value)} value={effortValue}>
|
||||
{EFFORT_OPTIONS.map(option => (
|
||||
<DropdownMenuRadioItem
|
||||
className={dropdownMenuRow}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { createContext, useContext, useMemo, useState } from 'react'
|
||||
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
@@ -18,8 +18,9 @@ import { Skeleton } from '@/components/ui/skeleton'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { getGlobalModelOptions } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
|
||||
import { currentPickerSelection, displayModelName, modelDisplayParts, reasoningEffortLabel } from '@/lib/model-status-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $modelPresets, applyModelPreset, modelPresetKey } from '@/store/model-presets'
|
||||
import {
|
||||
$visibleModels,
|
||||
collapseModelFamilies,
|
||||
@@ -40,9 +41,14 @@ import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { ModelEditSubmenu, resolveFastControl } from './model-edit-submenu'
|
||||
|
||||
// Lets the host dropdown (model-pill) hand the panel a way to dismiss itself so
|
||||
// clicking a model row commits + closes, while the hover-revealed edit submenu
|
||||
// (reasoning/fast) stays open to play with (its items preventDefault on select).
|
||||
export const ModelMenuCloseContext = createContext<() => void>(() => {})
|
||||
|
||||
interface ModelMenuPanelProps {
|
||||
gateway?: HermesGateway
|
||||
onSelectModel: (selection: { model: string; persistGlobal: boolean; provider: string }) => Promise<boolean> | void
|
||||
onSelectModel: (selection: { model: string; provider: string }) => Promise<boolean> | void
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}
|
||||
|
||||
@@ -54,6 +60,7 @@ interface ProviderGroup {
|
||||
export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.shell.modelMenu
|
||||
const closeMenu = useContext(ModelMenuCloseContext)
|
||||
const [search, setSearch] = useState('')
|
||||
// Reactive session state is read from the stores here (not drilled in), so
|
||||
// toggling effort/fast/model re-renders this panel in place without forcing
|
||||
@@ -63,6 +70,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const currentReasoningEffort = useStore($currentReasoningEffort)
|
||||
const modelPresets = useStore($modelPresets)
|
||||
const visibleModels = useStore($visibleModels)
|
||||
|
||||
const modelOptions = useQuery({
|
||||
@@ -76,8 +84,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
}
|
||||
})
|
||||
|
||||
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
|
||||
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
|
||||
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
|
||||
!!activeSessionId,
|
||||
{ model: currentModel, provider: currentProvider },
|
||||
modelOptions.data
|
||||
)
|
||||
|
||||
const loading = modelOptions.isPending && !modelOptions.data
|
||||
|
||||
const error = modelOptions.error
|
||||
@@ -87,13 +99,41 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
: null
|
||||
|
||||
const providers = modelOptions.data?.providers
|
||||
|
||||
const effectiveVisibleModels = useMemo(
|
||||
() => effectiveVisibleKeys(visibleModels, providers ?? []),
|
||||
[visibleModels, providers]
|
||||
)
|
||||
|
||||
const switchTo = (model: string, provider: string) =>
|
||||
onSelectModel({ model, persistGlobal: !activeSessionId, provider })
|
||||
// The composer picker never persists the profile default. With a session it
|
||||
// scopes the switch to that session; with none it's UI state shipped on the
|
||||
// next session.create (see selectModel). The default lives in Settings → Model.
|
||||
const switchTo = (model: string, provider: string) => onSelectModel({ model, provider })
|
||||
|
||||
// Selecting a model row restores that model's remembered preset onto the
|
||||
// session (effort/fast), gated by capability. Unset → Hermes defaults.
|
||||
const selectFamily = async (family: ModelFamily, provider: ModelOptionProvider) => {
|
||||
const caps = provider.capabilities?.[family.id]
|
||||
const preset = modelPresets[modelPresetKey(provider.slug, family.id)] ?? {}
|
||||
|
||||
// Variant-fast models (no speed param) express "fast" as a separate `-fast`
|
||||
// id, so honor the saved preset by selecting that sibling. Param-fast is
|
||||
// applied via applyModelPreset below instead.
|
||||
const variantFast = !(caps?.fast ?? false) && !!family.fastId
|
||||
const targetId = variantFast && preset.fast === true ? family.fastId! : family.id
|
||||
|
||||
if ((await switchTo(targetId, provider.slug)) === false) {
|
||||
return
|
||||
}
|
||||
|
||||
await applyModelPreset(
|
||||
{
|
||||
effort: (caps?.reasoning ?? true) ? (preset.effort ?? 'medium') : undefined,
|
||||
fast: (caps?.fast ?? false) ? (preset.fast ?? false) : undefined
|
||||
},
|
||||
{ failMessage: t.shell.modelOptions.updateFailed, request: requestGateway, sessionId: activeSessionId }
|
||||
)
|
||||
}
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels),
|
||||
@@ -152,37 +192,42 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
// -fast variant carries the same param support as its base.
|
||||
const caps = group.provider.capabilities?.[family.id]
|
||||
|
||||
// Single source of truth for the active row's fast state — keeps
|
||||
// the row label in lock-step with the submenu's Fast toggle and
|
||||
// handles the standalone `-fast` id case.
|
||||
// Effective settings for this row: live session state when it's
|
||||
// the active model, otherwise its remembered preset (Hermes
|
||||
// defaults when unset). Row label AND submenu read from these so
|
||||
// they never disagree.
|
||||
const preset = modelPresets[modelPresetKey(group.provider.slug, family.id)] ?? {}
|
||||
const effEffort = isCurrent ? currentReasoningEffort : preset.effort ?? ''
|
||||
const effFast = isCurrent ? currentFastMode : preset.fast ?? false
|
||||
|
||||
const fastControl = resolveFastControl(
|
||||
activeId ?? family.id,
|
||||
group.provider.models ?? [],
|
||||
caps?.fast ?? false,
|
||||
currentFastMode
|
||||
effFast
|
||||
)
|
||||
|
||||
// Grayed text is live session state only. Do not label inactive
|
||||
// rows as "Fast" just because they have a fast-capable sibling:
|
||||
// that makes an off Fast toggle look like it is already on.
|
||||
const meta = isCurrent
|
||||
? [
|
||||
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
|
||||
reasoningEffortLabel(currentReasoningEffort) || copy.medium
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: ''
|
||||
const meta = [
|
||||
fastControl.kind !== 'none' && fastControl.on ? copy.fast : null,
|
||||
(caps?.reasoning ?? true) ? reasoningEffortLabel(effEffort) || copy.medium : null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
// Every row is a hover-Edit submenu trigger. Activating it
|
||||
// (pointer or keyboard) switches to the family's base model;
|
||||
// the Fast toggle inside swaps to the -fast sibling (or flips
|
||||
// the speed param). The sub-trigger has no `onSelect`, so wire
|
||||
// both click and Enter/Space for keyboard parity.
|
||||
// (pointer or keyboard) switches to the family's base model and
|
||||
// restores its preset; the Fast toggle inside swaps to the -fast
|
||||
// sibling (or flips the speed param). The sub-trigger has no
|
||||
// `onSelect`, so wire both click and Enter/Space for keyboard parity.
|
||||
// Clicking the row commits the model and closes the picker; the
|
||||
// edit submenu (reasoning/fast) is reached by HOVER, so you can
|
||||
// still tweak those without the click dismissing everything.
|
||||
const activate = () => {
|
||||
if (!isCurrent) {
|
||||
void switchTo(family.id, group.provider.slug)
|
||||
void selectFamily(family, group.provider)
|
||||
}
|
||||
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -204,10 +249,12 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
{isCurrent ? <Codicon className="ml-auto text-foreground" name="check" size="0.75rem" /> : null}
|
||||
</DropdownMenuSubTrigger>
|
||||
<ModelEditSubmenu
|
||||
effort={effEffort}
|
||||
fastControl={fastControl}
|
||||
isActive={isCurrent}
|
||||
onActivate={() => switchTo(family.id, group.provider.slug)}
|
||||
model={family.id}
|
||||
onSelectModel={nextModel => switchTo(nextModel, group.provider.slug)}
|
||||
provider={group.provider.slug}
|
||||
reasoning={caps?.reasoning ?? true}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
|
||||
@@ -46,6 +46,12 @@ export interface SlashExecResponse {
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export interface BrowserManageResponse {
|
||||
connected?: boolean
|
||||
url?: string
|
||||
messages?: string[]
|
||||
}
|
||||
|
||||
export interface SessionSteerResponse {
|
||||
// 'queued' == accepted into the live turn's steer slot (injected at the next
|
||||
// tool-result boundary); 'rejected' == no live tool window, caller queues.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { type ToolCallMessagePartProps } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { type FormEvent, type KeyboardEvent, useCallback, useMemo, useRef, useState, type ComponentProps } from 'react'
|
||||
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool-fallback'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -36,14 +36,30 @@ function readClarifyArgs(args: unknown): ClarifyArgs {
|
||||
}
|
||||
|
||||
// Choice and "Other" rows share a layout; only color/hover differs.
|
||||
const OPTION_ROW_CLASS = 'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors'
|
||||
const OPTION_ROW_CLASS = 'flex w-full items-start gap-2 rounded-md px-2.5 py-1.5 text-left text-sm transition-colors'
|
||||
|
||||
const CLARIFY_SHELL_CLASS =
|
||||
'relative mb-3 mt-2 rounded-[0.5rem] border border-border/70 bg-card/40 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]'
|
||||
|
||||
function ClarifyShell({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<'div'>) {
|
||||
return (
|
||||
<div className={cn(CLARIFY_SHELL_CLASS, className)} data-slot="clarify-inline" {...props}>
|
||||
<span aria-hidden className="arc-border" />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioDot({ selected }: { selected: boolean }) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors',
|
||||
'mt-0.5 grid size-3.5 shrink-0 place-items-center rounded-full border transition-colors',
|
||||
selected ? 'border-primary' : 'border-muted-foreground/40'
|
||||
)}
|
||||
>
|
||||
@@ -99,9 +115,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// Race: tool.start fires a tick before clarify.request, so request_id
|
||||
// arrives slightly after the tool block mounts. Show the question (from
|
||||
// args) but disable submit until we have the request id from the gateway.
|
||||
// arrives slightly after the tool block mounts. Hold the whole panel on a
|
||||
// spinner until the gateway request is wired — showing disabled choices or
|
||||
// a "loading question" stub is worse than a brief wait.
|
||||
const ready = Boolean(matchingRequest?.requestId)
|
||||
const loading = !ready && !submitting
|
||||
|
||||
const respond = useCallback(
|
||||
async (answer: string) => {
|
||||
@@ -138,7 +156,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
|
||||
const handleTextareaKey = useCallback(
|
||||
(event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
if (event.nativeEvent.isComposing) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
const trimmed = draft.trim()
|
||||
|
||||
@@ -162,12 +184,20 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
[draft, respond]
|
||||
)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ClarifyShell
|
||||
aria-label={copy.loadingQuestion}
|
||||
className="grid min-h-24 place-items-center px-3 py-6"
|
||||
role="status"
|
||||
>
|
||||
<Loader2 aria-hidden className="size-5 animate-spin text-muted-foreground/80" />
|
||||
</ClarifyShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative mb-3 mt-2 grid gap-6 rounded-[0.5rem] border border-border/70 bg-card/40 px-3 py-2.5 text-sm shadow-[inset_0_1px_0_color-mix(in_srgb,var(--foreground)_3%,transparent)]"
|
||||
data-slot="clarify-inline"
|
||||
>
|
||||
<span aria-hidden className="arc-border" />
|
||||
<ClarifyShell className="grid gap-6 px-3 py-2.5">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
@@ -175,9 +205,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
>
|
||||
<HelpCircle className="size-3.5" />
|
||||
</span>
|
||||
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">
|
||||
{question || <em className="font-normal text-muted-foreground/70">{copy.loadingQuestion}</em>}
|
||||
</span>
|
||||
<span className="flex-1 whitespace-pre-wrap font-medium leading-snug text-foreground">{question}</span>
|
||||
</div>
|
||||
|
||||
{!typing && hasChoices && (
|
||||
@@ -190,7 +218,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
selectedChoice === choice && 'bg-accent/60'
|
||||
)}
|
||||
data-choice
|
||||
disabled={!ready || submitting}
|
||||
disabled={submitting}
|
||||
key={`${index}-${choice}`}
|
||||
onClick={() => {
|
||||
setSelectedChoice(choice)
|
||||
@@ -200,7 +228,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
>
|
||||
<RadioDot selected={selectedChoice === choice} />
|
||||
<span className="flex-1 wrap-anywhere">{choice}</span>
|
||||
{selectedChoice === choice && <Check aria-hidden className="size-4 shrink-0 text-primary" />}
|
||||
{selectedChoice === choice && <Check aria-hidden className="mt-0.5 size-4 shrink-0 text-primary" />}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
@@ -231,8 +259,9 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-1 text-[0.6875rem] text-muted-foreground/85">
|
||||
<KbdCombo combo="mod+enter" size="sm" />
|
||||
{copy.shortcutSuffix}
|
||||
<KbdCombo combo="enter" size="sm" />
|
||||
<KbdCombo combo="shift+enter" size="sm" />
|
||||
{t.composer.hotkeyDescs['composer.sendNewline']}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasChoices && (
|
||||
@@ -249,16 +278,10 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
{copy.back}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={!ready || submitting}
|
||||
onClick={() => void respond('')}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Button disabled={submitting} onClick={() => void respond('')} size="sm" type="button" variant="ghost">
|
||||
{copy.skip}
|
||||
</Button>
|
||||
<Button disabled={!ready || submitting || !draft.trim()} size="sm" type="submit">
|
||||
<Button disabled={submitting || !draft.trim()} size="sm" type="submit">
|
||||
{submitting ? <Loader2 className="size-3.5 animate-spin" /> : copy.send}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -270,7 +293,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="-mr-2"
|
||||
disabled={!ready || submitting}
|
||||
disabled={submitting}
|
||||
onClick={() => void respond('')}
|
||||
size="xs"
|
||||
type="button"
|
||||
@@ -280,6 +303,6 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ClarifyShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
memo,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
resetThreadScroll,
|
||||
setThreadAtBottom
|
||||
} from '@/store/thread-scroll'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { MessageRenderBoundary } from './message-render-boundary'
|
||||
|
||||
@@ -132,6 +134,20 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
const hiddenCount = firstVisible
|
||||
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
|
||||
const restoreFromBottomRef = useRef<number | null>(null)
|
||||
// Secondary windows (new-session scratch, subagent watch, cmd-click pop-out)
|
||||
// hide the titlebar tool cluster + session header, but the OS traffic lights
|
||||
// still sit in the top-left, so reserve the titlebar gap above the transcript.
|
||||
const secondaryWindow = isSecondaryWindow()
|
||||
// NB: CSS calc() requires whitespace around the +/- operator. This string is
|
||||
// assigned verbatim to the --sticky-human-top inline style below (it does not
|
||||
// go through Tailwind, which would auto-space it), so the spaces are load-
|
||||
// bearing — without them the declaration is invalid, gets dropped, and the
|
||||
// sticky user bubble falls back to its ~4px default and slides under the OS
|
||||
// traffic lights.
|
||||
const secondaryTitlebarGap = 'calc(var(--titlebar-height) + 0.75rem)'
|
||||
const threadContentTopPad = secondaryWindow
|
||||
? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
|
||||
: 'pt-[calc(var(--titlebar-height)-0.5rem)]'
|
||||
|
||||
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
|
||||
useEffect(() => () => resetThreadScroll(), [])
|
||||
@@ -235,8 +251,24 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
return (
|
||||
<div
|
||||
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
|
||||
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
|
||||
style={
|
||||
{
|
||||
height: clampToComposer ? 'var(--thread-viewport-height)' : '100%',
|
||||
...(secondaryWindow ? { '--sticky-human-top': secondaryTitlebarGap } : {})
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{secondaryWindow && (
|
||||
// Secondary windows hide the titlebar chrome, so the scroller runs to
|
||||
// the window's top edge and streamed text slides up under the OS
|
||||
// traffic lights. Content padding alone scrolls away with the text — a
|
||||
// fixed opaque strip (the titlebar's drag region) masks anything behind
|
||||
// it and keeps the window draggable, matching the main window's header.
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 top-0 z-10 h-(--titlebar-height) bg-background [-webkit-app-region:drag]"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
data-following={isAtBottom ? 'true' : 'false'}
|
||||
@@ -252,9 +284,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
|
||||
)}
|
||||
className={cn('mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6', threadContentTopPad)}
|
||||
data-slot="aui_thread-content"
|
||||
ref={contentRef as React.RefCallback<HTMLDivElement>}
|
||||
>
|
||||
|
||||
@@ -96,6 +96,7 @@ import { extractPreviewTargets } from '@/lib/preview-targets'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||
import { $compactionActive } from '@/store/compaction'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $connection } from '@/store/session'
|
||||
@@ -273,10 +274,7 @@ const AssistantMessage: FC<{ onBranchInNewChat?: (messageId: string) => void }>
|
||||
return pickPrimaryPreviewTarget(extractPreviewTargets(completedText))
|
||||
}, [completedText])
|
||||
|
||||
const getMessageText = useCallback(
|
||||
() => messageContentText(messageRuntime.getState().content),
|
||||
[messageRuntime]
|
||||
)
|
||||
const getMessageText = useCallback(() => messageContentText(messageRuntime.getState().content), [messageRuntime])
|
||||
|
||||
const enterRef = useEnterAnimation(isRunning, `assistant-message:${messageId}`)
|
||||
|
||||
@@ -339,13 +337,25 @@ const StatusRow: FC<{ children: ReactNode; label: string } & React.ComponentProp
|
||||
</div>
|
||||
)
|
||||
|
||||
// Fixed label while auto-compaction runs — decoupled from backend status text.
|
||||
const COMPACTION_LABEL = 'Summarizing thread'
|
||||
|
||||
const CompactionHint: FC = () => (
|
||||
<span className="shimmer min-w-0 truncate text-muted-foreground/55">{COMPACTION_LABEL}</span>
|
||||
)
|
||||
|
||||
const ResponseLoadingIndicator: FC = () => {
|
||||
const { t } = useI18n()
|
||||
const elapsed = useElapsedSeconds()
|
||||
const compacting = useStore($compactionActive)
|
||||
|
||||
return (
|
||||
<StatusRow data-slot="aui_response-loading" label={t.assistant.thread.loadingResponse}>
|
||||
<StatusRow
|
||||
data-slot="aui_response-loading"
|
||||
label={compacting ? COMPACTION_LABEL : t.assistant.thread.loadingResponse}
|
||||
>
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
{compacting && <CompactionHint />}
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
@@ -380,6 +390,7 @@ const StreamStallIndicator: FC = () => {
|
||||
})
|
||||
|
||||
const [stalled, setStalled] = useState(false)
|
||||
const compacting = useStore($compactionActive)
|
||||
|
||||
useEffect(() => {
|
||||
setStalled(false)
|
||||
@@ -388,15 +399,21 @@ const StreamStallIndicator: FC = () => {
|
||||
return () => window.clearTimeout(id)
|
||||
}, [activity])
|
||||
|
||||
const elapsed = useElapsedSeconds(stalled)
|
||||
const active = stalled || compacting
|
||||
const elapsed = useElapsedSeconds(active)
|
||||
|
||||
if (!stalled) {
|
||||
if (!active) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusRow className="mt-1.5" data-slot="aui_stream-stall" label="Hermes is thinking">
|
||||
<StatusRow
|
||||
className="mt-1.5"
|
||||
data-slot="aui_stream-stall"
|
||||
label={compacting ? COMPACTION_LABEL : 'Hermes is thinking'}
|
||||
>
|
||||
<span aria-hidden="true" className="dither inline-block size-3 rounded-[2px] text-midground/80 animate-pulse" />
|
||||
{compacting && <CompactionHint />}
|
||||
<ActivityTimerText seconds={elapsed} />
|
||||
</StatusRow>
|
||||
)
|
||||
@@ -571,10 +588,7 @@ const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ te
|
||||
|
||||
return (
|
||||
<MarkdownTextContent
|
||||
containerClassName={cn(
|
||||
'text-xs leading-snug text-muted-foreground/85',
|
||||
isRunning && 'shimmer text-muted-foreground/55'
|
||||
)}
|
||||
containerClassName="text-xs leading-snug text-muted-foreground/85"
|
||||
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
|
||||
isRunning={isRunning}
|
||||
text={displayText}
|
||||
|
||||
@@ -180,7 +180,7 @@ const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
|
||||
|
||||
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
|
||||
|
||||
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
|
||||
export const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
|
||||
const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
|
||||
|
||||
export const sortProviders = (providers: OAuthProvider[]) =>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { currentPickerSelection } from '@/lib/model-status-label'
|
||||
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
|
||||
|
||||
import type { HermesGateway } from '../hermes'
|
||||
@@ -11,7 +12,6 @@ import { startManualOnboarding } from '../store/onboarding'
|
||||
|
||||
import { InlineNotice } from './notifications'
|
||||
import { Button } from './ui/button'
|
||||
import { Checkbox } from './ui/checkbox'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog'
|
||||
import { Skeleton } from './ui/skeleton'
|
||||
@@ -23,7 +23,7 @@ interface ModelPickerDialogProps {
|
||||
sessionId?: string | null
|
||||
currentModel: string
|
||||
currentProvider: string
|
||||
onSelect: (selection: { provider: string; model: string; persistGlobal: boolean }) => void
|
||||
onSelect: (selection: { provider: string; model: string }) => void
|
||||
/**
|
||||
* Optional class to apply to DialogContent. Use to override z-index when
|
||||
* stacking the picker on top of another fixed overlay (e.g. the desktop
|
||||
@@ -45,7 +45,6 @@ export function ModelPickerDialog({
|
||||
}: ModelPickerDialogProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.modelPicker
|
||||
const [persistGlobal, setPersistGlobal] = useState(!sessionId)
|
||||
// Own the search term so we can filter manually. cmdk's built-in
|
||||
// shouldFilter reorders items by its fuzzy-match score (≈alphabetical with
|
||||
// an empty query), which destroys the backend's curated order. We disable
|
||||
@@ -68,8 +67,13 @@ export function ModelPickerDialog({
|
||||
})
|
||||
|
||||
const providers = modelOptions.data?.providers ?? []
|
||||
const optionsModel = String(modelOptions.data?.model ?? currentModel ?? '')
|
||||
const optionsProvider = String(modelOptions.data?.provider ?? currentProvider ?? '')
|
||||
|
||||
const { model: optionsModel, provider: optionsProvider } = currentPickerSelection(
|
||||
!!sessionId,
|
||||
{ model: currentModel, provider: currentProvider },
|
||||
modelOptions.data
|
||||
)
|
||||
|
||||
const loading = modelOptions.isPending && !modelOptions.data
|
||||
|
||||
const error = modelOptions.error
|
||||
@@ -79,11 +83,7 @@ export function ModelPickerDialog({
|
||||
: null
|
||||
|
||||
const selectModel = (provider: ModelOptionProvider, model: string) => {
|
||||
onSelect({
|
||||
provider: provider.slug,
|
||||
model,
|
||||
persistGlobal: persistGlobal || !sessionId
|
||||
})
|
||||
onSelect({ provider: provider.slug, model })
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
@@ -128,24 +128,13 @@ export function ModelPickerDialog({
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
<DialogFooter className="flex-row items-center justify-between gap-3 bg-card p-3 sm:justify-between">
|
||||
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={persistGlobal || !sessionId}
|
||||
disabled={!sessionId}
|
||||
onCheckedChange={checked => setPersistGlobal(checked === true)}
|
||||
/>
|
||||
{sessionId ? copy.persistGlobalSession : copy.persistGlobal}
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={addProvider} variant="ghost">
|
||||
{copy.addProvider}
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)} variant="outline">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
<DialogFooter className="flex-row items-center justify-end gap-2 bg-card p-3">
|
||||
<Button onClick={addProvider} variant="ghost">
|
||||
{copy.addProvider}
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)} variant="outline">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -80,9 +80,12 @@ const HOVER_REVEAL_EASE = 'cubic-bezier(0.32,0.72,0,1)'
|
||||
// Offset shadow lifting the revealed panel off the content (same both sides;
|
||||
// the mirror axis is offset-x, which is 0). Same color on light + dark.
|
||||
const HOVER_REVEAL_SHADOW = '0px -18px 18px -5px #00000012'
|
||||
// Edge trigger strip, inset past the OS window-resize grab area.
|
||||
// Edge trigger strip, inset past the OS window-resize grab area AND the
|
||||
// adjacent pane's scrollbar (0.5rem, .scrollbar-dt) — the strip overlays the
|
||||
// neighboring scroller's edge, so any overlap makes the scrollbar reveal the
|
||||
// pane on hover and swallow its clicks (#44140).
|
||||
const HOVER_REVEAL_TRIGGER_WIDTH = 14
|
||||
const HOVER_REVEAL_EDGE_GUTTER = 6
|
||||
const HOVER_REVEAL_EDGE_GUTTER = 'calc(0.5rem + 2px)'
|
||||
|
||||
// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal
|
||||
// from the keyboard, since its store-open toggle is a no-op while collapsed.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ContextMenuItem } from '@/components/ui/context-menu'
|
||||
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
@@ -9,7 +10,7 @@ import { Check, Copy, X } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type CopyPayload = string | (() => Promise<string> | string)
|
||||
type CopyButtonAppearance = 'button' | 'icon' | 'inline' | 'menu-item' | 'tool-row'
|
||||
type CopyButtonAppearance = 'button' | 'icon' | 'inline' | 'menu-item' | 'context-menu-item' | 'tool-row'
|
||||
type CopyStatus = 'copied' | 'error' | 'idle'
|
||||
const COPIED_RESET_MS = 1_500
|
||||
|
||||
@@ -159,9 +160,11 @@ export function CopyButton({
|
||||
status === 'copied' ? t.common.copied : status === 'error' ? resolvedErrorMessage : (title ?? resolvedLabel)
|
||||
const ariaLabel = status === 'idle' ? resolvedLabel : feedbackLabel
|
||||
|
||||
if (appearance === 'menu-item') {
|
||||
if (appearance === 'menu-item' || appearance === 'context-menu-item') {
|
||||
const MenuItem = appearance === 'menu-item' ? DropdownMenuItem : ContextMenuItem
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
<MenuItem
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
onSelect={event => {
|
||||
@@ -170,7 +173,7 @@ export function CopyButton({
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</DropdownMenuItem>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
7
apps/desktop/src/global.d.ts
vendored
7
apps/desktop/src/global.d.ts
vendored
@@ -24,6 +24,8 @@ declare global {
|
||||
// a spectator window (lazy resume — no agent build) for live-streaming
|
||||
// a running subagent's session.
|
||||
openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }>
|
||||
// Open (or focus) a compact secondary window on the new-session draft.
|
||||
openNewSessionWindow: () => Promise<{ ok: boolean; error?: string }>
|
||||
getBootProgress: () => Promise<DesktopBootProgress>
|
||||
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
|
||||
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
@@ -88,6 +90,8 @@ declare global {
|
||||
) => () => void
|
||||
signalDeepLinkReady?: () => Promise<{ ok: boolean }>
|
||||
onWindowStateChanged?: (callback: (payload: HermesWindowState) => void) => () => void
|
||||
onFocusSession?: (callback: (sessionId: string) => void) => () => void
|
||||
onNotificationAction?: (callback: (payload: { actionId: string; sessionId?: string }) => void) => () => void
|
||||
onPreviewFileChanged: (callback: (payload: HermesPreviewFileChanged) => void) => () => void
|
||||
onBackendExit: (callback: (payload: BackendExit) => void) => () => void
|
||||
onPowerResume?: (callback: () => void) => () => void
|
||||
@@ -413,6 +417,9 @@ export interface HermesNotification {
|
||||
title?: string
|
||||
body?: string
|
||||
silent?: boolean
|
||||
kind?: string
|
||||
sessionId?: string
|
||||
actions?: { id: string; text: string }[]
|
||||
}
|
||||
|
||||
export interface HermesPreviewTarget {
|
||||
|
||||
@@ -393,6 +393,14 @@ export function listOAuthProviders(): Promise<OAuthProvidersResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
export function disconnectOAuthProvider(providerId: string): Promise<{ ok: boolean; provider: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; provider: string }>({
|
||||
...profileScoped(),
|
||||
path: `/api/providers/oauth/${encodeURIComponent(providerId)}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
export function startOAuthLogin(providerId: string): Promise<OAuthStartResponse> {
|
||||
return window.hermesDesktop.api<OAuthStartResponse>({
|
||||
...profileScoped(),
|
||||
|
||||
@@ -131,6 +131,18 @@ export const en: Translations = {
|
||||
transcriptionUnavailable: 'Voice transcription is not available yet.',
|
||||
tryRecordingAgain: 'Try recording again.',
|
||||
unavailable: 'Voice unavailable'
|
||||
},
|
||||
native: {
|
||||
approvalTitle: 'Approval needed',
|
||||
approveAction: 'Approve',
|
||||
rejectAction: 'Reject',
|
||||
inputTitle: 'Input needed',
|
||||
inputBody: 'Hermes is waiting for your response.',
|
||||
turnDoneTitle: 'Hermes finished',
|
||||
turnDoneBody: 'The response is ready.',
|
||||
turnErrorTitle: 'Turn failed',
|
||||
backgroundDoneTitle: 'Background task finished',
|
||||
backgroundFailedTitle: 'Background task failed'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -177,6 +189,7 @@ export const en: Translations = {
|
||||
'nav.cron': 'Open scheduled jobs',
|
||||
'nav.agents': 'Open agents',
|
||||
'session.new': 'New session',
|
||||
'session.newWindow': 'New session in window',
|
||||
'session.next': 'Next session',
|
||||
'session.prev': 'Previous session',
|
||||
'session.slot.1': 'Switch to recent session 1',
|
||||
@@ -263,7 +276,46 @@ export const en: Translations = {
|
||||
keysSettings: 'Settings',
|
||||
mcp: 'MCP',
|
||||
archivedChats: 'Archived Chats',
|
||||
about: 'About'
|
||||
about: 'About',
|
||||
notifications: 'Notifications'
|
||||
},
|
||||
notifications: {
|
||||
title: 'Notifications',
|
||||
intro:
|
||||
'Native desktop notifications, separate from in-app toasts. These are device-local — each computer keeps its own settings.',
|
||||
enableAll: 'Enable notifications',
|
||||
enableAllDesc: 'Master switch. Turn this off to silence every notification below.',
|
||||
focusedHint: 'Completion alerts only fire while Hermes is in the background.',
|
||||
kinds: {
|
||||
approval: {
|
||||
label: 'Approval needed',
|
||||
description: 'A command is waiting for you to approve or reject it.'
|
||||
},
|
||||
input: {
|
||||
label: 'Input needed',
|
||||
description: 'Hermes asked a question or needs a password or secret.'
|
||||
},
|
||||
turnDone: {
|
||||
label: 'Response ready',
|
||||
description: 'A turn finished while Hermes was in the background.'
|
||||
},
|
||||
turnError: {
|
||||
label: 'Turn failed',
|
||||
description: 'A turn ended with an error.'
|
||||
},
|
||||
backgroundDone: {
|
||||
label: 'Background task finished',
|
||||
description: 'A backgrounded terminal command completed.'
|
||||
}
|
||||
},
|
||||
test: 'Send test notification',
|
||||
testTitle: 'Hermes',
|
||||
testBody: 'Notifications are working.',
|
||||
testSent: 'Test sent. If nothing appears, check your OS notification permissions and Focus/Do Not Disturb.',
|
||||
testUnsupported: 'This system does not support native notifications.',
|
||||
completionSoundTitle: 'Completion Sound',
|
||||
completionSoundDesc: 'Plays when an agent turn finishes. Pick a preset and preview it here.',
|
||||
completionSoundPreview: 'Preview'
|
||||
},
|
||||
sections: {
|
||||
model: 'Model',
|
||||
@@ -486,6 +538,10 @@ export const en: Translations = {
|
||||
provider: 'Provider',
|
||||
model: 'Model',
|
||||
applying: 'Applying...',
|
||||
defaultsLabel: 'Defaults',
|
||||
reasoning: 'Reasoning',
|
||||
reasoningOff: 'Off',
|
||||
defaultsFailed: 'Failed to save model defaults',
|
||||
auxiliaryTitle: 'Auxiliary models',
|
||||
resetAllToMain: 'Reset all to main',
|
||||
auxiliaryDesc: 'Helper tasks run on the main model by default. Assign a dedicated model to any task to override.',
|
||||
@@ -513,6 +569,17 @@ export const en: Translations = {
|
||||
collapse: 'Collapse',
|
||||
connectAnother: 'Connect another provider',
|
||||
otherProviders: 'Other providers',
|
||||
disconnect: 'Disconnect',
|
||||
disconnectInTerminal: 'Disconnect (runs the removal command in the terminal)',
|
||||
removeConfirm: provider => `Remove ${provider}?`,
|
||||
removeExternalGeneric: provider => `${provider} is managed by its own CLI — remove it there.`,
|
||||
removeKeyManaged: provider => `${provider} is configured from an API key. Remove it from API Keys.`,
|
||||
removeTerminalConfirm: (provider, command) =>
|
||||
`Disconnect ${provider}? This runs "${command}" in the terminal to clear the credential.`,
|
||||
removeTerminalRunning: provider => `Running ${provider} disconnect in the terminal…`,
|
||||
removedTitle: 'Account removed',
|
||||
removedMessage: provider => `${provider} was removed.`,
|
||||
failedRemove: provider => `Could not remove ${provider}`,
|
||||
noProviderKeys: 'No provider API keys available.',
|
||||
loading: 'Loading providers...'
|
||||
},
|
||||
@@ -1440,8 +1507,6 @@ export const en: Translations = {
|
||||
unknown: '(unknown)',
|
||||
search: 'Filter providers and models...',
|
||||
noModels: 'No models found.',
|
||||
persistGlobalSession: 'Persist globally (otherwise this session only)',
|
||||
persistGlobal: 'Persist globally',
|
||||
addProvider: 'Add provider',
|
||||
loadFailed: 'Could not load models',
|
||||
noAuthenticatedProviders: 'No authenticated providers.',
|
||||
@@ -1694,6 +1759,7 @@ export const en: Translations = {
|
||||
moreOptions: 'More approval options',
|
||||
allowSession: 'Allow this session',
|
||||
alwaysAllowMenu: 'Always allow…',
|
||||
jumpToApproval: 'Approval needed',
|
||||
reject: 'Reject',
|
||||
alwaysTitle: 'Always allow this command?',
|
||||
alwaysDescription: pattern =>
|
||||
|
||||
@@ -132,6 +132,18 @@ export const ja = defineLocale({
|
||||
transcriptionUnavailable: '音声文字起こしはまだ利用できません。',
|
||||
tryRecordingAgain: 'もう一度録音してください。',
|
||||
unavailable: '音声は利用できません'
|
||||
},
|
||||
native: {
|
||||
approvalTitle: '承認が必要です',
|
||||
approveAction: '承認',
|
||||
rejectAction: '拒否',
|
||||
inputTitle: '入力が必要です',
|
||||
inputBody: 'Hermes が応答を待っています。',
|
||||
turnDoneTitle: 'Hermes が完了しました',
|
||||
turnDoneBody: '応答の準備ができました。',
|
||||
turnErrorTitle: 'ターンが失敗しました',
|
||||
backgroundDoneTitle: 'バックグラウンドタスクが完了しました',
|
||||
backgroundFailedTitle: 'バックグラウンドタスクが失敗しました'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -177,7 +189,47 @@ export const ja = defineLocale({
|
||||
keysSettings: '設定',
|
||||
mcp: 'MCP',
|
||||
archivedChats: 'アーカイブ済みチャット',
|
||||
about: '情報'
|
||||
about: '情報',
|
||||
notifications: '通知'
|
||||
},
|
||||
notifications: {
|
||||
title: '通知',
|
||||
intro:
|
||||
'アプリ内トーストとは別の、ネイティブのデスクトップ通知です。設定は端末ごとに保存されます。',
|
||||
enableAll: '通知を有効にする',
|
||||
enableAllDesc: 'マスタースイッチ。オフにすると以下のすべての通知を無効にします。',
|
||||
focusedHint: '完了通知は Hermes がバックグラウンドにあるときのみ表示されます。',
|
||||
kinds: {
|
||||
approval: {
|
||||
label: '承認が必要',
|
||||
description: 'コマンドが承認または拒否を待っています。'
|
||||
},
|
||||
input: {
|
||||
label: '入力が必要',
|
||||
description: 'Hermes が質問したか、パスワードやシークレットを必要としています。'
|
||||
},
|
||||
turnDone: {
|
||||
label: '応答完了',
|
||||
description: 'Hermes がバックグラウンドのときにターンが完了しました。'
|
||||
},
|
||||
turnError: {
|
||||
label: 'ターン失敗',
|
||||
description: 'ターンがエラーで終了しました。'
|
||||
},
|
||||
backgroundDone: {
|
||||
label: 'バックグラウンドタスク完了',
|
||||
description: 'バックグラウンドのターミナルコマンドが完了しました。'
|
||||
}
|
||||
},
|
||||
test: 'テスト通知を送信',
|
||||
testTitle: 'Hermes',
|
||||
testBody: '通知は正常に動作しています。',
|
||||
testSent:
|
||||
'テストを送信しました。表示されない場合は、OS の通知許可と集中モード/おやすみモードを確認してください。',
|
||||
testUnsupported: 'このシステムはネイティブ通知に対応していません。',
|
||||
completionSoundTitle: '完了サウンド',
|
||||
completionSoundDesc: 'エージェントのターン終了時に再生されます。プリセットを選んでここで試聴できます。',
|
||||
completionSoundPreview: '試聴'
|
||||
},
|
||||
sections: {
|
||||
model: 'モデル',
|
||||
@@ -642,6 +694,11 @@ export const ja = defineLocale({
|
||||
collapse: '折りたたむ',
|
||||
connectAnother: '別のプロバイダーを接続',
|
||||
otherProviders: 'その他のプロバイダー',
|
||||
removeConfirm: provider => `${provider} を削除しますか?`,
|
||||
removeKeyManaged: provider => `${provider} は API キーで設定されています。API Keys から削除してください。`,
|
||||
removedTitle: 'アカウントを削除しました',
|
||||
removedMessage: provider => `${provider} を削除しました。`,
|
||||
failedRemove: provider => `${provider} を削除できませんでした`,
|
||||
noProviderKeys: '利用可能なプロバイダー API キーがありません。',
|
||||
loading: 'プロバイダーを読み込み中...'
|
||||
},
|
||||
@@ -1580,8 +1637,6 @@ export const ja = defineLocale({
|
||||
unknown: '(不明)',
|
||||
search: 'プロバイダーとモデルをフィルター...',
|
||||
noModels: 'モデルが見つかりません。',
|
||||
persistGlobalSession: 'グローバルに保持(それ以外はこのセッションのみ)',
|
||||
persistGlobal: 'グローバルに保持',
|
||||
addProvider: 'プロバイダーを追加',
|
||||
loadFailed: 'モデルを読み込めませんでした',
|
||||
noAuthenticatedProviders: '認証済みプロバイダーがありません。',
|
||||
@@ -1834,6 +1889,7 @@ export const ja = defineLocale({
|
||||
moreOptions: 'その他の承認オプション',
|
||||
allowSession: 'このセッションで許可',
|
||||
alwaysAllowMenu: '常に許可…',
|
||||
jumpToApproval: '承認が必要',
|
||||
reject: '拒否',
|
||||
alwaysTitle: 'このコマンドを常に許可しますか?',
|
||||
alwaysDescription: pattern =>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user