Merge remote-tracking branch 'origin/main' into hermes/hermes-db5e9361
# Conflicts: # hermes_cli/inventory.py
This commit is contained in:
commit
e3ff43f7a8
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.
|
||||
|
||||
12
.github/workflows/supply-chain-audit.yml
vendored
12
.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
|
||||
@ -32,7 +32,7 @@ jobs:
|
||||
# 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
|
||||
@ -72,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
|
||||
|
||||
@ -207,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
|
||||
|
||||
@ -286,7 +286,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
|
||||
|
||||
|
||||
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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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":
|
||||
@ -1033,7 +1055,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 ""
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -1164,7 +1164,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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -272,27 +272,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 +343,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 +375,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 +408,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 +621,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:
|
||||
|
||||
@ -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"),
|
||||
)
|
||||
|
||||
@ -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/).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -39,6 +39,7 @@ const { waitForDashboardPort } = require('./backend-ready.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
|
||||
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const { worktreesForIpc } = require('./git-worktrees.cjs')
|
||||
@ -62,6 +63,7 @@ const {
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
@ -242,6 +244,16 @@ if (INSTALL_STAMP) {
|
||||
function resolveHermesHome() {
|
||||
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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -42,6 +42,7 @@ import {
|
||||
$sessions,
|
||||
sessionPinId
|
||||
} from '@/store/session'
|
||||
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { routeSessionId } from '../routes'
|
||||
@ -122,7 +123,7 @@ function ChatHeader({
|
||||
// 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) {
|
||||
if (isNewSessionWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@ -302,7 +303,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
|
||||
|
||||
@ -77,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'
|
||||
@ -464,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) => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -47,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'
|
||||
@ -641,6 +642,9 @@ 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
|
||||
|
||||
@ -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,55 @@ 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',
|
||||
persistGlobal: false,
|
||||
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('keeps the global path on setGlobalModel when there is no active session', async () => {
|
||||
setGlobalModel.mockResolvedValue(undefined)
|
||||
let controls!: Controls
|
||||
|
||||
render(
|
||||
<Harness
|
||||
activeSessionId={null}
|
||||
onReady={value => (controls = value)}
|
||||
requestGateway={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
await expect(
|
||||
controls.selectModel({
|
||||
model: 'claude-sonnet-4.6',
|
||||
persistGlobal: false,
|
||||
provider: 'anthropic'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(setGlobalModel).toHaveBeenCalledWith('anthropic', 'claude-sonnet-4.6')
|
||||
})
|
||||
})
|
||||
|
||||
@ -82,9 +82,10 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
||||
|
||||
try {
|
||||
if (activeSessionId) {
|
||||
await requestGateway('slash.exec', {
|
||||
await requestGateway('config.set', {
|
||||
session_id: activeSessionId,
|
||||
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
|
||||
key: 'model',
|
||||
value: `${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
|
||||
})
|
||||
|
||||
if (selection.persistGlobal) {
|
||||
|
||||
@ -42,6 +42,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'
|
||||
@ -472,6 +473,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)
|
||||
|
||||
@ -16,7 +16,7 @@ import {
|
||||
} from '@/store/layout'
|
||||
import { $paneWidthOverride } from '@/store/panes'
|
||||
import { $connection } from '@/store/session'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
||||
|
||||
@ -80,6 +80,7 @@ export function AppShell({
|
||||
const connection = useStore($connection)
|
||||
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
|
||||
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
|
||||
const hideTitlebarControls = isNewSessionWindow()
|
||||
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 +163,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 +186,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,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 { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { MessageRenderBoundary } from './message-render-boundary'
|
||||
|
||||
@ -132,6 +134,13 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
const hiddenCount = firstVisible
|
||||
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
|
||||
const restoreFromBottomRef = useRef<number | null>(null)
|
||||
const newSessionWindow = isNewSessionWindow()
|
||||
const newSessionTitlebarGap = 'calc(var(--titlebar-height)+0.75rem)'
|
||||
const threadContentTopPad = newSessionWindow
|
||||
? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
|
||||
: isSecondaryWindow()
|
||||
? 'pt-6'
|
||||
: 'pt-[calc(var(--titlebar-height)+1.5rem)]'
|
||||
|
||||
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
|
||||
useEffect(() => () => resetThreadScroll(), [])
|
||||
@ -235,7 +244,12 @@ 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%',
|
||||
...(newSessionWindow ? { '--sticky-human-top': newSessionTitlebarGap } : {})
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
@ -252,9 +266,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>}
|
||||
>
|
||||
|
||||
@ -588,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}
|
||||
|
||||
@ -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.
|
||||
|
||||
2
apps/desktop/src/global.d.ts
vendored
2
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>
|
||||
|
||||
@ -189,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',
|
||||
|
||||
@ -185,6 +185,7 @@ export const zh: Translations = {
|
||||
'nav.cron': '打开定时任务',
|
||||
'nav.agents': '打开智能体',
|
||||
'session.new': '新建会话',
|
||||
'session.newWindow': '在新窗口中新建会话',
|
||||
'session.next': '下一个会话',
|
||||
'session.prev': '上一个会话',
|
||||
'session.slot.1': '切换到最近会话 1',
|
||||
@ -1093,7 +1094,8 @@ export const zh: Translations = {
|
||||
feishu: '创建飞书 / Lark 应用,配置机器人能力,复制 App ID、App secret 和事件加密密钥。',
|
||||
wecom: '在企业微信中添加群机器人,复制其 webhook key 作为 WECOM_BOT_ID。仅可发送——双向请用企业微信 (应用) 选项。',
|
||||
wecom_callback: '设置一个企业微信自建应用,暴露其回调 URL,并提供 corp ID、secret、agent ID 和 AES key。',
|
||||
weixin: '登录微信公众平台,复制 AppID 和 Token,并把消息回调 URL 指向 Hermes。',
|
||||
weixin:
|
||||
'运行 `hermes gateway setup`,选择 Weixin,然后使用个人微信账号扫描并确认二维码。Hermes 会通过腾讯 iLink Bot API 连接并保存凭据。',
|
||||
qqbot: '在 QQ 开放平台 (q.qq.com) 注册一个应用,复制 App ID 和 Client Secret。',
|
||||
api_server:
|
||||
'把 Hermes 暴露为兼容 OpenAI 的 API。设置一个鉴权密钥,然后把 Open WebUI / LobeChat 等指向 host:port。',
|
||||
|
||||
@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { ChatMessage, ChatMessagePart } from './chat-messages'
|
||||
import {
|
||||
appendAssistantTextPart,
|
||||
appendReasoningPart,
|
||||
chatMessageText,
|
||||
preserveLocalAssistantErrors,
|
||||
renderMediaTags,
|
||||
@ -175,6 +176,52 @@ describe('renderMediaTags', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('interleaved reasoning/text coalescing', () => {
|
||||
it('keeps narration contiguous when reasoning interrupts mid-sentence', () => {
|
||||
// Models that interleave reasoning_content + content deltas emit
|
||||
// text → reasoning → text within one tool-bounded segment. The two text
|
||||
// fragments are really one sentence and must not be split by the
|
||||
// "Thinking" block between them.
|
||||
let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Let me ')
|
||||
parts = appendReasoningPart(parts, 'checking the file...')
|
||||
parts = appendAssistantTextPart(parts, 'verify the full file is correct:')
|
||||
|
||||
expect(parts.map(p => p.type)).toEqual(['text', 'reasoning'])
|
||||
expect((parts[0] as { text: string }).text).toBe('Let me verify the full file is correct:')
|
||||
expect((parts[1] as { text: string }).text).toBe('checking the file...')
|
||||
})
|
||||
|
||||
it('merges reasoning bursts that straddle a narration fragment', () => {
|
||||
let parts: ChatMessagePart[] = appendReasoningPart([], 'first thought ')
|
||||
parts = appendAssistantTextPart(parts, 'Working on it.')
|
||||
parts = appendReasoningPart(parts, 'second thought')
|
||||
|
||||
expect(parts.map(p => p.type)).toEqual(['reasoning', 'text'])
|
||||
expect((parts[0] as { text: string }).text).toBe('first thought second thought')
|
||||
expect((parts[1] as { text: string }).text).toBe('Working on it.')
|
||||
})
|
||||
|
||||
it('starts a fresh text part after a tool call (segment boundary)', () => {
|
||||
let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Let me check.')
|
||||
parts = upsertToolPart(parts, { name: 'read_file', tool_id: 'tc-1' }, 'running')
|
||||
parts = appendAssistantTextPart(parts, 'Now editing.')
|
||||
|
||||
expect(parts.map(p => p.type)).toEqual(['text', 'tool-call', 'text'])
|
||||
expect((parts[0] as { text: string }).text).toBe('Let me check.')
|
||||
expect((parts[2] as { text: string }).text).toBe('Now editing.')
|
||||
})
|
||||
|
||||
it('does not merge reasoning across a tool call', () => {
|
||||
let parts: ChatMessagePart[] = appendReasoningPart([], 'before tool')
|
||||
parts = upsertToolPart(parts, { name: 'read_file', tool_id: 'tc-1' }, 'running')
|
||||
parts = appendReasoningPart(parts, 'after tool')
|
||||
|
||||
expect(parts.map(p => p.type)).toEqual(['reasoning', 'tool-call', 'reasoning'])
|
||||
expect((parts[0] as { text: string }).text).toBe('before tool')
|
||||
expect((parts[2] as { text: string }).text).toBe('after tool')
|
||||
})
|
||||
})
|
||||
|
||||
describe('preserveLocalAssistantErrors', () => {
|
||||
it('preserves a local user+error pair when hydration omits the failed turn', () => {
|
||||
const nextMessages: ChatMessage[] = [
|
||||
|
||||
@ -178,50 +178,70 @@ function displayContentForMessage(role: SessionMessage['role'], content: unknown
|
||||
return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText
|
||||
}
|
||||
|
||||
export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
||||
const next = [...parts]
|
||||
const last = next.at(-1)
|
||||
|
||||
if (last?.type === 'text') {
|
||||
next[next.length - 1] = { ...last, text: `${last.text}${delta}` }
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
next.push(textPart(delta))
|
||||
|
||||
return next
|
||||
const STREAM_PART: Record<'reasoning' | 'text', (text: string) => ChatMessagePart> = {
|
||||
reasoning: reasoningPart,
|
||||
text: textPart
|
||||
}
|
||||
|
||||
export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
||||
const next = appendTextPart(parts, delta)
|
||||
const last = next.at(-1)
|
||||
// Coalesce a streaming delta into the most recent same-type part within the
|
||||
// current segment, where a segment is bounded by any non-streaming part (a
|
||||
// tool call, image, …). The opposite streaming channel (text <-> reasoning) is
|
||||
// transparent, so a reasoning burst between two content deltas can't shred one
|
||||
// sentence into text / Thinking / text — the fragmentation models that
|
||||
// interleave reasoning_content + content otherwise produce. Tool calls still
|
||||
// open a fresh part, preserving narration order across steps.
|
||||
function appendStreamPart(
|
||||
parts: ChatMessagePart[],
|
||||
type: 'reasoning' | 'text',
|
||||
delta: string
|
||||
): { index: number; parts: ChatMessagePart[] } {
|
||||
const next = [...parts]
|
||||
|
||||
if (last?.type === 'text') {
|
||||
const current = last.text
|
||||
for (let i = next.length - 1; i >= 0; i--) {
|
||||
const part = next[i]
|
||||
|
||||
const deltaMayContainMedia =
|
||||
delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:')
|
||||
if (part.type === type) {
|
||||
next[i] = { ...part, text: `${(part as { text: string }).text}${delta}` } as ChatMessagePart
|
||||
|
||||
const needsMediaPass = deltaMayContainMedia || current.includes('MEDIA:')
|
||||
const nextText = needsMediaPass ? renderMediaTags(current) : current
|
||||
next[next.length - 1] = nextText === current ? last : { ...last, text: nextText }
|
||||
return { index: i, parts: next }
|
||||
}
|
||||
|
||||
if (part.type !== 'text' && part.type !== 'reasoning') {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
next.push(STREAM_PART[type](delta))
|
||||
|
||||
return { index: next.length - 1, parts: next }
|
||||
}
|
||||
|
||||
export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
||||
return appendStreamPart(parts, 'text', delta).parts
|
||||
}
|
||||
|
||||
export function appendReasoningPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
||||
const next = [...parts]
|
||||
const last = next.at(-1)
|
||||
return appendStreamPart(parts, 'reasoning', delta).parts
|
||||
}
|
||||
|
||||
if (last?.type === 'reasoning') {
|
||||
next[next.length - 1] = { ...last, text: `${last.text}${delta}` }
|
||||
export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
||||
const { index, parts: next } = appendStreamPart(parts, 'text', delta)
|
||||
const part = next[index]
|
||||
|
||||
if (part?.type !== 'text') {
|
||||
return next
|
||||
}
|
||||
|
||||
next.push(reasoningPart(delta))
|
||||
const mayContainMedia =
|
||||
delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:')
|
||||
|
||||
if (mayContainMedia || part.text.includes('MEDIA:')) {
|
||||
const rendered = renderMediaTags(part.text)
|
||||
|
||||
if (rendered !== part.text) {
|
||||
next[index] = { ...part, text: rendered }
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
@ -66,6 +66,7 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
|
||||
|
||||
// ── Session ──────────────────────────────────────────────────────────────
|
||||
{ id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] },
|
||||
{ id: 'session.newWindow', category: 'session', defaults: ['mod+shift+n'] },
|
||||
// ⌃Tab / ⌃⇧Tab — the universal tab-cycle chord. Literally Control, not Cmd
|
||||
// (macOS reserves Cmd+Tab for app switching); see `ctrl` in combo.ts.
|
||||
{ id: 'session.next', category: 'session', defaults: ['ctrl+tab'] },
|
||||
|
||||
@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway } from './media'
|
||||
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway, mediaExternalUrl } from './media'
|
||||
|
||||
describe('isRemoteGateway', () => {
|
||||
afterEach(() => {
|
||||
@ -35,6 +35,38 @@ describe('filePathFromMediaPath', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('mediaExternalUrl', () => {
|
||||
afterEach(() => {
|
||||
$connection.set(null)
|
||||
})
|
||||
|
||||
it('passes through http(s) URLs untouched', () => {
|
||||
$connection.set({ mode: 'remote', baseUrl: 'https://gw', token: 't' } as never)
|
||||
expect(mediaExternalUrl('https://example.com/a.png')).toBe('https://example.com/a.png')
|
||||
})
|
||||
|
||||
it('keeps file:// form in local mode', () => {
|
||||
$connection.set({ mode: 'local' } as never)
|
||||
expect(mediaExternalUrl('/tmp/a.png')).toBe('file:///tmp/a.png')
|
||||
expect(mediaExternalUrl('file:///tmp/a.png')).toBe('file:///tmp/a.png')
|
||||
})
|
||||
|
||||
it('rewrites gateway-local paths to an authenticated download URL', () => {
|
||||
$connection.set({ mode: 'remote', baseUrl: 'https://gw', token: 's e/cret' } as never)
|
||||
expect(mediaExternalUrl('file:///tmp/a b.png')).toBe(
|
||||
'https://gw/api/files/download?path=%2Ftmp%2Fa%20b.png&token=s%20e%2Fcret'
|
||||
)
|
||||
expect(mediaExternalUrl('/tmp/a b.png')).toBe(
|
||||
'https://gw/api/files/download?path=%2Ftmp%2Fa%20b.png&token=s%20e%2Fcret'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to file:// when remote connection lacks a token', () => {
|
||||
$connection.set({ mode: 'remote', baseUrl: 'https://gw' } as never)
|
||||
expect(mediaExternalUrl('/tmp/a.png')).toBe('file:///tmp/a.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gatewayMediaDataUrl', () => {
|
||||
const api = vi.fn(async () => ({ data_url: 'data:image/png;base64,ZHVtbXk=' }))
|
||||
|
||||
|
||||
@ -56,8 +56,25 @@ export function mediaMarkdownHref(path: string): string {
|
||||
return `#media:${encodeURIComponent(path)}`
|
||||
}
|
||||
|
||||
// Resolve a media path to a URL the shell can open. Remote mode rewrites
|
||||
// gateway-local paths to an authenticated /api/files/download URL (the file
|
||||
// lives on the gateway, not this disk); local mode keeps the file:// form.
|
||||
export function mediaExternalUrl(path: string): string {
|
||||
return /^(?:https?|file):/i.test(path) ? path : `file://${path}`
|
||||
if (/^https?:/i.test(path)) {
|
||||
return path
|
||||
}
|
||||
|
||||
if (isRemoteGateway()) {
|
||||
const conn = $connection.get()
|
||||
|
||||
if (conn?.baseUrl && conn.token) {
|
||||
const file = encodeURIComponent(filePathFromMediaPath(path))
|
||||
|
||||
return `${conn.baseUrl}/api/files/download?path=${file}&token=${encodeURIComponent(conn.token)}`
|
||||
}
|
||||
}
|
||||
|
||||
return /^file:/i.test(path) ? path : `file://${path}`
|
||||
}
|
||||
|
||||
// Custom Electron scheme (registered in electron/main.cjs) that streams a local
|
||||
|
||||
89
apps/desktop/src/store/profile.test.ts
Normal file
89
apps/desktop/src/store/profile.test.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { atom } from 'nanostores'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesConnection } from '@/global'
|
||||
|
||||
// Keep profile.ts's side-effecting imports inert: the gateway socket layer and
|
||||
// the REST query client must not run for real in a unit test.
|
||||
const ensureGatewayForProfile = vi.fn(async () => undefined)
|
||||
const $gateway = atom<unknown>({ id: 'live-socket' })
|
||||
|
||||
vi.mock('@/store/gateway', () => ({ $gateway, ensureGatewayForProfile }))
|
||||
vi.mock('@/hermes', () => ({
|
||||
getProfiles: vi.fn(async () => ({ profiles: [] })),
|
||||
setApiRequestProfile: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } }))
|
||||
|
||||
const { $activeGatewayProfile, ensureGatewayProfile } = await import('./profile')
|
||||
const { $connection } = await import('./session')
|
||||
|
||||
const remoteConn = (over: Partial<HermesConnection> = {}): HermesConnection =>
|
||||
({ baseUrl: 'https://hermes-roy.tail.ts.net', mode: 'remote', profile: 'vps-remote', ...over }) as HermesConnection
|
||||
|
||||
const localConn = (over: Partial<HermesConnection> = {}): HermesConnection =>
|
||||
({ baseUrl: '', mode: 'local', profile: 'default', ...over }) as HermesConnection
|
||||
|
||||
const getConnection = vi.fn<(profile?: string | null) => Promise<HermesConnection>>()
|
||||
|
||||
beforeEach(() => {
|
||||
getConnection.mockReset()
|
||||
ensureGatewayForProfile.mockClear()
|
||||
$gateway.set({ id: 'live-socket' })
|
||||
$activeGatewayProfile.set('default')
|
||||
$connection.set(localConn())
|
||||
vi.stubGlobal('window', { hermesDesktop: { getConnection } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
$connection.set(null)
|
||||
})
|
||||
|
||||
describe('ensureGatewayProfile → $connection sync (#46651)', () => {
|
||||
it('refreshes $connection to the remote descriptor when activating a remote pool profile', async () => {
|
||||
// Regression: the primary window backend is local, so $connection.mode is
|
||||
// "local". Activating the remote profile must flip it to "remote" — without
|
||||
// this, image attach uses path-based image.attach against the remote
|
||||
// gateway ("image not found: C:\\…") instead of image.attach_bytes.
|
||||
getConnection.mockResolvedValue(remoteConn())
|
||||
|
||||
await ensureGatewayProfile('vps-remote')
|
||||
|
||||
expect(ensureGatewayForProfile).toHaveBeenCalledWith('vps-remote')
|
||||
expect(getConnection).toHaveBeenCalledWith('vps-remote')
|
||||
expect($connection.get()?.mode).toBe('remote')
|
||||
expect($connection.get()?.profile).toBe('vps-remote')
|
||||
})
|
||||
|
||||
it('resyncs $connection back to local when returning to the default profile', async () => {
|
||||
$activeGatewayProfile.set('vps-remote')
|
||||
$connection.set(remoteConn())
|
||||
getConnection.mockResolvedValue(localConn())
|
||||
|
||||
await ensureGatewayProfile('default')
|
||||
|
||||
expect(getConnection).toHaveBeenCalledWith('default')
|
||||
expect($connection.get()?.mode).toBe('local')
|
||||
})
|
||||
|
||||
it('leaves the prior connection intact when the descriptor fetch fails', async () => {
|
||||
getConnection.mockRejectedValue(new Error('backend unreachable'))
|
||||
|
||||
await ensureGatewayProfile('vps-remote')
|
||||
|
||||
// Best-effort: boot/reconnect resyncs later; we must not null it out here.
|
||||
expect($connection.get()?.mode).toBe('local')
|
||||
})
|
||||
|
||||
it('does not churn $connection when the target is already the active profile', async () => {
|
||||
$activeGatewayProfile.set('vps-remote')
|
||||
$connection.set(remoteConn())
|
||||
|
||||
await ensureGatewayProfile('vps-remote')
|
||||
|
||||
expect(getConnection).not.toHaveBeenCalled()
|
||||
expect(ensureGatewayForProfile).not.toHaveBeenCalled()
|
||||
expect($connection.get()?.mode).toBe('remote')
|
||||
})
|
||||
})
|
||||
@ -12,6 +12,7 @@ import {
|
||||
storedStringRecord
|
||||
} from '@/lib/storage'
|
||||
import { $gateway, ensureGatewayForProfile } from '@/store/gateway'
|
||||
import { setConnection } from '@/store/session'
|
||||
import type { ProfileInfo } from '@/types/hermes'
|
||||
|
||||
// Canonical key for a profile: trimmed, empty → "default". Used everywhere we
|
||||
@ -178,6 +179,32 @@ export const $gatewaySwapTarget = atom<string | null>(null)
|
||||
|
||||
let gatewaySwitch: Promise<void> | null = null
|
||||
|
||||
// Keep the renderer's $connection (mode / baseUrl / profile) in lockstep with
|
||||
// the profile the live gateway is now on. $connection seeds from the PRIMARY
|
||||
// (window) backend at boot and otherwise only refreshes on a sleep/wake
|
||||
// reconnect — so activating a *background* profile left $connection describing
|
||||
// the primary, with the wrong `mode` for everything that branches on
|
||||
// local-vs-remote. Headline symptom: with a local primary and a remote pool
|
||||
// profile active, image attachments went out via the path-based `image.attach`
|
||||
// instead of `image.attach_bytes`, handing the remote gateway a client-only
|
||||
// path it can't resolve ("image not found: C:\…"), while the /api/fs/* file
|
||||
// browser and /api/media fetches targeted the wrong machine (#46651).
|
||||
// Best-effort: a failed descriptor fetch leaves the prior connection intact for
|
||||
// boot/reconnect to resync.
|
||||
async function syncConnectionToActiveProfile(profile: string): Promise<void> {
|
||||
const getConnection = window.hermesDesktop?.getConnection
|
||||
|
||||
if (!getConnection) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setConnection(await getConnection(profile))
|
||||
} catch {
|
||||
// Leave the prior connection in place; boot/reconnect resyncs it later.
|
||||
}
|
||||
}
|
||||
|
||||
// Make `profile`'s backend the active gateway, lazily opening its socket if it
|
||||
// isn't live yet. Unlike the old single-socket swap, background profiles keep
|
||||
// their sockets — so their sessions keep streaming concurrently. A null/empty
|
||||
@ -218,6 +245,9 @@ export async function ensureGatewayProfile(profile: string | null | undefined):
|
||||
// the active gateway at it — without closing the profile you came from.
|
||||
await ensureGatewayForProfile(target)
|
||||
$activeGatewayProfile.set(target)
|
||||
// The active backend just changed; resync $connection so remote-aware
|
||||
// paths (image.attach_bytes vs image.attach, /api/fs/*, /api/media) follow.
|
||||
await syncConnectionToActiveProfile(target)
|
||||
})()
|
||||
|
||||
try {
|
||||
|
||||
25
apps/desktop/src/store/session-sync.ts
Normal file
25
apps/desktop/src/store/session-sync.ts
Normal file
@ -0,0 +1,25 @@
|
||||
// Cross-window session-list sync. Each desktop window is its own renderer
|
||||
// process with its own gateway socket and session store, so a mutation in one
|
||||
// (e.g. a new chat started in the compact pop-out) never reaches another
|
||||
// window. This bus pings every window to re-pull the shared session list; the
|
||||
// data already lives in the backend, the other window just doesn't know to look.
|
||||
const CHANNEL = 'hermes:sessions'
|
||||
|
||||
const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL)
|
||||
|
||||
// A window that mutated the session list (created / titled a chat) tells the
|
||||
// others to refresh. A BroadcastChannel never delivers to its own poster, so the
|
||||
// caller refreshes locally as it already does.
|
||||
export function broadcastSessionsChanged(): void {
|
||||
channel?.postMessage(1)
|
||||
}
|
||||
|
||||
export function onSessionsChanged(handler: () => void): () => void {
|
||||
if (!channel) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
channel.addEventListener('message', handler)
|
||||
|
||||
return () => channel.removeEventListener('message', handler)
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from './windows'
|
||||
import { canOpenSessionWindow, openNewSessionInNewWindow, openSessionInNewWindow } from './windows'
|
||||
|
||||
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
|
||||
const initialHermesDesktop = desktopWindow.hermesDesktop
|
||||
@ -11,9 +11,13 @@ vi.mock('./notifications', () => ({
|
||||
notifyError: (...args: unknown[]) => notifyError(...args)
|
||||
}))
|
||||
|
||||
function installBridge(openSessionWindow?: Window['hermesDesktop']['openSessionWindow']) {
|
||||
function installBridge(
|
||||
openSessionWindow?: Window['hermesDesktop']['openSessionWindow'],
|
||||
openNewSessionWindow?: Window['hermesDesktop']['openNewSessionWindow']
|
||||
) {
|
||||
desktopWindow.hermesDesktop = {
|
||||
...(openSessionWindow ? { openSessionWindow } : {})
|
||||
...(openSessionWindow ? { openSessionWindow } : {}),
|
||||
...(openNewSessionWindow ? { openNewSessionWindow } : {})
|
||||
} as unknown as Window['hermesDesktop']
|
||||
}
|
||||
|
||||
@ -101,3 +105,39 @@ describe('openSessionInNewWindow', () => {
|
||||
expect(notifyError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openNewSessionInNewWindow', () => {
|
||||
it('no-ops gracefully when the bridge is absent (web fallback)', async () => {
|
||||
delete desktopWindow.hermesDesktop
|
||||
|
||||
await openNewSessionInNewWindow()
|
||||
|
||||
expect(notifyError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('no-ops when openNewSessionWindow is missing', async () => {
|
||||
installBridge(vi.fn().mockResolvedValue({ ok: true }))
|
||||
|
||||
await openNewSessionInNewWindow()
|
||||
|
||||
expect(notifyError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invokes the bridge', async () => {
|
||||
const openNew = vi.fn().mockResolvedValue({ ok: true })
|
||||
installBridge(vi.fn().mockResolvedValue({ ok: true }), openNew)
|
||||
|
||||
await openNewSessionInNewWindow()
|
||||
|
||||
expect(openNew).toHaveBeenCalledTimes(1)
|
||||
expect(notifyError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('notifies on an ok:false result', async () => {
|
||||
installBridge(vi.fn().mockResolvedValue({ ok: true }), vi.fn().mockResolvedValue({ ok: false, error: 'nope' }))
|
||||
|
||||
await openNewSessionInNewWindow()
|
||||
|
||||
expect(notifyError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@ -6,6 +6,7 @@ import { notifyError } from './notifications'
|
||||
// never from the router. A "secondary" window renders a single chat without the
|
||||
// global session sidebar or the install / onboarding overlays.
|
||||
const SECONDARY_WINDOW_FLAG = 'secondary'
|
||||
const NEW_SESSION_WINDOW_FLAG = '1'
|
||||
|
||||
let secondaryWindowCache: boolean | null = null
|
||||
|
||||
@ -27,6 +28,26 @@ export function isSecondaryWindow(): boolean {
|
||||
return result
|
||||
}
|
||||
|
||||
let newSessionWindowCache: boolean | null = null
|
||||
|
||||
export function isNewSessionWindow(): boolean {
|
||||
if (newSessionWindowCache !== null) {
|
||||
return newSessionWindowCache
|
||||
}
|
||||
|
||||
let result = false
|
||||
|
||||
try {
|
||||
result = new URLSearchParams(window.location.search).get('new') === NEW_SESSION_WINDOW_FLAG
|
||||
} catch {
|
||||
result = false
|
||||
}
|
||||
|
||||
newSessionWindowCache = result
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
let watchWindowCache: boolean | null = null
|
||||
|
||||
// A "watch" window spectates a session that is being driven elsewhere (a
|
||||
@ -57,6 +78,22 @@ export function canOpenSessionWindow(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.hermesDesktop?.openSessionWindow === 'function'
|
||||
}
|
||||
|
||||
type WindowOpenResult = { ok: boolean; error?: string } | undefined
|
||||
|
||||
// Run a window-open bridge call, surfacing any failure as a toast. Shared by the
|
||||
// session pop-out and the new-session pop-out.
|
||||
async function openWindow(call: () => Promise<WindowOpenResult>, failMessage: string): Promise<void> {
|
||||
try {
|
||||
const result = await call()
|
||||
|
||||
if (!result?.ok) {
|
||||
notifyError(new Error(result?.error || 'unknown error'), failMessage)
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, failMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Open (or focus) a standalone OS window for a single chat session. No-ops
|
||||
// gracefully outside Electron so callers can wire it unconditionally.
|
||||
// `watch: true` opens a spectator window (lazy resume, live-mirror stream).
|
||||
@ -65,13 +102,14 @@ export async function openSessionInNewWindow(sessionId: string, opts?: { watch?:
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.hermesDesktop.openSessionWindow(sessionId, opts)
|
||||
|
||||
if (!result?.ok) {
|
||||
notifyError(new Error(result?.error || 'unknown error'), 'Could not open chat in a new window')
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not open chat in a new window')
|
||||
}
|
||||
await openWindow(() => window.hermesDesktop.openSessionWindow(sessionId, opts), 'Could not open chat in a new window')
|
||||
}
|
||||
|
||||
// Open a fresh compact window on the new-session draft.
|
||||
export async function openNewSessionInNewWindow(): Promise<void> {
|
||||
if (!canOpenSessionWindow() || typeof window.hermesDesktop.openNewSessionWindow !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
await openWindow(() => window.hermesDesktop.openNewSessionWindow(), 'Could not open new session window')
|
||||
}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2023"],
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
@ -724,7 +724,7 @@ platform_toolsets:
|
||||
# # allowed_chats: ["-1001234567890"]
|
||||
# extra:
|
||||
# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages
|
||||
# rich_messages: true # Set false to force legacy MarkdownV2 for clients that cannot render Bot API rich messages
|
||||
# rich_messages: false # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
|
||||
#
|
||||
# Discord-specific settings (config.yaml top-level, not under platforms:):
|
||||
#
|
||||
|
||||
16
cli.py
16
cli.py
@ -977,6 +977,11 @@ def _run_cleanup(*, notify_session_finalize: bool = True):
|
||||
_cleanup_all_terminals()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from tools.async_delegation import interrupt_all as _interrupt_async_delegations
|
||||
_interrupt_async_delegations(reason="CLI shutdown")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_cleanup_all_browsers()
|
||||
except Exception:
|
||||
@ -5783,14 +5788,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
if not self._session_db:
|
||||
return []
|
||||
try:
|
||||
sessions = self._session_db.list_sessions_rich(
|
||||
from hermes_cli.session_listing import query_session_listing
|
||||
|
||||
return query_session_listing(
|
||||
self._session_db,
|
||||
source="cli",
|
||||
exclude_sources=["tool"],
|
||||
current_session_id=self.session_id,
|
||||
include_all_sources=False,
|
||||
include_unnamed=True,
|
||||
limit=limit,
|
||||
exclude_sources=["tool"],
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
return [s for s in sessions if s.get("id") != self.session_id]
|
||||
|
||||
def _show_recent_sessions(self, *, reason: str = "history", limit: int = 10) -> bool:
|
||||
"""Render recent sessions inline from the active chat TUI.
|
||||
|
||||
214
cron/jobs.py
214
cron/jobs.py
@ -5,6 +5,7 @@ Jobs are stored in ~/.hermes/cron/jobs.json
|
||||
Output is saved to ~/.hermes/cron/output/{job_id}/{timestamp}.md
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
@ -14,6 +15,19 @@ import threading
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
|
||||
# Cross-process advisory file locking for jobs.json critical sections.
|
||||
# fcntl is Unix-only; on Windows fall back to msvcrt. Either may be absent,
|
||||
# in which case _jobs_lock() degrades to in-process locking only (the old
|
||||
# behaviour) rather than failing.
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - non-Unix
|
||||
fcntl = None
|
||||
try:
|
||||
import msvcrt
|
||||
except ImportError: # pragma: no cover - non-Windows
|
||||
msvcrt = None
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
@ -41,10 +55,79 @@ JOBS_FILE = CRON_DIR / "jobs.json"
|
||||
# In-process lock protecting load_jobs→modify→save_jobs cycles.
|
||||
# Required when tick() runs jobs in parallel threads — without this,
|
||||
# concurrent mark_job_run / advance_next_run calls can clobber each other.
|
||||
_jobs_file_lock = threading.Lock()
|
||||
_jobs_file_lock = threading.RLock()
|
||||
_jobs_lock_state = threading.local()
|
||||
OUTPUT_DIR = CRON_DIR / "output"
|
||||
ONESHOT_GRACE_SECONDS = 120
|
||||
|
||||
|
||||
def _jobs_lock_file() -> Path:
|
||||
"""Return the advisory lock path for the current cron directory."""
|
||||
return CRON_DIR / ".jobs.lock"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _jobs_lock():
|
||||
"""Serialize a load_jobs→modify→save_jobs critical section.
|
||||
|
||||
Combines the in-process threading lock (cheap mutual exclusion between
|
||||
the gateway's parallel tick threads) with a cross-process advisory file
|
||||
lock on ``<cron dir>/.jobs.lock`` (mutual exclusion between the gateway process
|
||||
and standalone ``hermes`` CLI invocations, which previously shared no lock
|
||||
at all — a `cron pause` could be silently clobbered by a concurrent
|
||||
gateway write, leaving a "paused" job still firing).
|
||||
|
||||
The flock is blocking, but every critical section that uses it is short
|
||||
(field updates only — no agent execution), so contention resolves in
|
||||
milliseconds. If neither fcntl nor msvcrt is available the manager still
|
||||
provides in-process locking, matching the historical behaviour.
|
||||
|
||||
Nested calls in the same thread reuse the held lock so legacy callers that
|
||||
invoke save_jobs() inside a broader mutation section don't deadlock or try
|
||||
to reacquire the advisory file lock.
|
||||
"""
|
||||
depth = getattr(_jobs_lock_state, "depth", 0)
|
||||
if depth:
|
||||
_jobs_lock_state.depth = depth + 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_jobs_lock_state.depth -= 1
|
||||
return
|
||||
|
||||
with _jobs_file_lock:
|
||||
_jobs_lock_state.depth = 1
|
||||
lock_fd = None
|
||||
try:
|
||||
try:
|
||||
ensure_dirs()
|
||||
lock_fd = open(_jobs_lock_file(), "a+", encoding="utf-8")
|
||||
lock_fd.seek(0)
|
||||
if fcntl is not None:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
elif msvcrt is not None:
|
||||
getattr(msvcrt, "locking")(lock_fd.fileno(), getattr(msvcrt, "LK_LOCK"), 1)
|
||||
except (OSError, IOError) as e:
|
||||
# Never let a locking failure take down cron writes — fall back to
|
||||
# in-process-only protection (still held via _jobs_file_lock).
|
||||
logger.warning("jobs.json cross-process lock unavailable (%s); "
|
||||
"proceeding with in-process lock only", e)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if lock_fd is not None:
|
||||
try:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
elif msvcrt is not None:
|
||||
getattr(msvcrt, "locking")(lock_fd.fileno(), getattr(msvcrt, "LK_UNLCK"), 1)
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
finally:
|
||||
lock_fd.close()
|
||||
finally:
|
||||
_jobs_lock_state.depth = 0
|
||||
|
||||
# Fields on a cron job that must never change after creation. ``id`` is used
|
||||
# as a filesystem path component under ``OUTPUT_DIR``; allowing it to be
|
||||
# updated lets an unsafe value (``../escape``, absolute path, nested) leak
|
||||
@ -468,8 +551,8 @@ def load_jobs() -> List[Dict[str, Any]]:
|
||||
)
|
||||
|
||||
|
||||
def save_jobs(jobs: List[Dict[str, Any]]):
|
||||
"""Save all jobs to storage."""
|
||||
def _save_jobs_unlocked(jobs: List[Dict[str, Any]]):
|
||||
"""Save all jobs to storage. Caller must hold _jobs_lock()."""
|
||||
ensure_dirs()
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(JOBS_FILE.parent), suffix='.tmp', prefix='.jobs_')
|
||||
try:
|
||||
@ -487,6 +570,12 @@ def save_jobs(jobs: List[Dict[str, Any]]):
|
||||
raise
|
||||
|
||||
|
||||
def save_jobs(jobs: List[Dict[str, Any]]):
|
||||
"""Save all jobs to storage."""
|
||||
with _jobs_lock():
|
||||
_save_jobs_unlocked(jobs)
|
||||
|
||||
|
||||
def _normalize_workdir(workdir: Optional[str]) -> Optional[str]:
|
||||
"""Normalize and validate a cron job workdir.
|
||||
|
||||
@ -670,9 +759,10 @@ def create_job(
|
||||
"workdir": normalized_workdir,
|
||||
}
|
||||
|
||||
jobs = load_jobs()
|
||||
jobs.append(job)
|
||||
save_jobs(jobs)
|
||||
with _jobs_lock():
|
||||
jobs = load_jobs()
|
||||
jobs.append(job)
|
||||
save_jobs(jobs)
|
||||
|
||||
return job
|
||||
|
||||
@ -743,49 +833,50 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
|
||||
f"Cron job field(s) cannot be updated: {', '.join(sorted(bad_fields))}"
|
||||
)
|
||||
|
||||
jobs = load_jobs()
|
||||
for i, job in enumerate(jobs):
|
||||
if job["id"] != job_id:
|
||||
continue
|
||||
with _jobs_lock():
|
||||
jobs = load_jobs()
|
||||
for i, job in enumerate(jobs):
|
||||
if job["id"] != job_id:
|
||||
continue
|
||||
|
||||
# Validate / normalize workdir if present in updates. Empty string or
|
||||
# None both mean "clear the field" (restore old behaviour).
|
||||
if "workdir" in updates:
|
||||
_wd = updates["workdir"]
|
||||
if _wd in {None, "", False}:
|
||||
updates["workdir"] = None
|
||||
else:
|
||||
updates["workdir"] = _normalize_workdir(_wd)
|
||||
# Validate / normalize workdir if present in updates. Empty string
|
||||
# or None both mean "clear the field" (restore old behaviour).
|
||||
if "workdir" in updates:
|
||||
_wd = updates["workdir"]
|
||||
if _wd in {None, "", False}:
|
||||
updates["workdir"] = None
|
||||
else:
|
||||
updates["workdir"] = _normalize_workdir(_wd)
|
||||
|
||||
updated = _apply_skill_fields({**job, **updates})
|
||||
schedule_changed = "schedule" in updates
|
||||
updated = _apply_skill_fields({**job, **updates})
|
||||
schedule_changed = "schedule" in updates
|
||||
|
||||
if "skills" in updates or "skill" in updates:
|
||||
normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills"))
|
||||
updated["skills"] = normalized_skills
|
||||
updated["skill"] = normalized_skills[0] if normalized_skills else None
|
||||
if "skills" in updates or "skill" in updates:
|
||||
normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills"))
|
||||
updated["skills"] = normalized_skills
|
||||
updated["skill"] = normalized_skills[0] if normalized_skills else None
|
||||
|
||||
if schedule_changed:
|
||||
updated_schedule = updated["schedule"]
|
||||
# The API may pass schedule as a raw string (e.g. "every 10m")
|
||||
# instead of a pre-parsed dict. Normalize it the same way
|
||||
# create_job() does so downstream code can call .get() safely.
|
||||
if isinstance(updated_schedule, str):
|
||||
updated_schedule = parse_schedule(updated_schedule)
|
||||
updated["schedule"] = updated_schedule
|
||||
updated["schedule_display"] = updates.get(
|
||||
"schedule_display",
|
||||
updated_schedule.get("display", updated.get("schedule_display")),
|
||||
)
|
||||
if updated.get("state") != "paused":
|
||||
updated["next_run_at"] = compute_next_run(updated_schedule)
|
||||
if schedule_changed:
|
||||
updated_schedule = updated["schedule"]
|
||||
# The API may pass schedule as a raw string (e.g. "every 10m")
|
||||
# instead of a pre-parsed dict. Normalize it the same way
|
||||
# create_job() does so downstream code can call .get() safely.
|
||||
if isinstance(updated_schedule, str):
|
||||
updated_schedule = parse_schedule(updated_schedule)
|
||||
updated["schedule"] = updated_schedule
|
||||
updated["schedule_display"] = updates.get(
|
||||
"schedule_display",
|
||||
updated_schedule.get("display", updated.get("schedule_display")),
|
||||
)
|
||||
if updated.get("state") != "paused":
|
||||
updated["next_run_at"] = compute_next_run(updated_schedule)
|
||||
|
||||
if updated.get("enabled", True) and updated.get("state") != "paused" and not updated.get("next_run_at"):
|
||||
updated["next_run_at"] = compute_next_run(updated["schedule"])
|
||||
if updated.get("enabled", True) and updated.get("state") != "paused" and not updated.get("next_run_at"):
|
||||
updated["next_run_at"] = compute_next_run(updated["schedule"])
|
||||
|
||||
jobs[i] = updated
|
||||
save_jobs(jobs)
|
||||
return _normalize_job_record(jobs[i])
|
||||
jobs[i] = updated
|
||||
save_jobs(jobs)
|
||||
return _normalize_job_record(jobs[i])
|
||||
return None
|
||||
|
||||
|
||||
@ -847,19 +938,20 @@ def remove_job(job_id: str) -> bool:
|
||||
if not job:
|
||||
return False
|
||||
canonical_id = job["id"]
|
||||
jobs = load_jobs()
|
||||
original_len = len(jobs)
|
||||
jobs = [j for j in jobs if j["id"] != canonical_id]
|
||||
if len(jobs) < original_len:
|
||||
# Resolve the output dir BEFORE saving so a legacy unsafe ID (e.g.
|
||||
# left over from before the create-time guard) fails closed without
|
||||
# half-applying the removal.
|
||||
job_output_dir = _job_output_dir(canonical_id)
|
||||
save_jobs(jobs)
|
||||
# Clean up output directory to prevent orphaned dirs accumulating
|
||||
if job_output_dir.exists():
|
||||
shutil.rmtree(job_output_dir)
|
||||
return True
|
||||
with _jobs_lock():
|
||||
jobs = load_jobs()
|
||||
original_len = len(jobs)
|
||||
jobs = [j for j in jobs if j["id"] != canonical_id]
|
||||
if len(jobs) < original_len:
|
||||
# Resolve the output dir BEFORE saving so a legacy unsafe ID (e.g.
|
||||
# left over from before the create-time guard) fails closed without
|
||||
# half-applying the removal.
|
||||
job_output_dir = _job_output_dir(canonical_id)
|
||||
save_jobs(jobs)
|
||||
# Clean up output directory to prevent orphaned dirs accumulating
|
||||
if job_output_dir.exists():
|
||||
shutil.rmtree(job_output_dir)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@ -874,7 +966,7 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None,
|
||||
``delivery_error`` is tracked separately from the agent error — a job
|
||||
can succeed (agent produced output) but fail delivery (platform down).
|
||||
"""
|
||||
with _jobs_file_lock:
|
||||
with _jobs_lock():
|
||||
jobs = load_jobs()
|
||||
for i, job in enumerate(jobs):
|
||||
if job["id"] == job_id:
|
||||
@ -948,7 +1040,7 @@ def advance_next_run(job_id: str) -> bool:
|
||||
|
||||
Returns True if next_run_at was advanced, False otherwise.
|
||||
"""
|
||||
with _jobs_file_lock:
|
||||
with _jobs_lock():
|
||||
jobs = load_jobs()
|
||||
for job in jobs:
|
||||
if job["id"] == job_id:
|
||||
@ -973,12 +1065,12 @@ def get_due_jobs() -> List[Dict[str, Any]]:
|
||||
the job is fast-forwarded to the next future run instead of firing
|
||||
immediately. This prevents a burst of missed jobs on gateway restart.
|
||||
"""
|
||||
with _jobs_file_lock:
|
||||
with _jobs_lock():
|
||||
return _get_due_jobs_locked()
|
||||
|
||||
|
||||
def _get_due_jobs_locked() -> List[Dict[str, Any]]:
|
||||
"""Inner implementation of get_due_jobs(); must be called with _jobs_file_lock held."""
|
||||
"""Inner implementation of get_due_jobs(); must be called with _jobs_lock held."""
|
||||
now = _hermes_now()
|
||||
raw_jobs = load_jobs()
|
||||
jobs = [_apply_skill_fields(j) for j in copy.deepcopy(raw_jobs)]
|
||||
@ -1158,7 +1250,7 @@ def rewrite_skill_refs(
|
||||
if not consolidated and not pruned_set:
|
||||
return {"rewrites": [], "jobs_updated": 0, "jobs_scanned": 0}
|
||||
|
||||
with _jobs_file_lock:
|
||||
with _jobs_lock():
|
||||
jobs = load_jobs()
|
||||
rewrites: List[Dict[str, Any]] = []
|
||||
changed = False
|
||||
|
||||
@ -316,6 +316,7 @@ as_hermes mkdir -p \
|
||||
"$HERMES_HOME/cron" \
|
||||
"$HERMES_HOME/sessions" \
|
||||
"$HERMES_HOME/logs" \
|
||||
"$HERMES_HOME/logs/gateways" \
|
||||
"$HERMES_HOME/hooks" \
|
||||
"$HERMES_HOME/memories" \
|
||||
"$HERMES_HOME/skills" \
|
||||
|
||||
@ -17,6 +17,57 @@ from utils import atomic_json_write
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DIRECTORY_PATH = get_hermes_home() / "channel_directory.json"
|
||||
# User-maintained friendly-name overlay. The directory is fully regenerated
|
||||
# from live adapters + session data on a timer, so hand-edits to
|
||||
# channel_directory.json don't survive. Aliases declared here are re-applied
|
||||
# on every build AND every load, giving durable human-friendly names (and
|
||||
# letting you pre-name a chat before it has produced any traffic).
|
||||
# Format: {"<platform>": {"<chat_id>": "<friendly name>", ...}, ...}
|
||||
CHANNEL_ALIASES_PATH = get_hermes_home() / "channel_aliases.json"
|
||||
|
||||
|
||||
def _load_channel_aliases() -> Dict[str, Dict[str, str]]:
|
||||
if not CHANNEL_ALIASES_PATH.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(CHANNEL_ALIASES_PATH, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _apply_channel_aliases(platforms: Dict[str, Any]) -> None:
|
||||
"""Overlay friendly names onto directory entries by chat_id.
|
||||
|
||||
Renames matching entries in place; injects a placeholder entry for an
|
||||
aliased id that hasn't been discovered yet (so a freshly-created group is
|
||||
addressable by name before its first message). Mutates *platforms*.
|
||||
"""
|
||||
aliases = _load_channel_aliases()
|
||||
for plat_name, id_map in aliases.items():
|
||||
if not isinstance(id_map, dict):
|
||||
continue
|
||||
entries = platforms.setdefault(plat_name, [])
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for chat_id, friendly in id_map.items():
|
||||
if not isinstance(friendly, str) or not friendly.strip():
|
||||
continue
|
||||
chat_id = str(chat_id)
|
||||
friendly = friendly.strip()
|
||||
matched = False
|
||||
for e in entries:
|
||||
if isinstance(e, dict) and e.get("id") == chat_id:
|
||||
e["name"] = friendly
|
||||
matched = True
|
||||
if not matched:
|
||||
entries.append({
|
||||
"id": chat_id,
|
||||
"name": friendly,
|
||||
"type": "group" if str(chat_id).endswith("@g.us") else "dm",
|
||||
"thread_id": None,
|
||||
})
|
||||
|
||||
|
||||
def _normalize_channel_query(value: str) -> str:
|
||||
@ -96,6 +147,9 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Overlay user-maintained friendly names before persisting.
|
||||
_apply_channel_aliases(platforms)
|
||||
|
||||
directory = {
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"platforms": platforms,
|
||||
@ -247,12 +301,20 @@ def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:
|
||||
def load_directory() -> Dict[str, Any]:
|
||||
"""Load the cached channel directory from disk."""
|
||||
if not DIRECTORY_PATH.exists():
|
||||
return {"updated_at": None, "platforms": {}}
|
||||
base = {"updated_at": None, "platforms": {}}
|
||||
_apply_channel_aliases(base["platforms"])
|
||||
return base
|
||||
try:
|
||||
with open(DIRECTORY_PATH, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
data = json.load(f)
|
||||
# Re-apply aliases on read so friendly names take effect immediately,
|
||||
# even between timed rebuilds and for brand-new alias entries.
|
||||
_apply_channel_aliases(data.setdefault("platforms", {}))
|
||||
return data
|
||||
except Exception:
|
||||
return {"updated_at": None, "platforms": {}}
|
||||
base = {"updated_at": None, "platforms": {}}
|
||||
_apply_channel_aliases(base["platforms"])
|
||||
return base
|
||||
|
||||
|
||||
def lookup_channel_type(platform_name: str, chat_id: str) -> Optional[str]:
|
||||
|
||||
@ -417,9 +417,9 @@ class StreamingConfig:
|
||||
# if the original preview has been visible for at least this many
|
||||
# seconds, so the platform's visible timestamp reflects completion
|
||||
# time instead of the preview creation time. Currently applied to
|
||||
# Telegram only (other platforms ignore the setting). Default 60s
|
||||
# matches the OpenClaw rollout. Set to 0 to disable.
|
||||
fresh_final_after_seconds: float = 60.0
|
||||
# Telegram only (other platforms ignore the setting). Default 0 disables
|
||||
# the fresh-message replacement path; set >0 to opt in.
|
||||
fresh_final_after_seconds: float = 0.0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
@ -446,7 +446,7 @@ class StreamingConfig:
|
||||
),
|
||||
cursor=data.get("cursor", DEFAULT_STREAMING_CURSOR),
|
||||
fresh_final_after_seconds=_coerce_float(
|
||||
data.get("fresh_final_after_seconds"), 60.0
|
||||
data.get("fresh_final_after_seconds"), 0.0
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@ -419,11 +419,11 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
self._mention_patterns = self._compile_mention_patterns()
|
||||
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
|
||||
self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
|
||||
# Bot API 10.1 Rich Messages: send final replies via sendRichMessage
|
||||
# with the raw agent markdown so tables/task lists/etc. render natively.
|
||||
# Enabled by default; users can opt out for clients that accept but do
|
||||
# not render rich messages via platforms.telegram.extra.rich_messages.
|
||||
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", True)
|
||||
# Bot API 10.1 Rich Messages: when explicitly enabled, send final
|
||||
# replies via sendRichMessage with the raw agent markdown so
|
||||
# tables/task lists/etc. render natively. Disabled by default because
|
||||
# several Telegram clients accept but render rich messages poorly.
|
||||
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False)
|
||||
# Latched off after a capability failure on sendRichMessage /
|
||||
# sendRichMessageDraft (e.g. older python-telegram-bot without the
|
||||
# endpoint) so later sends skip the doomed rich attempt entirely.
|
||||
@ -983,7 +983,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
self, content: str, metadata: Optional[Dict[str, Any]] = None
|
||||
) -> bool:
|
||||
return bool(
|
||||
getattr(self, "_rich_messages_enabled", True)
|
||||
getattr(self, "_rich_messages_enabled", False)
|
||||
and not getattr(self, "_rich_send_disabled", False)
|
||||
and not (metadata or {}).get("expect_edits")
|
||||
and content
|
||||
@ -996,23 +996,16 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
def prefers_fresh_final_streaming(
|
||||
self, content: str, metadata: Optional[Dict[str, Any]] = None
|
||||
) -> bool:
|
||||
"""Finalize rich-eligible streamed replies with a fresh sendRichMessage
|
||||
instead of Hermes' current MarkdownV2 edit path.
|
||||
"""Whether to replace a streamed preview with a fresh rich final.
|
||||
|
||||
The final edit path has not yet been upgraded to Bot API 10.1's
|
||||
``rich_message`` edit parameter, so finalizing through edit would lose
|
||||
rich constructs such as tables/task lists. When the completed content
|
||||
is rich-eligible, re-send it via ``sendRichMessage`` and delete the
|
||||
preview (see ``gateway.stream_consumer._try_fresh_final``).
|
||||
|
||||
``metadata`` is intentionally ignored: the preview was sent with
|
||||
``expect_edits=True`` (to stay on the editable path mid-stream), but the
|
||||
FINAL answer is a brand-new message that should render rich. Gating
|
||||
otherwise matches :meth:`_should_attempt_rich`: rich not latched off,
|
||||
content present and within the rich character limit, and the bot exposes
|
||||
an async ``do_api_request``.
|
||||
Keep this disabled for Telegram. The fresh-final path briefly shows two
|
||||
copies of the final answer, then deletes the streaming preview after the
|
||||
rich send succeeds. That is especially visible on clients that support
|
||||
rich messages well, and it looks like duplicate delivery at the end of
|
||||
every streamed turn. Until Telegram rich edits are wired directly, final
|
||||
streamed replies should edit the existing preview in place.
|
||||
"""
|
||||
return self._should_attempt_rich(content)
|
||||
return False
|
||||
|
||||
def streaming_overflow_limit(self) -> Optional[int]:
|
||||
"""Allow the stream consumer to accumulate up to the rich-message cap
|
||||
@ -1026,7 +1019,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
streams split exactly as before.
|
||||
"""
|
||||
if (
|
||||
getattr(self, "_rich_messages_enabled", True)
|
||||
getattr(self, "_rich_messages_enabled", False)
|
||||
and not getattr(self, "_rich_send_disabled", False)
|
||||
and self._bot_supports_rich()
|
||||
):
|
||||
@ -1216,7 +1209,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
def _should_attempt_rich_draft(self, content: str) -> bool:
|
||||
return bool(
|
||||
getattr(self, "_rich_messages_enabled", True)
|
||||
getattr(self, "_rich_messages_enabled", False)
|
||||
and not getattr(self, "_rich_send_disabled", False)
|
||||
and not getattr(self, "_rich_draft_disabled", False)
|
||||
and content
|
||||
@ -5560,6 +5553,52 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
event.text = self._append_observed_note(event.text, cached.context_note())
|
||||
logger.info("[Telegram] Cached observed group %s at %s", cached.kind, cached.path)
|
||||
|
||||
async def _cache_replied_media(self, msg: Any, event: MessageEvent) -> None:
|
||||
"""Cache media from the message this turn replies to, if any."""
|
||||
from gateway.platforms.base import cache_media_bytes
|
||||
|
||||
reply_msg = getattr(msg, "reply_to_message", None)
|
||||
if reply_msg is None:
|
||||
return
|
||||
source, filename, mime, kind = self._observed_media_source(reply_msg)
|
||||
if source is None:
|
||||
return
|
||||
|
||||
max_bytes = getattr(self, "_max_doc_bytes", 20 * 1024 * 1024)
|
||||
file_size = getattr(source, "file_size", None)
|
||||
try:
|
||||
size = int(file_size or 0)
|
||||
except (TypeError, ValueError):
|
||||
size = 0
|
||||
if not (0 < size <= max_bytes):
|
||||
return
|
||||
|
||||
try:
|
||||
file_obj = await source.get_file()
|
||||
data = bytes(await file_obj.download_as_bytearray())
|
||||
if not filename:
|
||||
filename = os.path.basename(getattr(file_obj, "file_path", "") or "")
|
||||
cached = cache_media_bytes(data, filename=filename, mime_type=mime, default_kind=kind)
|
||||
except Exception as exc:
|
||||
logger.warning("[Telegram] Failed to cache replied-to media: %s", exc, exc_info=True)
|
||||
return
|
||||
|
||||
if cached is None:
|
||||
return
|
||||
|
||||
event.media_urls.append(cached.path)
|
||||
event.media_types.append(cached.media_type)
|
||||
if len(event.media_urls) == 1:
|
||||
if cached.kind == "image":
|
||||
event.message_type = MessageType.PHOTO
|
||||
elif cached.kind == "video":
|
||||
event.message_type = MessageType.VIDEO
|
||||
event.text = self._append_observed_note(
|
||||
event.text,
|
||||
f"[Replied-to {cached.kind} '{cached.display_name}' saved at: {cached.path}]",
|
||||
)
|
||||
logger.info("[Telegram] Cached replied-to %s at %s", cached.kind, cached.path)
|
||||
|
||||
def _observed_media_source(self, msg: Message):
|
||||
"""Return (telegram_file_source, filename, mime, default_kind) or Nones."""
|
||||
if msg.photo:
|
||||
@ -5749,6 +5788,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
event = self._build_message_event(msg, MessageType.TEXT, update_id=update.update_id)
|
||||
event.text = self._clean_bot_trigger_text(event.text)
|
||||
await self._cache_replied_media(msg, event)
|
||||
event = self._apply_telegram_group_observe_attribution(event)
|
||||
self._enqueue_text_event(event)
|
||||
|
||||
@ -5763,6 +5803,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
event = self._build_message_event(msg, MessageType.COMMAND, update_id=update.update_id)
|
||||
event.text = self._clean_bot_trigger_text(event.text)
|
||||
await self._cache_replied_media(msg, event)
|
||||
event = self._apply_telegram_group_observe_attribution(event)
|
||||
await self.handle_message(event)
|
||||
|
||||
|
||||
437
gateway/run.py
437
gateway/run.py
@ -402,6 +402,17 @@ async def _send_or_update_status_coro(adapter, chat_id, status_key, content, met
|
||||
return await adapter.send(chat_id, content, metadata=metadata)
|
||||
|
||||
|
||||
def _resolve_progress_thread_id(platform: Any, source_thread_id: Any, event_message_id: Any) -> Optional[str]:
|
||||
"""Return thread/root ID that progress/status bubbles should target."""
|
||||
platform_value = getattr(platform, "value", platform)
|
||||
platform_key = str(platform_value or "").lower()
|
||||
if source_thread_id:
|
||||
return str(source_thread_id)
|
||||
if platform_key in {"slack", "mattermost"} and event_message_id:
|
||||
return str(event_message_id)
|
||||
return None
|
||||
|
||||
|
||||
def _telegramize_command_mentions(text: str, platform: Any) -> str:
|
||||
"""Rewrite slash-command mentions to Telegram-valid command names.
|
||||
|
||||
@ -677,6 +688,15 @@ def _build_gateway_agent_history(
|
||||
clean_msg = {k: v for k, v in msg.items() if k not in {"timestamp", "observed"}}
|
||||
agent_history.append(clean_msg)
|
||||
elif content:
|
||||
# Strip gateway-injected auto-continue notes that were persisted
|
||||
# as part of user messages during interrupted turns. Keep the
|
||||
# user's real text after the note, but never replay the recovery
|
||||
# instruction itself — that is what caused infinite re-execution
|
||||
# loops for interrupted long-running tools.
|
||||
if role == "user":
|
||||
content = _strip_auto_continue_noise(content)
|
||||
if not content:
|
||||
continue
|
||||
# Simple text message - just need role and content.
|
||||
if msg.get("mirror"):
|
||||
mirror_src = msg.get("mirror_source", "another session")
|
||||
@ -684,6 +704,10 @@ def _build_gateway_agent_history(
|
||||
entry = _build_replay_entry(role, content, msg)
|
||||
agent_history.append(entry)
|
||||
|
||||
# Strip interrupted tool-call tails so the LLM doesn't re-execute
|
||||
# tools that were killed mid-flight.
|
||||
agent_history = _strip_interrupted_tool_tails(agent_history)
|
||||
|
||||
observed_context = "\n".join(observed_group_context).strip() or None
|
||||
return agent_history, observed_context
|
||||
|
||||
@ -749,6 +773,99 @@ _AUTO_APPEND_MEDIA_TOOL_NAMES = {
|
||||
"image_generate",
|
||||
}
|
||||
|
||||
# ---- helpers: detect interrupted tool tails & auto-continue noise ----------
|
||||
|
||||
def _is_interrupted_tool_result(content: Any) -> bool:
|
||||
"""Return True if a tool result indicates the tool was interrupted."""
|
||||
if not isinstance(content, str):
|
||||
return False
|
||||
lowered = content.lower()
|
||||
if "[command interrupted]" in lowered:
|
||||
return True
|
||||
if "exit_code" in lowered and ("130" in lowered or "-1" in lowered):
|
||||
return "interrupt" in lowered
|
||||
return False
|
||||
|
||||
|
||||
def _strip_interrupted_tool_tails(
|
||||
agent_history: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Strip interrupted assistant→tool sequences from replay history.
|
||||
|
||||
Older interrupted gateway turns can be followed by a queued real user
|
||||
message, so the interrupted assistant/tool block is not necessarily the
|
||||
final tail by the time we rebuild replay history. Remove any contiguous
|
||||
assistant(tool_calls) + tool-result block that contains an interrupted tool
|
||||
result, while preserving successful tool-call sequences intact.
|
||||
"""
|
||||
if not agent_history:
|
||||
return agent_history
|
||||
|
||||
cleaned: List[Dict[str, Any]] = []
|
||||
i = 0
|
||||
n = len(agent_history)
|
||||
while i < n:
|
||||
msg = agent_history[i]
|
||||
if msg.get("role") == "assistant" and "tool_calls" in msg:
|
||||
j = i + 1
|
||||
tool_results: List[Dict[str, Any]] = []
|
||||
while j < n and agent_history[j].get("role") == "tool":
|
||||
tool_results.append(agent_history[j])
|
||||
j += 1
|
||||
if tool_results and any(
|
||||
_is_interrupted_tool_result(m.get("content", ""))
|
||||
for m in tool_results
|
||||
):
|
||||
logger.debug(
|
||||
"Stripping interrupted assistant→tool replay block "
|
||||
"(indices %d–%d, tool_results=%d)",
|
||||
i, j - 1, len(tool_results),
|
||||
)
|
||||
i = j
|
||||
continue
|
||||
if msg.get("role") == "tool" and _is_interrupted_tool_result(msg.get("content", "")):
|
||||
logger.debug("Stripping orphan interrupted tool result from replay history")
|
||||
i += 1
|
||||
continue
|
||||
cleaned.append(msg)
|
||||
i += 1
|
||||
|
||||
return cleaned
|
||||
|
||||
|
||||
_AUTO_CONTINUE_NOTE_PREFIX = "[System note: Your previous turn"
|
||||
_AUTO_CONTINUE_FALLBACK_PREFIX = "[System note: A new message"
|
||||
|
||||
|
||||
def _is_auto_continue_noise(content: Any) -> bool:
|
||||
"""Return True if this user-message content is a gateway-injected
|
||||
auto-continue note that should NOT be replayed as a real user turn."""
|
||||
if not isinstance(content, str):
|
||||
return False
|
||||
return (
|
||||
content.startswith(_AUTO_CONTINUE_NOTE_PREFIX)
|
||||
or content.startswith(_AUTO_CONTINUE_FALLBACK_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
def _strip_auto_continue_noise(content: Any) -> Any:
|
||||
"""Remove persisted gateway auto-continue note prefix from user text.
|
||||
|
||||
Older gateway builds prepended the recovery note directly to the user
|
||||
message, so the transcript row can contain both the synthetic note and
|
||||
the user's real question. Strip one or more leading synthetic notes while
|
||||
preserving any real text that follows.
|
||||
"""
|
||||
if not _is_auto_continue_noise(content):
|
||||
return content
|
||||
text = str(content)
|
||||
while _is_auto_continue_noise(text):
|
||||
end = text.find("]")
|
||||
if end < 0:
|
||||
return ""
|
||||
text = text[end + 1 :].lstrip()
|
||||
return text
|
||||
|
||||
# Tools in this set return their deliverable artifact as a JSON payload with a
|
||||
# local-file path field rather than a literal ``MEDIA:`` tag (e.g. image_generate
|
||||
# returns ``{"success": true, "image": "/abs/path.png"}``). The auto-append path
|
||||
@ -1815,9 +1932,42 @@ def _format_gateway_process_notification(evt: dict) -> "str | None":
|
||||
text += "]"
|
||||
return text
|
||||
|
||||
if evt_type == "async_delegation":
|
||||
# Reuse the shared rich formatter (self-contained task-source block).
|
||||
from tools.process_registry import format_process_notification
|
||||
return format_process_notification(evt)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _drain_gateway_watch_events(completion_queue) -> "list[dict]":
|
||||
"""Drain gateway-owned watch events without spinning on requeued events.
|
||||
|
||||
Watch events are handled by the post-turn gateway drain. Process
|
||||
completions are owned by their per-process watcher task, and async
|
||||
delegation completions are owned by ``_async_delegation_watcher``.
|
||||
Requeueing async events inside ``while not queue.empty()`` would make the
|
||||
loop non-terminating, so detach the current batch first, then requeue any
|
||||
events this drain does not own after the queue is empty.
|
||||
"""
|
||||
watch_events: list[dict] = []
|
||||
requeue: list[dict] = []
|
||||
while not completion_queue.empty():
|
||||
try:
|
||||
evt = completion_queue.get_nowait()
|
||||
except Exception:
|
||||
break
|
||||
evt_type = evt.get("type", "completion")
|
||||
if evt_type in {"watch_match", "watch_disabled"}:
|
||||
watch_events.append(evt)
|
||||
elif evt_type == "async_delegation":
|
||||
requeue.append(evt)
|
||||
# else: process completion events are handled by the watcher task
|
||||
for evt in requeue:
|
||||
completion_queue.put(evt)
|
||||
return watch_events
|
||||
|
||||
|
||||
# Module-level weak reference to the active GatewayRunner instance.
|
||||
# Used by tools (e.g. send_message) that need to route through a live
|
||||
# adapter for plugin platforms. Set in GatewayRunner.__init__().
|
||||
@ -5247,6 +5397,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
# turn so the agent kicks off the new chat.
|
||||
asyncio.create_task(self._handoff_watcher())
|
||||
|
||||
# Start background async-delegation watcher — drains completion events
|
||||
# from delegate_task(background=true) subagents and injects each
|
||||
# result back into its originating session as a new turn, covering the
|
||||
# idle case where the subagent finishes with no agent turn running.
|
||||
asyncio.create_task(self._async_delegation_watcher())
|
||||
|
||||
logger.info("Press Ctrl+C to stop")
|
||||
|
||||
return True
|
||||
@ -5883,6 +6039,16 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.debug("process_registry.kill_all (%s) error: %s", phase, _e)
|
||||
try:
|
||||
from tools.async_delegation import interrupt_all as _interrupt_async
|
||||
_async_n = _interrupt_async(reason=f"gateway shutdown ({phase})")
|
||||
if _async_n:
|
||||
logger.info(
|
||||
"Shutdown (%s): interrupted %d background delegation(s)",
|
||||
phase, _async_n,
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.debug("async interrupt_all (%s) error: %s", phase, _e)
|
||||
try:
|
||||
from tools.terminal_tool import cleanup_all_environments
|
||||
cleanup_all_environments()
|
||||
@ -7451,6 +7617,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if canonical == "resume":
|
||||
return await self._handle_resume_command(event)
|
||||
|
||||
if canonical == "sessions":
|
||||
return await self._handle_sessions_command(event)
|
||||
|
||||
if canonical == "branch":
|
||||
return await self._handle_branch_command(event)
|
||||
|
||||
@ -8827,6 +8996,20 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_response_time, _api_calls, _resp_len,
|
||||
)
|
||||
|
||||
# Re-baseline the cached agent's message_count snapshot now that
|
||||
# this turn has completed and the agent has flushed its rows to
|
||||
# the SessionDB. The cross-process coherence guard (#45966)
|
||||
# snapshots the count at agent-BUILD time (before this turn's own
|
||||
# writes) and never refreshes it on reuse — so without this, this
|
||||
# process's own turn would grow the count and the next turn would
|
||||
# see a mismatch and rebuild the agent every turn, destroying
|
||||
# prompt caching. Refreshing here makes the guard fire only on a
|
||||
# DIFFERENT process's writes. Uses the (possibly compaction-
|
||||
# updated) live session_id. Fail-safe inside the helper.
|
||||
self._refresh_agent_cache_message_count(
|
||||
session_key, session_entry.session_id
|
||||
)
|
||||
|
||||
# Successful turn — clear any stuck-loop counter for this session.
|
||||
# This ensures the counter only accumulates across CONSECUTIVE
|
||||
# restarts where the session was active (never completed).
|
||||
@ -8930,18 +9113,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
logger.error("Process watcher setup error: %s", e)
|
||||
|
||||
# Drain watch pattern notifications that arrived during the agent run.
|
||||
# Watch events and completions share the same queue; completions are
|
||||
# already handled by the per-process watcher task above, so we only
|
||||
# inject watch-type events here.
|
||||
# Watch events and completions share the same queue; process
|
||||
# completions are already handled by the per-process watcher task
|
||||
# above, so we only inject watch-type events here.
|
||||
#
|
||||
# Async-delegation completions ALSO ride this shared queue but are
|
||||
# owned by the dedicated _async_delegation_watcher (started at
|
||||
# boot), which covers both the idle and post-turn cases with a
|
||||
# single consumer — so we leave them on the queue here.
|
||||
try:
|
||||
from tools.process_registry import process_registry as _pr
|
||||
_watch_events = []
|
||||
while not _pr.completion_queue.empty():
|
||||
evt = _pr.completion_queue.get_nowait()
|
||||
evt_type = evt.get("type", "completion")
|
||||
if evt_type in {"watch_match", "watch_disabled"}:
|
||||
_watch_events.append(evt)
|
||||
# else: completion events are handled by the watcher task
|
||||
_watch_events = _drain_gateway_watch_events(_pr.completion_queue)
|
||||
for evt in _watch_events:
|
||||
synth_text = _format_gateway_process_notification(evt)
|
||||
if synth_text:
|
||||
@ -12200,6 +12382,74 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
except Exception as e:
|
||||
logger.error("Watch notification injection error: %s", e)
|
||||
|
||||
def _enrich_async_delegation_routing(self, evt: dict) -> None:
|
||||
"""Fill platform/chat_id/thread_id/chat_type on an async-delegation event.
|
||||
|
||||
Async-delegation completion events only carry ``session_key`` (the
|
||||
daemon worker has no access to the per-message routing metadata the
|
||||
terminal background watcher captures at spawn time). Parse the
|
||||
session_key into the routing fields ``_build_process_event_source``
|
||||
expects. Best-effort: a CLI-origin event (empty session_key) is left
|
||||
as-is and simply won't route on the gateway.
|
||||
"""
|
||||
if evt.get("platform"):
|
||||
return # already enriched
|
||||
parsed = _parse_session_key(evt.get("session_key", "") or "")
|
||||
if not parsed:
|
||||
return
|
||||
evt["platform"] = parsed.get("platform", "")
|
||||
evt["chat_type"] = parsed.get("chat_type", "")
|
||||
evt["chat_id"] = parsed.get("chat_id", "")
|
||||
if parsed.get("thread_id"):
|
||||
evt["thread_id"] = parsed["thread_id"]
|
||||
|
||||
async def _async_delegation_watcher(self, interval: float = 2.0) -> None:
|
||||
"""Drain async-delegation completions and inject them as new turns.
|
||||
|
||||
Background subagents (``delegate_task(background=true)``) run on the
|
||||
async-delegation daemon executor — they have no per-process watcher
|
||||
task, so their completion events would only be seen by the post-turn
|
||||
queue drain. This watcher covers the IDLE case: when a background
|
||||
subagent finishes while no agent turn is running, its result still
|
||||
re-enters the originating session promptly.
|
||||
|
||||
Mirrors the CLI's idle ``process_loop`` drain. Stays silent when the
|
||||
queue has nothing for us; ignores non-async event types (those are
|
||||
handled by ``_run_process_watcher`` / the post-turn drain).
|
||||
"""
|
||||
await asyncio.sleep(3) # let platforms finish connecting
|
||||
from tools.process_registry import process_registry as _pr
|
||||
while self._running:
|
||||
try:
|
||||
# Peek the queue for async-delegation events. We must NOT
|
||||
# consume watch/completion events here (other drains own them),
|
||||
# so requeue anything that isn't ours.
|
||||
requeue = []
|
||||
async_events = []
|
||||
while not _pr.completion_queue.empty():
|
||||
try:
|
||||
evt = _pr.completion_queue.get_nowait()
|
||||
except Exception:
|
||||
break
|
||||
if evt.get("type") == "async_delegation":
|
||||
async_events.append(evt)
|
||||
else:
|
||||
requeue.append(evt)
|
||||
for evt in requeue:
|
||||
_pr.completion_queue.put(evt)
|
||||
for evt in async_events:
|
||||
self._enrich_async_delegation_routing(evt)
|
||||
synth_text = _format_gateway_process_notification(evt)
|
||||
if not synth_text:
|
||||
continue
|
||||
try:
|
||||
await self._inject_watch_notification(synth_text, evt)
|
||||
except Exception as e:
|
||||
logger.error("Async delegation injection error: %s", e)
|
||||
except Exception as e:
|
||||
logger.debug("Async delegation watcher error: %s", e)
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
async def _run_process_watcher(self, watcher: dict) -> None:
|
||||
"""
|
||||
Periodically check a background process and push updates to the user.
|
||||
@ -12256,7 +12506,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if session.exited:
|
||||
# --- Agent-triggered completion: inject synthetic message ---
|
||||
# Skip if the agent already consumed the result via wait/poll/log
|
||||
from tools.process_registry import process_registry as _pr_check
|
||||
from tools.process_registry import format_process_notification, process_registry as _pr_check
|
||||
if agent_notify and not _pr_check.is_completion_consumed(session_id):
|
||||
from tools.ansi_strip import strip_ansi
|
||||
_raw = strip_ansi(session.output_buffer) if session.output_buffer else ""
|
||||
@ -12272,12 +12522,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_out = f"[… output truncated — showing last {len(_tail)} chars]\n{_tail}"
|
||||
else:
|
||||
_out = _raw
|
||||
synth_text = (
|
||||
f"[IMPORTANT: Background process {session_id} completed "
|
||||
f"(exit code {session.exit_code}).\n"
|
||||
f"Command: {session.command}\n"
|
||||
f"Output:\n{_out}]"
|
||||
)
|
||||
synth_text = format_process_notification({
|
||||
"type": "completion",
|
||||
"session_id": session_id,
|
||||
"command": session.command,
|
||||
"exit_code": session.exit_code,
|
||||
"completion_reason": getattr(session, "completion_reason", "exited"),
|
||||
"termination_source": getattr(session, "termination_source", ""),
|
||||
"output": _out,
|
||||
})
|
||||
if not synth_text:
|
||||
break
|
||||
source = self._build_process_event_source({
|
||||
"session_id": session_id,
|
||||
"session_key": session_key,
|
||||
@ -12736,6 +12991,57 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if release_running_state:
|
||||
self._release_running_agent_state(session_key)
|
||||
|
||||
def _refresh_agent_cache_message_count(
|
||||
self, session_key: str, session_id: Optional[str]
|
||||
) -> None:
|
||||
"""Re-baseline a cached agent's stored message_count after THIS turn.
|
||||
|
||||
The cross-process coherence guard (#45966) compares the session's
|
||||
on-disk ``message_count`` against the count snapshotted next to the
|
||||
cached agent, and rebuilds the agent on a mismatch. But the snapshot
|
||||
is taken at agent-BUILD time — before this turn writes its own user +
|
||||
assistant (+ tool) rows — and the cache entry is never rewritten on a
|
||||
reuse. So without this re-baseline, THIS process's own turn would
|
||||
grow ``message_count`` and the very next turn would see a mismatch
|
||||
and rebuild the agent — every turn, for every conversation — silently
|
||||
destroying the per-conversation prompt caching the cache exists to
|
||||
protect.
|
||||
|
||||
Call this once a turn has completed and the agent has flushed its
|
||||
rows to the SessionDB. It snapshots the now-current count (which
|
||||
includes this process's own writes) so the guard only fires when a
|
||||
DIFFERENT process changes the transcript out from under us. The
|
||||
``_sig`` is left untouched; only the count element is refreshed, and
|
||||
only when the same agent is still cached (no rebuild/eviction raced
|
||||
in between). Fail-safe: any DB error leaves the snapshot as-is, which
|
||||
at worst costs one unnecessary rebuild on the next turn.
|
||||
"""
|
||||
if self._session_db is None or not session_id:
|
||||
return
|
||||
_cache_lock = getattr(self, "_agent_cache_lock", None)
|
||||
_cache = getattr(self, "_agent_cache", None)
|
||||
if not _cache_lock or _cache is None:
|
||||
return
|
||||
try:
|
||||
_sess_row = self._session_db.get_session(session_id)
|
||||
_live = _sess_row.get("message_count", 0) if _sess_row else None
|
||||
except Exception:
|
||||
return
|
||||
if _live is None:
|
||||
return
|
||||
with _cache_lock:
|
||||
cached = _cache.get(session_key)
|
||||
# Only re-baseline a live 3-tuple entry; skip pending sentinels,
|
||||
# legacy 2-tuples (they intentionally opt out of the guard), and
|
||||
# the case where the entry was evicted/rebuilt mid-turn.
|
||||
if (
|
||||
isinstance(cached, tuple)
|
||||
and len(cached) > 2
|
||||
and cached[0] is not _AGENT_PENDING_SENTINEL
|
||||
):
|
||||
if cached[2] != _live:
|
||||
_cache[session_key] = (cached[0], cached[1], _live)
|
||||
|
||||
def _evict_cached_agent(self, session_key: str) -> None:
|
||||
"""Remove a cached agent for a session (called on /new, /model, etc).
|
||||
|
||||
@ -13648,10 +13954,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
# - Feishu only honors reply_in_thread when sending a reply, so topic
|
||||
# progress uses the triggering event message as the reply target
|
||||
# - Other platforms should use explicit source.thread_id only
|
||||
if source.platform == Platform.SLACK:
|
||||
_progress_thread_id = source.thread_id or event_message_id
|
||||
else:
|
||||
_progress_thread_id = source.thread_id
|
||||
_progress_thread_id = _resolve_progress_thread_id(
|
||||
source.platform, source.thread_id, event_message_id,
|
||||
)
|
||||
_progress_metadata = (
|
||||
self._thread_metadata_for_source(source, event_message_id)
|
||||
if _progress_thread_id == source.thread_id
|
||||
@ -14260,20 +14565,57 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
agent = None
|
||||
_cache_lock = getattr(self, "_agent_cache_lock", None)
|
||||
_cache = getattr(self, "_agent_cache", None)
|
||||
|
||||
# Detect cross-process writes: when another process (e.g. hermes
|
||||
# dashboard) appends to the same session in the shared SessionDB,
|
||||
# the cached agent's in-memory transcript becomes stale. Compare
|
||||
# the session's current message_count against the count recorded
|
||||
# when the agent was cached; on mismatch, invalidate the cache
|
||||
# so a fresh agent re-reads from disk. (#45966)
|
||||
_current_msg_count = None
|
||||
if self._session_db is not None and session_id:
|
||||
try:
|
||||
_sess_row = self._session_db.get_session(session_id)
|
||||
if _sess_row:
|
||||
_current_msg_count = _sess_row.get("message_count", 0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if _cache_lock and _cache is not None:
|
||||
with _cache_lock:
|
||||
cached = _cache.get(session_key)
|
||||
if cached and cached[1] == _sig:
|
||||
agent = cached[0]
|
||||
# Refresh LRU order so the cap enforcement evicts
|
||||
# truly-oldest entries, not the one we just used.
|
||||
if hasattr(_cache, "move_to_end"):
|
||||
try:
|
||||
_cache.move_to_end(session_key)
|
||||
except KeyError:
|
||||
pass
|
||||
self._init_cached_agent_for_turn(agent, _interrupt_depth)
|
||||
logger.debug("Reusing cached agent for session %s", session_key)
|
||||
# cached[2] is the message_count at cache time;
|
||||
# stale when a second process appended rows.
|
||||
_cached_mc = cached[2] if len(cached) > 2 else None
|
||||
if (
|
||||
_cached_mc is not None
|
||||
and _current_msg_count is not None
|
||||
and _current_msg_count != _cached_mc
|
||||
):
|
||||
# Cross-process write detected — discard stale
|
||||
# agent so it rebuilds from fresh DB transcript.
|
||||
logger.info(
|
||||
"Agent cache invalidated for session %s: "
|
||||
"message_count changed (%s -> %s), "
|
||||
"possible cross-process write",
|
||||
session_key, _cached_mc, _current_msg_count,
|
||||
)
|
||||
evicted = self._agent_cache.pop(session_key, None)
|
||||
_ev_agent = evicted[0] if isinstance(evicted, tuple) and evicted else None
|
||||
if _ev_agent and _ev_agent is not _AGENT_PENDING_SENTINEL:
|
||||
self._cleanup_agent_resources(_ev_agent)
|
||||
else:
|
||||
agent = cached[0]
|
||||
# Refresh LRU order so the cap enforcement evicts
|
||||
# truly-oldest entries, not the one we just used.
|
||||
if hasattr(_cache, "move_to_end"):
|
||||
try:
|
||||
_cache.move_to_end(session_key)
|
||||
except KeyError:
|
||||
pass
|
||||
self._init_cached_agent_for_turn(agent, _interrupt_depth)
|
||||
logger.debug("Reusing cached agent for session %s", session_key)
|
||||
|
||||
if agent is None:
|
||||
# Config changed or first message — create fresh agent
|
||||
@ -14311,7 +14653,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
)
|
||||
if _cache_lock and _cache is not None:
|
||||
with _cache_lock:
|
||||
_cache[session_key] = (agent, _sig)
|
||||
_cache[session_key] = (agent, _sig, _current_msg_count)
|
||||
self._enforce_agent_cache_cap()
|
||||
logger.debug("Created new agent for session %s (sig=%s)", session_key, _sig)
|
||||
|
||||
@ -14625,6 +14967,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
except Exception as _e:
|
||||
logger.error("Failed to send approval request: %s", _e)
|
||||
|
||||
# Keep real user text separate from API-only recovery guidance. If
|
||||
# an auto-continue note is prepended below, persist the original
|
||||
# message so stale guidance never replays as user-authored text.
|
||||
_persist_user_message_override: Optional[Any] = None
|
||||
|
||||
# Prepend pending model switch note so the model knows about the switch
|
||||
_pending_notes = getattr(self, '_pending_model_notes', {})
|
||||
_msn = _pending_notes.pop(session_key, None) if session_key else None
|
||||
@ -14685,21 +15032,23 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if _reason == "shutdown_timeout"
|
||||
else "a gateway interruption"
|
||||
)
|
||||
_persist_user_message_override = message
|
||||
message = (
|
||||
f"[System note: Your previous turn in this session was interrupted "
|
||||
f"by {_reason_phrase}. The conversation history below is intact. "
|
||||
f"If it contains unfinished tool result(s), process them first and "
|
||||
f"summarize what was accomplished, then address the user's new "
|
||||
f"message below.]\n\n"
|
||||
f"[System note: A new message has arrived. The previous turn "
|
||||
f"was interrupted by {_reason_phrase}. "
|
||||
f"Address the user's NEW message below FIRST. "
|
||||
f"Do NOT re-execute old tool calls — skip any unfinished "
|
||||
f"work from the conversation history and focus on what the "
|
||||
f"user is asking now.]\n\n"
|
||||
+ message
|
||||
)
|
||||
elif _has_fresh_tool_tail:
|
||||
_persist_user_message_override = message
|
||||
message = (
|
||||
"[System note: Your previous turn was interrupted before you could "
|
||||
"process the last tool result(s). The conversation history contains "
|
||||
"tool outputs you haven't responded to yet. Please finish processing "
|
||||
"those results and summarize what was accomplished, then address the "
|
||||
"user's new message below.]\n\n"
|
||||
"[System note: A new message has arrived. The conversation "
|
||||
"history contains pending tool outputs from an interrupted turn. "
|
||||
"IGNORE those pending results. Address the user's NEW message "
|
||||
"below FIRST. Do NOT re-execute old tool calls from the history.]\n\n"
|
||||
+ message
|
||||
)
|
||||
|
||||
@ -14757,7 +15106,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"conversation_history": agent_history,
|
||||
"task_id": session_id,
|
||||
}
|
||||
if observed_group_context:
|
||||
if _persist_user_message_override is not None:
|
||||
_conversation_kwargs["persist_user_message"] = _persist_user_message_override
|
||||
elif observed_group_context:
|
||||
_conversation_kwargs["persist_user_message"] = message
|
||||
_moa_cfg = moa_config
|
||||
if _moa_cfg is None:
|
||||
|
||||
@ -394,20 +394,35 @@ class GatewaySlashCommandsMixin:
|
||||
|
||||
async def _handle_status_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /status command."""
|
||||
from gateway.run import _AGENT_PENDING_SENTINEL, _load_gateway_config, _resolve_gateway_model
|
||||
|
||||
source = event.source
|
||||
session_entry = self.session_store.get_or_create_session(source)
|
||||
|
||||
connected_platforms = [p.value for p in self.adapters.keys()]
|
||||
|
||||
# Check if there's an active agent
|
||||
# Check if there's an active agent. Keep the sentinel distinct: a
|
||||
# starting/pending run should not be treated as a fully usable agent for
|
||||
# model/context display, but it still occupies the session slot.
|
||||
session_key = session_entry.session_key
|
||||
is_running = session_key in self._running_agents
|
||||
agent = self._running_agents.get(session_key)
|
||||
is_running = agent is not None and agent is not _AGENT_PENDING_SENTINEL
|
||||
|
||||
# Count pending /queue follow-ups (slot + overflow).
|
||||
adapter = self.adapters.get(source.platform) if source else None
|
||||
queue_depth = self._queue_depth(session_key, adapter=adapter)
|
||||
|
||||
def _clean_str(value: Any) -> str:
|
||||
return value.strip() if isinstance(value, str) and value.strip() else ""
|
||||
|
||||
def _int_value(value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
title = None
|
||||
session_row: dict[str, Any] = {}
|
||||
# Pull token totals from the SQLite session DB rather than the
|
||||
# in-memory SessionStore. The agent's per-turn token deltas are
|
||||
# persisted into sessions_db (run_agent.py), not into SessionEntry,
|
||||
@ -422,17 +437,92 @@ class GatewaySlashCommandsMixin:
|
||||
title = None
|
||||
try:
|
||||
row = self._session_db.get_session(session_entry.session_id)
|
||||
if row:
|
||||
if isinstance(row, dict):
|
||||
session_row = row
|
||||
db_total_tokens = (
|
||||
(row.get("input_tokens") or 0)
|
||||
+ (row.get("output_tokens") or 0)
|
||||
+ (row.get("cache_read_tokens") or 0)
|
||||
+ (row.get("cache_write_tokens") or 0)
|
||||
+ (row.get("reasoning_tokens") or 0)
|
||||
_int_value(row.get("input_tokens"))
|
||||
+ _int_value(row.get("output_tokens"))
|
||||
+ _int_value(row.get("cache_read_tokens"))
|
||||
+ _int_value(row.get("cache_write_tokens"))
|
||||
+ _int_value(row.get("reasoning_tokens"))
|
||||
)
|
||||
except Exception:
|
||||
db_total_tokens = 0
|
||||
|
||||
# Resolve model/context for cockpit-style status. Prefer the live or
|
||||
# cached agent because it carries the actual runtime route and context
|
||||
# compressor. Fall back to persisted SessionDB metadata plus the
|
||||
# SessionStore's last_prompt_tokens so /status remains useful between
|
||||
# turns without making billing/account calls.
|
||||
status_agent = agent if is_running else None
|
||||
if status_agent is None:
|
||||
cache_lock = getattr(self, "_agent_cache_lock", None)
|
||||
cache = getattr(self, "_agent_cache", None)
|
||||
if cache_lock is not None and cache is not None:
|
||||
try:
|
||||
with cache_lock:
|
||||
cached = cache.get(session_key)
|
||||
if cached:
|
||||
status_agent = cached[0]
|
||||
except Exception:
|
||||
status_agent = None
|
||||
|
||||
model_name = ""
|
||||
provider_name = ""
|
||||
base_url = ""
|
||||
context_used = 0
|
||||
context_total = 0
|
||||
if status_agent is not None and status_agent is not _AGENT_PENDING_SENTINEL:
|
||||
model_name = _clean_str(getattr(status_agent, "model", ""))
|
||||
provider_name = _clean_str(getattr(status_agent, "provider", ""))
|
||||
base_url = _clean_str(getattr(status_agent, "base_url", ""))
|
||||
ctx = getattr(status_agent, "context_compressor", None)
|
||||
if ctx is not None:
|
||||
context_used = _int_value(getattr(ctx, "last_prompt_tokens", 0))
|
||||
context_total = _int_value(getattr(ctx, "context_length", 0))
|
||||
|
||||
model_name = model_name or _clean_str(session_row.get("model"))
|
||||
provider_name = provider_name or _clean_str(session_row.get("billing_provider"))
|
||||
base_url = base_url or _clean_str(session_row.get("billing_base_url"))
|
||||
context_used = context_used or _int_value(getattr(session_entry, "last_prompt_tokens", 0))
|
||||
|
||||
user_config: dict[str, Any] = {}
|
||||
if not model_name or not provider_name or not context_total:
|
||||
try:
|
||||
user_config = _load_gateway_config()
|
||||
except Exception:
|
||||
user_config = {}
|
||||
if not model_name:
|
||||
model_name = _resolve_gateway_model(user_config)
|
||||
if not provider_name:
|
||||
model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {}
|
||||
if isinstance(model_cfg, dict):
|
||||
provider_name = _clean_str(model_cfg.get("provider"))
|
||||
if not context_total:
|
||||
model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {}
|
||||
configured_context = model_cfg.get("context_length") if isinstance(model_cfg, dict) else None
|
||||
if isinstance(configured_context, int) and configured_context > 0:
|
||||
context_total = configured_context
|
||||
|
||||
model_line = ""
|
||||
if model_name:
|
||||
if provider_name:
|
||||
model_line = t("gateway.status.model_provider", model=model_name, provider=provider_name)
|
||||
else:
|
||||
model_line = t("gateway.status.model", model=model_name)
|
||||
|
||||
context_line = ""
|
||||
if context_total:
|
||||
pct = min(100, round((context_used / context_total) * 100)) if context_total else 0
|
||||
context_line = t(
|
||||
"gateway.status.context",
|
||||
used=f"{context_used:,}",
|
||||
total=f"{context_total:,}",
|
||||
pct=f"{pct}",
|
||||
)
|
||||
elif context_used:
|
||||
context_line = t("gateway.status.context_used", used=f"{context_used:,}")
|
||||
|
||||
lines = [
|
||||
t("gateway.status.header"),
|
||||
"",
|
||||
@ -443,6 +533,12 @@ class GatewaySlashCommandsMixin:
|
||||
lines.extend([
|
||||
t("gateway.status.created", timestamp=session_entry.created_at.strftime('%Y-%m-%d %H:%M')),
|
||||
t("gateway.status.last_activity", timestamp=session_entry.updated_at.strftime('%Y-%m-%d %H:%M')),
|
||||
])
|
||||
if model_line:
|
||||
lines.append(model_line)
|
||||
if context_line:
|
||||
lines.append(context_line)
|
||||
lines.extend([
|
||||
t("gateway.status.tokens", tokens=f"{db_total_tokens:,}"),
|
||||
t("gateway.status.agent_running", state=t("gateway.status.state_yes") if is_running else t("gateway.status.state_no")),
|
||||
])
|
||||
@ -2845,6 +2941,52 @@ class GatewaySlashCommandsMixin:
|
||||
return t("gateway.resume.resumed_one", title=title, count=msg_count)
|
||||
return t("gateway.resume.resumed_many", title=title, count=msg_count)
|
||||
|
||||
async def _handle_sessions_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /sessions — list previous sessions for gateway chats."""
|
||||
if not self._session_db:
|
||||
from hermes_state import format_session_db_unavailable
|
||||
return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
|
||||
|
||||
from hermes_cli.session_listing import (
|
||||
format_gateway_session_listing,
|
||||
parse_session_listing_args,
|
||||
query_session_listing,
|
||||
)
|
||||
|
||||
source = event.source
|
||||
raw_args = event.get_command_args().strip()
|
||||
try:
|
||||
include_all, include_unnamed, target = parse_session_listing_args(raw_args)
|
||||
except ValueError as exc:
|
||||
return t("gateway.resume.parse_error", error=exc)
|
||||
|
||||
if target:
|
||||
resume_event = dataclasses.replace(event, text=f"/resume {target}")
|
||||
return await self._handle_resume_command(resume_event)
|
||||
|
||||
current_entry = self.session_store.get_or_create_session(source)
|
||||
rows = query_session_listing(
|
||||
self._session_db,
|
||||
source=source.platform.value if source.platform else None,
|
||||
current_session_id=current_entry.session_id,
|
||||
include_all_sources=include_all,
|
||||
include_unnamed=include_unnamed,
|
||||
limit=10,
|
||||
exclude_sources=["tool"],
|
||||
)
|
||||
if source.platform == Platform.MATRIX and not include_all:
|
||||
rows = [
|
||||
row for row in rows
|
||||
if self._same_matrix_room(
|
||||
source, self._gateway_session_origin_for_id(str(row.get("id") or ""))
|
||||
)
|
||||
]
|
||||
return format_gateway_session_listing(
|
||||
rows,
|
||||
include_source=include_all,
|
||||
title="Sessions" if include_unnamed else "Named Sessions",
|
||||
)
|
||||
|
||||
async def _handle_branch_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /branch [name] — fork the current session into a new independent copy.
|
||||
|
||||
|
||||
@ -643,6 +643,21 @@ def acquire_scoped_lock(scope: str, identity: str, metadata: Optional[dict[str,
|
||||
live_cmdline = _read_process_cmdline(existing_pid)
|
||||
if live_cmdline is not None or not _record_looks_like_gateway(existing):
|
||||
stale = True
|
||||
# Secondary defence against boot-time PID+start_time collisions:
|
||||
# systemd spawns core services deterministically, so an unrelated
|
||||
# process (e.g. cron) can land on the exact same PID and jiffy
|
||||
# count as a previous gateway. If both start_times are known and
|
||||
# match but the live process is not a gateway, and we can confirm
|
||||
# that by reading its cmdline, the lock is stale.
|
||||
if (
|
||||
not stale
|
||||
and existing.get("start_time") is not None
|
||||
and current_start is not None
|
||||
and not _looks_like_gateway_process(existing_pid)
|
||||
):
|
||||
live_cmdline = _read_process_cmdline(existing_pid)
|
||||
if live_cmdline is not None:
|
||||
stale = True
|
||||
# Check if process is stopped (Ctrl+Z / SIGTSTP) — stopped
|
||||
# processes still appear alive to _pid_exists but are not
|
||||
# actually running. Treat them as stale so --replace works.
|
||||
|
||||
@ -635,6 +635,15 @@ class GatewayStreamConsumer:
|
||||
)
|
||||
if self._final_response_sent:
|
||||
self._final_content_delivered = True
|
||||
elif self._fallback_final_send:
|
||||
# The final edit attempt itself may be the one
|
||||
# that exhausts flood-control strikes and
|
||||
# promotes the consumer into fallback mode. Do
|
||||
# not return to the gateway with a full-response
|
||||
# fallback still pending; send only the unsent
|
||||
# tail here so the normal gateway send path does
|
||||
# not duplicate the visible prefix.
|
||||
await self._send_fallback_final(self._accumulated)
|
||||
elif not self._already_sent:
|
||||
self._final_response_sent = await self._send_or_edit(self._accumulated)
|
||||
if self._final_response_sent:
|
||||
|
||||
@ -103,7 +103,12 @@ XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:acces
|
||||
XAI_OAUTH_REDIRECT_HOST = "127.0.0.1"
|
||||
XAI_OAUTH_REDIRECT_PORT = 56121
|
||||
XAI_OAUTH_REDIRECT_PATH = "/callback"
|
||||
XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
||||
# xAI/Grok OAuth access tokens are intentionally short-lived (about 6h in
|
||||
# current SuperGrok flows). A two-minute refresh window is too narrow for
|
||||
# gateway/cron workloads that may only touch the provider every 30 minutes,
|
||||
# leaving brief but noisy credential-expiry gaps. Refresh up to one hour
|
||||
# early so ordinary runtime calls keep the token warm without user reauth.
|
||||
XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 3600
|
||||
QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"
|
||||
QWEN_OAUTH_TOKEN_URL = "https://chat.qwen.ai/api/v1/oauth2/token"
|
||||
QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
||||
@ -616,8 +621,8 @@ ZAI_ENDPOINTS = [
|
||||
# (id, base_url, probe_models, label)
|
||||
("global", "https://api.z.ai/api/paas/v4", ["glm-5"], "Global"),
|
||||
("cn", "https://open.bigmodel.cn/api/paas/v4", ["glm-5"], "China"),
|
||||
("coding-global", "https://api.z.ai/api/coding/paas/v4", ["glm-5.1", "glm-5v-turbo", "glm-4.7"], "Global (Coding Plan)"),
|
||||
("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", ["glm-5.1", "glm-5v-turbo", "glm-4.7"], "China (Coding Plan)"),
|
||||
("coding-global", "https://api.z.ai/api/coding/paas/v4", ["glm-5.2", "glm-5.1", "glm-5v-turbo", "glm-4.7"], "Global (Coding Plan)"),
|
||||
("coding-cn", "https://open.bigmodel.cn/api/coding/paas/v4", ["glm-5.2", "glm-5.1", "glm-5v-turbo", "glm-4.7"], "China (Coding Plan)"),
|
||||
]
|
||||
|
||||
|
||||
@ -1079,8 +1084,13 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]:
|
||||
return {"version": AUTH_STORE_VERSION, "providers": {}}
|
||||
|
||||
|
||||
def _save_auth_store(auth_store: Dict[str, Any]) -> Path:
|
||||
auth_file = _auth_file_path()
|
||||
def _save_auth_store(auth_store: Dict[str, Any], target_path: Optional[Path] = None) -> Path:
|
||||
# target_path=None preserves the existing contract (write the active
|
||||
# store at _auth_file_path()). An explicit path lets callers persist a
|
||||
# specific store — e.g. the global-root write-through for rotating xAI
|
||||
# OAuth grants (#43589) — reusing this function's atomic O_EXCL + 0o600
|
||||
# write so the root auth.json gets the same TOCTOU-safe treatment.
|
||||
auth_file = target_path if target_path is not None else _auth_file_path()
|
||||
auth_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Tighten parent dir to 0o700 so siblings can't traverse to creds.
|
||||
# No-op on Windows (POSIX mode bits not enforced); ignore failures.
|
||||
@ -3886,13 +3896,64 @@ def _pool_codex_access_token() -> str:
|
||||
# xAI Grok OAuth — tokens stored in ~/.hermes/auth.json
|
||||
# =============================================================================
|
||||
|
||||
def _xai_oauth_state_from_store(auth_store: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Return usable xAI OAuth state from provider state or credential pool."""
|
||||
state = _load_provider_state(auth_store, "xai-oauth")
|
||||
tokens = state.get("tokens") if isinstance(state, dict) else None
|
||||
if isinstance(tokens, dict):
|
||||
access_token = str(tokens.get("access_token", "") or "").strip()
|
||||
refresh_token = str(tokens.get("refresh_token", "") or "").strip()
|
||||
if access_token and refresh_token:
|
||||
return state
|
||||
|
||||
credential_pool = auth_store.get("credential_pool")
|
||||
entries = (
|
||||
credential_pool.get("xai-oauth")
|
||||
if isinstance(credential_pool, dict)
|
||||
else None
|
||||
)
|
||||
if isinstance(entries, list):
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
access_token = str(entry.get("access_token", "") or "").strip()
|
||||
refresh_token = str(entry.get("refresh_token", "") or "").strip()
|
||||
if not access_token or not refresh_token:
|
||||
continue
|
||||
merged = dict(state or {})
|
||||
merged["tokens"] = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": str(entry.get("token_type") or "Bearer"),
|
||||
}
|
||||
if entry.get("last_refresh"):
|
||||
merged["last_refresh"] = entry.get("last_refresh")
|
||||
merged.setdefault("auth_mode", "oauth_pkce")
|
||||
return merged
|
||||
|
||||
return state if isinstance(state, dict) else None
|
||||
|
||||
|
||||
def _xai_oauth_state_has_usable_tokens(state: Optional[Dict[str, Any]]) -> bool:
|
||||
tokens = state.get("tokens") if isinstance(state, dict) else None
|
||||
return (
|
||||
isinstance(tokens, dict)
|
||||
and bool(str(tokens.get("access_token", "") or "").strip())
|
||||
and bool(str(tokens.get("refresh_token", "") or "").strip())
|
||||
)
|
||||
|
||||
|
||||
def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]:
|
||||
if _lock:
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
else:
|
||||
auth_store = _load_auth_store()
|
||||
state = _load_provider_state(auth_store, "xai-oauth")
|
||||
state = _xai_oauth_state_from_store(auth_store)
|
||||
if not _xai_oauth_state_has_usable_tokens(state):
|
||||
global_state = _xai_oauth_state_from_store(_load_global_auth_store())
|
||||
if _xai_oauth_state_has_usable_tokens(global_state):
|
||||
state = global_state
|
||||
if not state:
|
||||
raise AuthError(
|
||||
"No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok / Premium+) in `hermes model`.",
|
||||
@ -3932,6 +3993,62 @@ def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _profile_has_own_xai_oauth_state(auth_store: Dict[str, Any]) -> bool:
|
||||
"""True when this store has its OWN ``providers.xai-oauth`` block.
|
||||
|
||||
Distinguishes a profile that genuinely shadows the root xAI grant from
|
||||
one that only *reads* root via ``_load_provider_state``'s fallback. Only
|
||||
the latter needs the refresh write-through below.
|
||||
"""
|
||||
providers = auth_store.get("providers")
|
||||
return isinstance(providers, dict) and isinstance(providers.get("xai-oauth"), dict)
|
||||
|
||||
|
||||
def _write_through_xai_oauth_to_global_root(state: Dict[str, Any]) -> None:
|
||||
"""Persist a rotated xAI OAuth ``state`` into the global-root auth.json.
|
||||
|
||||
Best-effort write-through for the multi-profile rotation hazard (#43589):
|
||||
xAI rotates the refresh_token on every refresh, so when a profile session
|
||||
refreshes a grant it resolved from the root fallback, the rotated chain
|
||||
must land back in root. Otherwise root keeps a now-revoked refresh token
|
||||
and every other profile reading the stale root grant dies with
|
||||
``invalid_grant`` once its access token expires.
|
||||
|
||||
Only updates ``providers.xai-oauth`` in the root store; never touches the
|
||||
profile store (the caller already saved that). Swallows all errors — a
|
||||
failed write-through degrades to the pre-existing behavior (root stale),
|
||||
it must never break the profile's own successful save.
|
||||
"""
|
||||
global_path = _global_auth_file_path()
|
||||
if global_path is None:
|
||||
# Classic mode (profile == root); the profile save already hit root.
|
||||
return
|
||||
# Seat belt: under pytest, refuse to write the real user's
|
||||
# ~/.hermes/auth.json even when HERMES_HOME points at a profile path
|
||||
# (mirrors the read-side guard in _load_global_auth_store). Uses the
|
||||
# unmodified HOME env, not Path.home() which fixtures may monkeypatch.
|
||||
if os.environ.get("PYTEST_CURRENT_TEST"):
|
||||
real_home_env = os.environ.get("HOME", "")
|
||||
if real_home_env:
|
||||
real_root = Path(real_home_env) / ".hermes" / "auth.json"
|
||||
try:
|
||||
if global_path.resolve(strict=False) == real_root.resolve(strict=False):
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
if global_path.exists():
|
||||
global_store = _load_auth_store(global_path)
|
||||
else:
|
||||
global_store = {}
|
||||
if not isinstance(global_store, dict):
|
||||
return
|
||||
_store_provider_state(global_store, "xai-oauth", dict(state), set_active=False)
|
||||
_save_auth_store(global_store, global_path)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.debug("xAI OAuth: write-through to global root failed: %s", exc)
|
||||
|
||||
|
||||
def _save_xai_oauth_tokens(
|
||||
tokens: Dict[str, Any],
|
||||
*,
|
||||
@ -3943,6 +4060,11 @@ def _save_xai_oauth_tokens(
|
||||
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
# A profile that lacks its own xai-oauth block is reading the root
|
||||
# grant through _load_provider_state's fallback. When such a profile
|
||||
# refreshes the (rotating) grant, we must write the rotated chain back
|
||||
# to root too, or root is left holding a revoked refresh token (#43589).
|
||||
write_through_to_root = not _profile_has_own_xai_oauth_state(auth_store)
|
||||
state = _load_provider_state(auth_store, "xai-oauth") or {}
|
||||
state["tokens"] = tokens
|
||||
state["last_refresh"] = last_refresh
|
||||
@ -3953,6 +4075,8 @@ def _save_xai_oauth_tokens(
|
||||
state["redirect_uri"] = redirect_uri
|
||||
_save_provider_state(auth_store, "xai-oauth", state)
|
||||
_save_auth_store(auth_store)
|
||||
if write_through_to_root:
|
||||
_write_through_xai_oauth_to_global_root(state)
|
||||
|
||||
|
||||
def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> bool:
|
||||
@ -5639,18 +5763,24 @@ def _snapshot_nous_pool_status() -> Dict[str, Any]:
|
||||
# subscription-feature checks) call it many times per render — `hermes tools` → "All Platforms"
|
||||
# was firing the refresh ~31× during one menu paint, racking up >13s of HTTP and burning
|
||||
# single-use refresh tokens. Cache the snapshot for a few seconds, keyed on the auth.json
|
||||
# mtime so that `hermes auth login/logout/add/remove` invalidate naturally on the next call.
|
||||
# path + mtime so that profile switches do not share a process memo and
|
||||
# `hermes auth login/logout/add/remove` invalidate naturally on the next call.
|
||||
_NOUS_AUTH_STATUS_CACHE_TTL = 15.0 # seconds
|
||||
_nous_auth_status_cache: Optional[Tuple[float, Optional[float], Dict[str, Any]]] = None
|
||||
_nous_auth_status_cache: Optional[Tuple[float, str, Optional[float], Dict[str, Any]]] = None
|
||||
|
||||
|
||||
def _auth_file_mtime() -> Optional[float]:
|
||||
def _auth_file_cache_key() -> Tuple[str, Optional[float]]:
|
||||
auth_file = _auth_file_path()
|
||||
try:
|
||||
return _auth_file_path().stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
auth_file_key = str(auth_file.resolve(strict=False))
|
||||
except Exception:
|
||||
return None
|
||||
auth_file_key = str(auth_file)
|
||||
try:
|
||||
return auth_file_key, auth_file.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
return auth_file_key, None
|
||||
except Exception:
|
||||
return auth_file_key, None
|
||||
|
||||
|
||||
def invalidate_nous_auth_status_cache() -> None:
|
||||
@ -5682,18 +5812,19 @@ def get_nous_auth_status() -> Dict[str, Any]:
|
||||
"""
|
||||
global _nous_auth_status_cache
|
||||
now = time.monotonic()
|
||||
mtime = _auth_file_mtime()
|
||||
auth_file_key, mtime = _auth_file_cache_key()
|
||||
cached = _nous_auth_status_cache
|
||||
if cached is not None:
|
||||
cached_at, cached_mtime, cached_status = cached
|
||||
cached_at, cached_auth_file_key, cached_mtime, cached_status = cached
|
||||
if (
|
||||
cached_mtime == mtime
|
||||
cached_auth_file_key == auth_file_key
|
||||
and cached_mtime == mtime
|
||||
and (now - cached_at) < _NOUS_AUTH_STATUS_CACHE_TTL
|
||||
):
|
||||
return dict(cached_status)
|
||||
|
||||
status = _compute_nous_auth_status()
|
||||
_nous_auth_status_cache = (now, mtime, dict(status))
|
||||
_nous_auth_status_cache = (now, auth_file_key, mtime, dict(status))
|
||||
return status
|
||||
|
||||
|
||||
|
||||
@ -510,6 +510,7 @@ _QUICK_STATE_FILES = (
|
||||
"cron/jobs.json",
|
||||
"gateway_state.json",
|
||||
"channel_directory.json",
|
||||
"channel_aliases.json",
|
||||
"processes.json",
|
||||
# Pairing stores (generic + per-platform JSONs outside state.db)
|
||||
"pairing", # legacy location (gateway/pairing.py)
|
||||
|
||||
@ -225,7 +225,8 @@ class CLICommandsMixin:
|
||||
print(" Usage: /snapshot [list|create [label]|restore <id>|prune [N]]")
|
||||
|
||||
def _handle_stop_command(self):
|
||||
"""Handle /stop — kill all running background processes.
|
||||
"""Handle /stop — kill all running background processes and
|
||||
background (async) delegations.
|
||||
|
||||
Inspired by OpenAI Codex's separation of interrupt (stop current turn)
|
||||
from /stop (clean up background processes). See openai/codex#14602.
|
||||
@ -235,13 +236,26 @@ class CLICommandsMixin:
|
||||
processes = process_registry.list_sessions()
|
||||
running = [p for p in processes if p.get("status") == "running"]
|
||||
|
||||
if not running:
|
||||
# Background subagents dispatched via delegate_task(background=true)
|
||||
# live in their own registry, not the process registry.
|
||||
try:
|
||||
from tools.async_delegation import active_count, interrupt_all
|
||||
n_async = active_count()
|
||||
except Exception:
|
||||
n_async = 0
|
||||
interrupt_all = None
|
||||
|
||||
if not running and not n_async:
|
||||
print(" No running background processes.")
|
||||
return
|
||||
|
||||
print(f" Stopping {len(running)} background process(es)...")
|
||||
killed = process_registry.kill_all()
|
||||
print(f" ✅ Stopped {killed} process(es).")
|
||||
if running:
|
||||
print(f" Stopping {len(running)} background process(es)...")
|
||||
killed = process_registry.kill_all()
|
||||
print(f" ✅ Stopped {killed} process(es).")
|
||||
if n_async and interrupt_all is not None:
|
||||
stopped = interrupt_all(reason="/stop")
|
||||
print(f" ✅ Interrupted {stopped} background delegation(s).")
|
||||
|
||||
def _handle_agents_command(self):
|
||||
"""Handle /agents — show background processes and agent status."""
|
||||
@ -261,6 +275,22 @@ class CLICommandsMixin:
|
||||
if finished:
|
||||
_cprint(f" Recently finished: {len(finished)}")
|
||||
|
||||
# Background (async) delegations — delegate_task(background=true)
|
||||
try:
|
||||
from tools.async_delegation import list_async_delegations
|
||||
delegations = list_async_delegations()
|
||||
except Exception:
|
||||
delegations = []
|
||||
running_d = [d for d in delegations if d.get("status") == "running"]
|
||||
if delegations:
|
||||
_cprint(f" Background delegations: {len(running_d)} running")
|
||||
for d in delegations:
|
||||
goal = (d.get("goal") or "")[:60]
|
||||
_cprint(
|
||||
f" {d.get('delegation_id', '?')} · "
|
||||
f"{d.get('status', '?')} · {goal}"
|
||||
)
|
||||
|
||||
agent_running = getattr(self, "_agent_running", False)
|
||||
_cprint(f" Agent: {'running' if agent_running else 'idle'}")
|
||||
|
||||
|
||||
@ -111,7 +111,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
args_hint="<prompt>"),
|
||||
CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session",
|
||||
args_hint="[text | remove N | clear]"),
|
||||
CommandDef("status", "Show session info", "Session"),
|
||||
CommandDef("status", "Show session, model, token, and context info", "Session"),
|
||||
CommandDef("whoami", "Show your slash command access (admin / user)", "Info"),
|
||||
CommandDef("profile", "Show active profile name and home directory", "Info"),
|
||||
CommandDef("sethome", "Set this chat as the home channel", "Session",
|
||||
|
||||
@ -1791,6 +1791,7 @@ DEFAULT_CONFIG = {
|
||||
"reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium",
|
||||
# "low", "minimal", "none" (empty = inherit parent's level)
|
||||
"max_concurrent_children": 3, # max parallel children per batch; floor of 1 enforced, no ceiling
|
||||
"max_async_children": 3, # max concurrent background (background=true) subagents; new dispatches rejected at capacity
|
||||
# Orchestrator role controls (see tools/delegate_tool.py:_get_max_spawn_depth
|
||||
# and _get_orchestrator_enabled). Floored at 1, no upper ceiling —
|
||||
# raise deliberately, each level multiplies API cost.
|
||||
@ -2027,7 +2028,7 @@ DEFAULT_CONFIG = {
|
||||
"channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group)
|
||||
"allowed_chats": "", # If set, bot ONLY responds in these group/supergroup chat IDs (whitelist)
|
||||
"extra": {
|
||||
"rich_messages": True, # Bot API 10.1 rich messages; set false to force legacy MarkdownV2
|
||||
"rich_messages": False, # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
|
||||
},
|
||||
},
|
||||
|
||||
@ -2364,7 +2365,7 @@ DEFAULT_CONFIG = {
|
||||
# delivered as a fresh message if the preview has been visible at
|
||||
# least this many seconds, so the platform timestamp reflects
|
||||
# completion time. Telegram only; other platforms ignore it.
|
||||
"fresh_final_after_seconds": 60.0,
|
||||
"fresh_final_after_seconds": 0.0,
|
||||
},
|
||||
|
||||
# Session storage — controls automatic cleanup of ~/.hermes/state.db.
|
||||
@ -4874,15 +4875,15 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
raw_mcp_servers = config.get("mcp_servers")
|
||||
if isinstance(raw_mcp_servers, dict):
|
||||
try:
|
||||
from hermes_cli.mcp_security import validate_mcp_server_entry
|
||||
from hermes_cli.mcp_security import validate_mcp_server_entry as _validate_mcp_server_entry
|
||||
except Exception:
|
||||
validate_mcp_server_entry = None
|
||||
if validate_mcp_server_entry:
|
||||
_validate_mcp_server_entry = None
|
||||
if _validate_mcp_server_entry:
|
||||
mcp_touched = False
|
||||
for server_name, entry in raw_mcp_servers.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
issues = validate_mcp_server_entry(server_name, entry)
|
||||
issues = _validate_mcp_server_entry(server_name, entry)
|
||||
if not issues:
|
||||
continue
|
||||
entry["enabled"] = False
|
||||
|
||||
@ -28,11 +28,14 @@ from typing import Literal, Sequence
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Only this prior state triggers automatic restart. Everything else
|
||||
# Only this desired state triggers automatic restart. Everything else
|
||||
# (startup_failed, starting, stopped, missing) registers the slot in
|
||||
# the down state and waits for explicit user action — this avoids the
|
||||
# crash-loop where a broken gateway keeps being restarted across
|
||||
# `docker restart` cycles.
|
||||
# `docker restart` cycles. Older installs only have gateway_state;
|
||||
# newer lifecycle commands persist desired_state separately so a transient
|
||||
# runtime state (draining/startup_failed) does not erase the operator's
|
||||
# durable start/stop intent across pod/container recreation.
|
||||
_AUTOSTART_STATES = frozenset({"running"})
|
||||
|
||||
# Stale runtime files we sweep before recreating service slots. These
|
||||
@ -104,7 +107,7 @@ def reconcile_profile_gateways(
|
||||
container_argv=container_argv,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
default_prior_state = legacy_default_state or _read_prior_state(hermes_home)
|
||||
default_prior_state = legacy_default_state or _read_desired_state(hermes_home)
|
||||
default_should_start = default_prior_state in _AUTOSTART_STATES
|
||||
if not dry_run:
|
||||
_cleanup_stale_runtime_files(hermes_home)
|
||||
@ -139,7 +142,7 @@ def reconcile_profile_gateways(
|
||||
)
|
||||
continue
|
||||
|
||||
prior_state = _read_prior_state(entry)
|
||||
prior_state = _read_desired_state(entry)
|
||||
should_start = prior_state in _AUTOSTART_STATES
|
||||
|
||||
if not dry_run:
|
||||
@ -188,6 +191,7 @@ def _maybe_migrate_legacy_gateway_run_state(
|
||||
import time
|
||||
state_file.write_text(json.dumps({
|
||||
"gateway_state": "running",
|
||||
"desired_state": "running",
|
||||
"timestamp": int(time.time()),
|
||||
"migrated_from": "legacy-container-cmd",
|
||||
}) + "\n")
|
||||
@ -203,8 +207,15 @@ def _read_container_argv() -> tuple[str, ...]:
|
||||
return tuple(part.decode("utf-8", "replace") for part in raw.split(b"\0") if part)
|
||||
|
||||
|
||||
def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool:
|
||||
"""Return True for Docker commands equivalent to `gateway run`."""
|
||||
def _strip_container_argv_prefix(argv: Sequence[str]) -> list[str]:
|
||||
"""Strip the s6/wrapper prefix off PID 1 argv, leaving the hermes args.
|
||||
|
||||
The container PID 1 argv looks like
|
||||
``/init /opt/hermes/docker/main-wrapper.sh <subcommand> [args...]`` and
|
||||
the wrapper re-execs ``hermes <subcommand>``. Peel ``init`` →
|
||||
``main-wrapper.sh`` → ``hermes`` so callers can match on the bare
|
||||
subcommand. Shared by the legacy-gateway and dashboard role detectors.
|
||||
"""
|
||||
args = list(argv)
|
||||
if args and Path(args[0]).name == "init":
|
||||
args = args[1:]
|
||||
@ -212,20 +223,58 @@ def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool:
|
||||
args = args[1:]
|
||||
if args and Path(args[0]).name == "hermes":
|
||||
args = args[1:]
|
||||
return args
|
||||
|
||||
|
||||
def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool:
|
||||
"""Return True for Docker commands equivalent to `gateway run`."""
|
||||
args = _strip_container_argv_prefix(argv)
|
||||
if "--no-supervise" in args:
|
||||
return False
|
||||
return len(args) >= 2 and args[0] == "gateway" and args[1] == "run"
|
||||
|
||||
|
||||
def _read_prior_state(profile_dir: Path) -> str | None:
|
||||
"""Read gateway_state.json's ``gateway_state`` field, or None if
|
||||
missing or unparseable. Unparseable counts as "no prior state" so
|
||||
we don't bork the whole reconciliation on a corrupt file."""
|
||||
def _is_dashboard_container(argv: Sequence[str]) -> bool:
|
||||
"""Return True when the container's command is the dashboard.
|
||||
|
||||
A dashboard-only container (``hermes dashboard ...``) never spawns or
|
||||
supervises per-profile gateways — that is the gateway container's job.
|
||||
Reconciling profile gateway s6 slots there is not just wasted work: when
|
||||
the gateway and dashboard containers share a bind-mounted HERMES_HOME,
|
||||
both race to ``flock()`` the same ``logs/gateways/<profile>/lock`` files,
|
||||
producing "Resource busy" failures and an s6-log restart storm. So the
|
||||
dashboard container skips reconciliation entirely.
|
||||
|
||||
Detected from PID 1 argv (``/proc/1/cmdline``) rather than an operator
|
||||
flag: the role is a fact about the container's command, not a tunable,
|
||||
and a flag can be forgotten in a hand-written compose/k8s manifest —
|
||||
reintroducing the exact storm this prevents. Mirrors the argv handling
|
||||
in :func:`_is_legacy_gateway_run_request`.
|
||||
"""
|
||||
args = _strip_container_argv_prefix(argv)
|
||||
return bool(args) and args[0] == "dashboard"
|
||||
|
||||
|
||||
def _read_desired_state(profile_dir: Path) -> str | None:
|
||||
"""Read the persisted gateway desired state for reconciliation.
|
||||
|
||||
Newer state files carry ``desired_state``: operator intent written by
|
||||
s6 lifecycle commands. Older files only carry ``gateway_state``; keep
|
||||
that as a compatibility fallback so existing running/stopped profiles
|
||||
preserve their behavior until the next explicit start/stop.
|
||||
|
||||
Missing or unparseable files count as "no desired state" so we don't
|
||||
bork the whole reconciliation on a corrupt file.
|
||||
"""
|
||||
state_file = profile_dir / "gateway_state.json"
|
||||
if not state_file.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(state_file.read_text()).get("gateway_state")
|
||||
data = json.loads(state_file.read_text())
|
||||
desired_state = data.get("desired_state")
|
||||
if desired_state is not None:
|
||||
return desired_state
|
||||
return data.get("gateway_state")
|
||||
except (OSError, json.JSONDecodeError):
|
||||
log.warning(
|
||||
"could not read %s; treating as no prior state", state_file,
|
||||
@ -378,6 +427,22 @@ _LOG_ROTATE_BYTES = 256 * 1024
|
||||
|
||||
def main() -> int:
|
||||
"""Entry point invoked from /etc/cont-init.d/02-reconcile-profiles."""
|
||||
# A dashboard-only container never spawns or supervises per-profile
|
||||
# gateways, so reconciling their s6 slots here is pure waste — and
|
||||
# actively harmful: when the gateway and dashboard containers share a
|
||||
# bind-mounted HERMES_HOME, both race to flock() the same s6-log lock
|
||||
# files under logs/gateways/<profile>/lock, producing "Resource busy"
|
||||
# failures and a restart storm. Detect the role from PID 1 argv and
|
||||
# skip reconciliation in the dashboard container. No operator flag:
|
||||
# the role is a fact about the container's command, and a flag can be
|
||||
# forgotten in a hand-written manifest, reintroducing the storm.
|
||||
if _is_dashboard_container(_read_container_argv()):
|
||||
print(
|
||||
"reconcile: skipping (dashboard container — does not need "
|
||||
"per-profile gateways)"
|
||||
)
|
||||
return 0
|
||||
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data"))
|
||||
scandir = Path(os.environ.get("S6_PROFILE_GATEWAY_SCANDIR", "/run/service"))
|
||||
actions = reconcile_profile_gateways(
|
||||
|
||||
@ -796,6 +796,7 @@ def run_doctor(args):
|
||||
"huggingface",
|
||||
"lmstudio",
|
||||
"nous",
|
||||
"nvidia",
|
||||
}
|
||||
provider_accepts_vendor_slug = (
|
||||
provider_policy_id in providers_accepting_vendor_slugs
|
||||
|
||||
@ -252,9 +252,24 @@ def run_dump(args):
|
||||
except Exception:
|
||||
profile = "(default)"
|
||||
|
||||
# Terminal backend
|
||||
# Terminal backend — report the EFFECTIVE backend, not just config.yaml.
|
||||
# ``terminal.backend`` in config.yaml is bridged to the TERMINAL_ENV env var,
|
||||
# but a TERMINAL_ENV set directly in .env / the shell overrides config and is
|
||||
# what terminal_tool actually uses (tools/terminal_tool.py reads TERMINAL_ENV).
|
||||
# Reporting only the config value hides that override and sends users chasing
|
||||
# the wrong cause when the agent runs in a docker/podman sandbox even though
|
||||
# config says "local" (and vice-versa). run_dump() has already loaded .env,
|
||||
# so os.environ reflects the real override here.
|
||||
terminal_cfg = config.get("terminal", {})
|
||||
backend = terminal_cfg.get("backend", "local")
|
||||
config_backend = terminal_cfg.get("backend", "local")
|
||||
env_backend = (os.environ.get("TERMINAL_ENV") or "").strip().lower()
|
||||
if env_backend and env_backend != str(config_backend).strip().lower():
|
||||
backend = (
|
||||
f"{env_backend} (TERMINAL_ENV overrides config.yaml "
|
||||
f"terminal.backend={config_backend})"
|
||||
)
|
||||
else:
|
||||
backend = config_backend
|
||||
|
||||
# OpenAI SDK version
|
||||
try:
|
||||
|
||||
@ -1095,11 +1095,30 @@ def get_gateway_runtime_snapshot(system: bool = False) -> GatewayRuntimeSnapshot
|
||||
# Other container runtimes (or containers built before Phase 2)
|
||||
# still get the original "docker (foreground)" label.
|
||||
try:
|
||||
from hermes_cli.service_manager import detect_service_manager
|
||||
from hermes_cli.service_manager import detect_service_manager, get_service_manager
|
||||
if detect_service_manager() == "s6":
|
||||
profile = _profile_suffix() or "default"
|
||||
service_name = f"gateway-{profile}"
|
||||
mgr = get_service_manager()
|
||||
service_installed = False
|
||||
service_running = False
|
||||
try:
|
||||
service_dir = getattr(mgr, "scandir", None)
|
||||
if service_dir is not None:
|
||||
service_installed = (service_dir / service_name).is_dir()
|
||||
except Exception:
|
||||
service_installed = False
|
||||
if service_installed:
|
||||
try:
|
||||
service_running = bool(mgr.is_running(service_name))
|
||||
except Exception:
|
||||
service_running = False
|
||||
return GatewayRuntimeSnapshot(
|
||||
manager="s6 (container supervisor)",
|
||||
service_installed=service_installed,
|
||||
service_running=service_running,
|
||||
gateway_pids=gateway_pids,
|
||||
service_scope="s6",
|
||||
)
|
||||
except Exception:
|
||||
pass # Fall through to the legacy label on any detection error.
|
||||
@ -3776,6 +3795,101 @@ def _is_official_docker_checkout() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _running_under_gateway_supervisor() -> bool:
|
||||
"""Return True when this process IS the gateway a service manager launched.
|
||||
|
||||
The conflict guard below must never fire on the service's own startup, or
|
||||
it would wedge the unit into a respawn/refuse loop. Each supervisor exports
|
||||
a reliable marker into the child's environment:
|
||||
|
||||
- systemd sets ``INVOCATION_ID`` for every unit it launches (the same
|
||||
marker ``gateway/run.py`` already uses to pick the restart path).
|
||||
- launchd sets ``XPC_SERVICE_NAME`` to the job label for jobs it spawns;
|
||||
interactive shells inherit the sentinel ``"0"`` instead.
|
||||
- the s6-overlay container longrun exports ``HERMES_S6_SUPERVISED_CHILD``.
|
||||
"""
|
||||
if os.environ.get("INVOCATION_ID"):
|
||||
return True
|
||||
if os.environ.get("HERMES_S6_SUPERVISED_CHILD"):
|
||||
return True
|
||||
xpc_service = os.environ.get("XPC_SERVICE_NAME", "")
|
||||
if xpc_service and xpc_service != "0":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _guard_supervised_gateway_conflict(force: bool = False) -> None:
|
||||
"""Refuse a foreground gateway when a service manager already supervises one.
|
||||
|
||||
Running ``hermes gateway run [--replace]`` (or the manual-restart fallback)
|
||||
from a shell on a systemd/launchd host spawns a second, long-lived
|
||||
dispatcher that escapes the service cgroup, survives
|
||||
``systemctl restart``, and becomes a silent concurrent writer on the shared
|
||||
kanban DB — the documented root cause of multi-writer SQLite WAL corruption
|
||||
(issue #35240). Pass ``--force`` to start anyway.
|
||||
"""
|
||||
if force or _running_under_gateway_supervisor():
|
||||
return
|
||||
try:
|
||||
snapshot = get_gateway_runtime_snapshot()
|
||||
except Exception:
|
||||
# Best-effort guard: a probe failure must never block a real startup.
|
||||
logger.debug("Supervised-gateway conflict probe failed", exc_info=True)
|
||||
return
|
||||
if not (snapshot.service_installed and snapshot.service_running):
|
||||
return
|
||||
|
||||
print_error(
|
||||
f"A gateway is already running under {snapshot.manager} for this profile."
|
||||
)
|
||||
print(
|
||||
" Starting another one from a shell leaves an orphan dispatcher that\n"
|
||||
" escapes the service, survives restarts, and writes to the same kanban\n"
|
||||
" DB concurrently — which can corrupt it. Restart the supervised gateway\n"
|
||||
" instead:"
|
||||
)
|
||||
print()
|
||||
print(" hermes gateway restart")
|
||||
print()
|
||||
print(
|
||||
" Pass --force to start a foreground gateway anyway (not recommended\n"
|
||||
" while the service is running)."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _guard_existing_gateway_process_conflict(replace: bool = False) -> None:
|
||||
"""Refuse duplicate foreground startup before importing gateway.run.
|
||||
|
||||
``gateway.run`` performs the authoritative PID/lock check, but importing it
|
||||
is expensive: it pulls in model_tools/plugin discovery first. On small
|
||||
instances, a supervisor or dashboard loop repeatedly running bare
|
||||
``hermes gateway run`` can burn memory/CPU just to fail with "already
|
||||
running" after plugin discovery. This cheap PID-file preflight preserves the
|
||||
same user-facing contract while avoiding that startup work without scanning
|
||||
unrelated gateway processes from other HERMES_HOME roots.
|
||||
"""
|
||||
if replace or _running_under_gateway_supervisor():
|
||||
return
|
||||
try:
|
||||
from gateway.status import get_running_pid
|
||||
|
||||
pid = get_running_pid()
|
||||
except Exception:
|
||||
logger.debug("Existing-gateway process probe failed", exc_info=True)
|
||||
return
|
||||
if pid is None:
|
||||
return
|
||||
|
||||
print_error(
|
||||
f"Another gateway instance is already running (PID {pid})."
|
||||
)
|
||||
print(" Use 'hermes gateway restart' to replace it,")
|
||||
print(" or 'hermes gateway stop' first.")
|
||||
print(" Or use 'hermes gateway run --replace' to auto-replace.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _guard_official_docker_root_gateway() -> None:
|
||||
"""Refuse gateway startup when the official Docker privilege drop was bypassed."""
|
||||
if not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
@ -3803,7 +3917,7 @@ def _guard_official_docker_root_gateway() -> None:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
||||
def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False, force: bool = False):
|
||||
"""Run the gateway in foreground.
|
||||
|
||||
Args:
|
||||
@ -3812,8 +3926,12 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
||||
replace: If True, kill any existing gateway instance before starting.
|
||||
This prevents systemd restart loops when the old process
|
||||
hasn't fully exited yet.
|
||||
force: Skip the supervised-gateway conflict guard and start even when a
|
||||
systemd/launchd service is already supervising this profile.
|
||||
"""
|
||||
_guard_official_docker_root_gateway()
|
||||
_guard_supervised_gateway_conflict(force=force)
|
||||
_guard_existing_gateway_process_conflict(replace=replace)
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# Detached Windows gateway runs must ignore console-control broadcasts
|
||||
@ -6359,7 +6477,8 @@ def _gateway_command_inner(args):
|
||||
verbose = getattr(args, "verbose", 0)
|
||||
quiet = getattr(args, "quiet", False)
|
||||
replace = getattr(args, "replace", False)
|
||||
run_gateway(verbose, quiet=quiet, replace=replace)
|
||||
force = getattr(args, "force", False)
|
||||
run_gateway(verbose, quiet=quiet, replace=replace, force=force)
|
||||
return
|
||||
|
||||
if subcmd == "setup":
|
||||
|
||||
@ -160,6 +160,37 @@ def build_models_payload(
|
||||
moa_row = _moa_provider_row(ctx)
|
||||
if moa_row is not None:
|
||||
rows = [moa_row] + [r for r in rows if str(r.get("slug", "")).lower() != "moa"]
|
||||
|
||||
# --- Deduplicate: remove models from aggregators that overlap with
|
||||
# user-defined providers. When a local proxy (e.g. litellm-proxy)
|
||||
# serves a model whose name also appears in an aggregator's curated
|
||||
# catalog, the picker would show the model under both providers.
|
||||
# Selecting it from the aggregator row sets model.provider to the
|
||||
# aggregator (e.g. openrouter) instead of the user's proxy — silently
|
||||
# breaking the call. Filtering at the payload level keeps the
|
||||
# aggregator rows honest: they only show models the user can't get
|
||||
# from a more-specific provider. (#45954)
|
||||
try:
|
||||
from hermes_cli.providers import is_aggregator as _is_aggregator
|
||||
except Exception:
|
||||
_is_aggregator = None # type: ignore[assignment]
|
||||
|
||||
if _is_aggregator is not None:
|
||||
user_models: set[str] = set()
|
||||
for row in rows:
|
||||
if row.get("is_user_defined"):
|
||||
user_models.update(m.lower() for m in (row.get("models") or []))
|
||||
if user_models:
|
||||
for row in rows:
|
||||
slug = row.get("slug", "")
|
||||
if not _is_aggregator(slug):
|
||||
continue
|
||||
original = row.get("models") or []
|
||||
filtered = [m for m in original if m.lower() not in user_models]
|
||||
if len(filtered) < len(original):
|
||||
row["models"] = filtered
|
||||
row["total_models"] = len(filtered)
|
||||
|
||||
if include_unconfigured:
|
||||
rows = list(rows) + [r for r in _append_unconfigured_rows(rows, ctx) if str(r.get("slug", "")).lower() != "moa"]
|
||||
if picker_hints:
|
||||
|
||||
@ -452,8 +452,19 @@ def _apply_profile_override() -> None:
|
||||
if Path(hermes_home_env).parent.name == "profiles":
|
||||
return
|
||||
|
||||
# 2. If no flag, check active_profile in the hermes root
|
||||
if profile_name is None:
|
||||
# 2. If no flag, check active_profile in the hermes root.
|
||||
#
|
||||
# EXCEPTION: a supervised s6 gateway child (exported by the container
|
||||
# run-script as HERMES_S6_SUPERVISED_CHILD=1) must NOT follow the sticky
|
||||
# active_profile. Each supervised slot has a fixed profile identity: named
|
||||
# slots pass ``-p <name>`` explicitly (handled in step 1 above), and the
|
||||
# reserved ``gateway-default`` slot runs bare ``hermes gateway run`` to mean
|
||||
# "the root HERMES_HOME profile". If the reserved default child read
|
||||
# active_profile here, switching the active profile (e.g. via the dashboard)
|
||||
# would silently redirect the default gateway into that profile — yielding a
|
||||
# duplicate gateway for the active profile and no real default gateway. See
|
||||
# the "Docker & Profiles & Dashboard" report.
|
||||
if profile_name is None and not os.environ.get("HERMES_S6_SUPERVISED_CHILD"):
|
||||
try:
|
||||
from hermes_constants import get_default_hermes_root
|
||||
|
||||
@ -8259,10 +8270,16 @@ def _discard_lockfile_churn(git_cmd, repo_root):
|
||||
)
|
||||
if diff.returncode != 0:
|
||||
return
|
||||
dirty_package_dirs = {
|
||||
Path(line.strip()).parent
|
||||
for line in diff.stdout.splitlines()
|
||||
if line.strip().endswith("package.json")
|
||||
}
|
||||
dirty = [
|
||||
line.strip()
|
||||
for line in diff.stdout.splitlines()
|
||||
if line.strip().endswith("package-lock.json")
|
||||
and Path(line.strip()).parent not in dirty_package_dirs
|
||||
]
|
||||
if not dirty:
|
||||
return
|
||||
@ -10753,8 +10770,24 @@ def cmd_dashboard(args):
|
||||
if getattr(args, "skip_build", False):
|
||||
reexec_argv.append("--skip-build")
|
||||
env = os.environ.copy()
|
||||
# Drop the profile HERMES_HOME so the child binds the machine root.
|
||||
env.pop("HERMES_HOME", None)
|
||||
# Pin the child to the machine ROOT, not the launching profile's
|
||||
# HERMES_HOME. We must resolve the root explicitly instead of just
|
||||
# dropping HERMES_HOME: in the Docker layout the machine root is
|
||||
# /opt/data (set via `ENV HERMES_HOME=/opt/data`), so an unset
|
||||
# HERMES_HOME falls back to $HOME/.hermes = /opt/data/.hermes — an
|
||||
# empty, auto-seeded home where the dashboard sees only the default
|
||||
# profile and the install-method stamp is missing (so the Docker
|
||||
# update-button guard also misfires). get_default_hermes_root()
|
||||
# returns the root for both layouts: ~/.hermes for a standard install
|
||||
# and /opt/data for Docker (it strips a trailing profiles/<name>).
|
||||
# See the support report for the double-mount workaround this avoids.
|
||||
try:
|
||||
from hermes_constants import get_default_hermes_root
|
||||
env["HERMES_HOME"] = str(get_default_hermes_root())
|
||||
except Exception:
|
||||
# Best-effort: if root resolution fails, fall back to the prior
|
||||
# behaviour (drop HERMES_HOME) rather than block the reroute.
|
||||
env.pop("HERMES_HOME", None)
|
||||
# On Windows, os.execvpe() does not truly replace the process — it
|
||||
# spawns via CreateProcess then the parent exits. Under Python 3.14+
|
||||
# this can crash with STATUS_ACCESS_VIOLATION (0xC0000005) when
|
||||
|
||||
@ -221,6 +221,10 @@ def _probe_single_server(
|
||||
Returns list of ``(tool_name, description)`` tuples.
|
||||
Raises on connection failure.
|
||||
"""
|
||||
issues = validate_mcp_server_entry(name, config)
|
||||
if issues:
|
||||
raise ValueError("; ".join(issues))
|
||||
|
||||
from tools.mcp_tool import (
|
||||
_ensure_mcp_loop,
|
||||
_run_on_mcp_loop,
|
||||
@ -352,6 +356,12 @@ def cmd_mcp_add(args):
|
||||
if explicit_env:
|
||||
server_config["env"] = explicit_env
|
||||
|
||||
issues = validate_mcp_server_entry(name, server_config)
|
||||
if issues:
|
||||
for issue in issues:
|
||||
_warning(issue)
|
||||
_warning(f"Server '{name}' was NOT saved due to suspicious configuration.")
|
||||
return
|
||||
|
||||
# ── Authentication ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@ -1853,18 +1853,7 @@ def _model_flow_kimi(config, current_model=""):
|
||||
print()
|
||||
|
||||
# Step 3: Model selection — show appropriate models for the endpoint
|
||||
if is_coding_plan:
|
||||
# Coding Plan models (kimi-k2.6 first)
|
||||
model_list = [
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.5",
|
||||
"kimi-for-coding",
|
||||
"kimi-k2-thinking",
|
||||
"kimi-k2-thinking-turbo",
|
||||
]
|
||||
else:
|
||||
# Legacy Moonshot models (excludes Coding Plan-only models)
|
||||
model_list = _PROVIDER_MODELS.get("moonshot", [])
|
||||
model_list = _PROVIDER_MODELS.get("kimi-coding" if is_coding_plan else "moonshot", [])
|
||||
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(
|
||||
|
||||
@ -258,6 +258,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"gemini-3.5-flash",
|
||||
],
|
||||
"zai": [
|
||||
"glm-5.2",
|
||||
"glm-5.1",
|
||||
"glm-5",
|
||||
"glm-5v-turbo",
|
||||
@ -282,6 +283,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"openai/gpt-oss-120b",
|
||||
],
|
||||
"kimi-coding": [
|
||||
"kimi-k2.7-code",
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.5",
|
||||
"kimi-for-coding",
|
||||
@ -2370,6 +2372,15 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
||||
if api_key:
|
||||
live = _p.fetch_models(api_key=api_key)
|
||||
if live:
|
||||
if normalized in {"kimi-coding", "kimi-coding-cn"}:
|
||||
curated = list(_PROVIDER_MODELS.get(normalized, []))
|
||||
merged = list(curated)
|
||||
merged_lower = {m.lower() for m in curated}
|
||||
for m in live:
|
||||
if m.lower() not in merged_lower:
|
||||
merged.append(m)
|
||||
merged_lower.add(m.lower())
|
||||
return merged
|
||||
return live
|
||||
# Use profile's fallback_models if defined
|
||||
if _p.fallback_models:
|
||||
|
||||
@ -455,6 +455,37 @@ def remove_wrapper_script(name: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _migrate_profile_config_if_outdated(profile_dir: Path) -> None:
|
||||
"""Bring a copied profile config.yaml up to the current schema.
|
||||
|
||||
Profile creation can clone a config file that predates schema tracking (no
|
||||
``_config_version``) or that is simply older than the running Hermes. If we
|
||||
leave it untouched, the first desktop/doctor view of the new profile shows a
|
||||
scary ``v0 → latest`` warning even though we just created the profile. Scope
|
||||
the normal migration pipeline to the new profile and keep it non-interactive.
|
||||
"""
|
||||
config_path = profile_dir / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
from hermes_cli.config import check_config_version, migrate_config
|
||||
|
||||
token = set_hermes_home_override(str(profile_dir))
|
||||
try:
|
||||
current_ver, latest_ver = check_config_version()
|
||||
if current_ver < latest_ver:
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
except Exception:
|
||||
# Profile creation should not fail because an old copied config could
|
||||
# not be migrated. The next `hermes doctor --fix` can still surface the
|
||||
# detailed error in the target profile.
|
||||
pass
|
||||
|
||||
|
||||
def find_alias_for_profile(profile_name: str) -> Optional[str]:
|
||||
"""Return the alias name of the wrapper that activates *profile_name*, or None.
|
||||
|
||||
@ -910,6 +941,14 @@ def create_profile(
|
||||
except OSError:
|
||||
pass # best-effort — the feature still works via the empty skills/ dir
|
||||
|
||||
# Cloned configs can be older than the running Hermes (or predate schema
|
||||
# tracking entirely). Migrate config-only clones immediately so
|
||||
# desktop/status surfaces don't warn that a just-created profile is
|
||||
# v0/outdated. Leave --clone-all snapshots byte-for-byte apart from the
|
||||
# explicit runtime/history stripping above.
|
||||
if not clone_all:
|
||||
_migrate_profile_config_if_outdated(profile_dir)
|
||||
|
||||
# Persist description if the caller provided one. Done last so a
|
||||
# partial-create failure doesn't strand a description file in an
|
||||
# incomplete profile.
|
||||
@ -1229,7 +1268,7 @@ def _maybe_register_gateway_service(profile_name: str) -> None:
|
||||
if not mgr.supports_runtime_registration():
|
||||
return # host backend; no-op
|
||||
try:
|
||||
mgr.register_profile_gateway(profile_name)
|
||||
mgr.register_profile_gateway(profile_name, start_now=False)
|
||||
except ValueError:
|
||||
# Already registered (e.g. the container-boot reconciler ran
|
||||
# first and brought up a stale slot). That's fine.
|
||||
|
||||
@ -77,6 +77,7 @@ class ServiceManager(Protocol):
|
||||
profile: str,
|
||||
*,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
start_now: bool = True,
|
||||
) -> None: ...
|
||||
def unregister_profile_gateway(self, profile: str) -> None: ...
|
||||
def list_profile_gateways(self) -> list[str]: ...
|
||||
@ -86,7 +87,8 @@ def detect_service_manager() -> ServiceManagerKind:
|
||||
"""Detect which service manager is available in this environment.
|
||||
|
||||
Returns:
|
||||
"s6" — inside a container when /init is s6-svscan (Phase 2+)
|
||||
"s6" — s6-svscan is PID 1 (s6-overlay image; Docker, Podman, or a
|
||||
Fly Firecracker microVM)
|
||||
"windows" — native Windows host
|
||||
"launchd" — macOS host
|
||||
"systemd" — Linux host with a working user/system bus
|
||||
@ -100,14 +102,20 @@ def detect_service_manager() -> ServiceManagerKind:
|
||||
# Imports deferred so importing this module doesn't drag in the
|
||||
# whole gateway dependency graph for callers that only need the
|
||||
# Protocol type or validate_profile_name().
|
||||
from hermes_constants import is_container
|
||||
from hermes_cli.gateway import (
|
||||
is_macos,
|
||||
is_windows,
|
||||
supports_systemd_services,
|
||||
)
|
||||
|
||||
if is_container() and _s6_running():
|
||||
# Gate on _s6_running() alone (PID 1 comm == s6-svscan AND /run/s6/basedir),
|
||||
# NOT is_container(): the latter only detects Docker/Podman/lxc, so it is
|
||||
# False on Fly's Firecracker microVMs even though s6-overlay is PID 1 there.
|
||||
# That false negative made the whole s6 dispatch path inert on Fly, so
|
||||
# `hermes gateway start/stop/restart` fell through to host code that spawns
|
||||
# a foreground gateway competing with the supervised one. _s6_running() is
|
||||
# already an s6-overlay-specific signal, so the container gate was redundant.
|
||||
if _s6_running():
|
||||
return "s6"
|
||||
if is_windows():
|
||||
return "windows"
|
||||
@ -175,6 +183,7 @@ class _RegistrationUnsupportedMixin:
|
||||
profile: str,
|
||||
*,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
start_now: bool = True,
|
||||
) -> None:
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not support runtime profile "
|
||||
@ -325,6 +334,62 @@ def get_service_manager() -> ServiceManager:
|
||||
S6_DYNAMIC_SCANDIR = Path("/run/service")
|
||||
S6_SERVICE_PREFIX = "gateway-"
|
||||
|
||||
|
||||
def _profile_dir_for_gateway_service(name: str) -> Path:
|
||||
"""Resolve ``gateway-<profile>`` to its persistent profile directory.
|
||||
|
||||
s6 lifecycle commands may be invoked from any active profile, including
|
||||
``gateway stop --all``. Do not write the caller's HERMES_HOME blindly;
|
||||
derive the shared profile root from the current HERMES_HOME and map the
|
||||
service suffix to either the root default profile or
|
||||
``<root>/profiles/<profile>``.
|
||||
"""
|
||||
import os
|
||||
|
||||
profile = name[len(S6_SERVICE_PREFIX):] if name.startswith(S6_SERVICE_PREFIX) else name
|
||||
validate_profile_name(profile)
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data"))
|
||||
if hermes_home.parent.name == "profiles":
|
||||
root = hermes_home.parent.parent
|
||||
else:
|
||||
root = hermes_home
|
||||
return root if profile == "default" else root / "profiles" / profile
|
||||
|
||||
|
||||
def _write_gateway_desired_state(name: str, desired_state: str) -> None:
|
||||
"""Persist durable s6 gateway intent next to runtime status.
|
||||
|
||||
``gateway_state`` remains the volatile runtime field written by the
|
||||
gateway process. ``desired_state`` records the operator's start/stop
|
||||
intent so container-boot reconciliation can restore the correct s6
|
||||
want-up/want-down state after pod recreation even if the previous runtime
|
||||
state was transient (draining, startup_failed, etc.). The write is
|
||||
best-effort: a failed persistence attempt must not prevent immediate s6
|
||||
lifecycle control.
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
|
||||
profile_dir = _profile_dir_for_gateway_service(name)
|
||||
state_file = profile_dir / "gateway_state.json"
|
||||
try:
|
||||
if not profile_dir.exists():
|
||||
return
|
||||
try:
|
||||
data = json.loads(state_file.read_text()) if state_file.exists() else {}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
data = {}
|
||||
data["desired_state"] = desired_state
|
||||
data["updated_at"] = int(time.time())
|
||||
tmp = state_file.with_suffix(state_file.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(data, separators=(",", ":")) + "\n")
|
||||
tmp.replace(state_file)
|
||||
except OSError:
|
||||
return
|
||||
|
||||
|
||||
# s6-overlay installs its binaries under /command/ and only adds that
|
||||
# directory to PATH for processes started under the supervision tree
|
||||
# (services started by s6-svscan, cont-init.d scripts, etc.). Code
|
||||
@ -680,7 +745,17 @@ class S6ServiceManager:
|
||||
f': "${{HERMES_HOME:=/opt/data}}"\n'
|
||||
f'log_dir="$HERMES_HOME/logs/gateways/{prof}"\n'
|
||||
f'mkdir -p "$log_dir"\n'
|
||||
# The gateways/ parent must be chowned too (non-recursively):
|
||||
# `mkdir -p` creates it root-owned on a root-context boot, and a
|
||||
# leaf-only chown leaves it that way — every profile registered
|
||||
# later then runs its log service as hermes and crash-loops on
|
||||
# `mkdir: Permission denied`. The parent chown runs on every
|
||||
# root-context boot, so it also heals volumes already poisoned
|
||||
# by older images. Non-recursive on purpose: sibling profile
|
||||
# dirs are each managed by their own log/run. See #45258.
|
||||
f'chown hermes:hermes "$HERMES_HOME/logs/gateways" 2>/dev/null || true\n'
|
||||
f'chown -R hermes:hermes "$log_dir" 2>/dev/null || true\n'
|
||||
f'rm -f "$log_dir/lock"\n'
|
||||
# Skip the drop when already non-root (CAP_SETGID).
|
||||
f'[ "$(id -u)" = 0 ] || exec s6-log 1 n10 s1000000 T "$log_dir"\n'
|
||||
f'exec s6-setuidgid hermes s6-log 1 n10 s1000000 T "$log_dir"\n'
|
||||
@ -743,6 +818,7 @@ class S6ServiceManager:
|
||||
(permission denied on the supervise FIFO, timeout, etc.).
|
||||
"""
|
||||
self._run_svc("-u", "start", name)
|
||||
_write_gateway_desired_state(name, "running")
|
||||
|
||||
def _supervised_pid(self, name: str) -> int | None:
|
||||
"""Return the PID of the supervised gateway process, or None.
|
||||
@ -794,6 +870,7 @@ class S6ServiceManager:
|
||||
except Exception:
|
||||
pass
|
||||
self._run_svc("-d", "stop", name)
|
||||
_write_gateway_desired_state(name, "stopped")
|
||||
|
||||
def restart(self, name: str) -> None:
|
||||
"""Restart a registered service (``s6-svc -t`` = SIGTERM).
|
||||
@ -803,6 +880,7 @@ class S6ServiceManager:
|
||||
S6CommandError: s6-svc exited non-zero for any other reason.
|
||||
"""
|
||||
self._run_svc("-t", "restart", name)
|
||||
_write_gateway_desired_state(name, "running")
|
||||
|
||||
def is_running(self, name: str) -> bool:
|
||||
"""True iff ``s6-svstat`` reports the service as up."""
|
||||
@ -823,15 +901,15 @@ class S6ServiceManager:
|
||||
profile: str,
|
||||
*,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
start_now: bool = True,
|
||||
) -> None:
|
||||
"""Create the s6 service directory for a profile gateway.
|
||||
|
||||
Triggers ``s6-svscanctl -a`` so s6-svscan picks the new directory
|
||||
up immediately. The service is created in the *up* state — to
|
||||
register without auto-starting, follow up with ``stop(profile)``
|
||||
(or pass the start flag via the future ``start_now=False`` arg,
|
||||
which the Phase 4 reconciliation path uses via a ``down``
|
||||
marker file written directly).
|
||||
up immediately. When *start_now* is ``True`` (the default) the
|
||||
service starts immediately; when ``False`` a ``down`` marker file
|
||||
is written so s6-supervise leaves the service stopped until the
|
||||
user explicitly runs ``hermes -p <profile> gateway start``.
|
||||
|
||||
Raises:
|
||||
ValueError: if the profile name is invalid or the service
|
||||
@ -879,6 +957,13 @@ class S6ServiceManager:
|
||||
# rationale.
|
||||
_seed_supervise_skeleton(tmp_dir)
|
||||
|
||||
# When start_now is False, write a `down` marker so
|
||||
# s6-supervise does not auto-start the service on rescan.
|
||||
# Mirrors the same pattern in container_boot.py
|
||||
# _register_gateway_slot when start=False.
|
||||
if not start_now:
|
||||
(tmp_dir / "down").touch()
|
||||
|
||||
tmp_dir.rename(svc_dir)
|
||||
except Exception:
|
||||
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||
|
||||
97
hermes_cli/session_listing.py
Normal file
97
hermes_cli/session_listing.py
Normal file
@ -0,0 +1,97 @@
|
||||
"""Shared session-listing helpers for CLI and gateway slash surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def parse_session_listing_args(raw_args: str) -> tuple[bool, bool, str]:
|
||||
"""Parse `/sessions`-style args into listing flags plus a resume target.
|
||||
|
||||
Returns ``(include_all_sources, include_unnamed, target)``. ``list``/``ls``
|
||||
and ``browse`` are display aliases; ``all``/``--all`` widens source scope;
|
||||
``full``/``--full`` keeps unnamed sessions in the listing. Anything else is
|
||||
treated as a target so `/sessions <id-or-title>` can delegate to `/resume`.
|
||||
"""
|
||||
import shlex
|
||||
|
||||
parts = shlex.split(raw_args or "")
|
||||
include_all = False
|
||||
include_unnamed = False
|
||||
target_parts: list[str] = []
|
||||
for part in parts:
|
||||
lower = part.strip().lower()
|
||||
if lower in {"list", "ls", "browse"}:
|
||||
continue
|
||||
if lower in {"all", "--all"}:
|
||||
include_all = True
|
||||
continue
|
||||
if lower in {"full", "--full"}:
|
||||
include_unnamed = True
|
||||
continue
|
||||
target_parts.append(part)
|
||||
return include_all, include_unnamed, " ".join(target_parts).strip()
|
||||
|
||||
|
||||
def query_session_listing(
|
||||
session_db: Any,
|
||||
*,
|
||||
source: str | None,
|
||||
current_session_id: str | None = None,
|
||||
include_all_sources: bool = False,
|
||||
include_unnamed: bool = False,
|
||||
limit: int = 10,
|
||||
exclude_sources: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return session rows for interactive listing surfaces.
|
||||
|
||||
This is the shared selection policy behind CLI/gateway session browsing:
|
||||
source-scoped by default, optionally global, hide unnamed sessions unless
|
||||
the caller asks for a full listing, and never include the current session.
|
||||
"""
|
||||
query_source = None if include_all_sources else source
|
||||
fetch_limit = max(limit * 4, limit)
|
||||
rows = session_db.list_sessions_rich(
|
||||
source=query_source,
|
||||
exclude_sources=exclude_sources,
|
||||
limit=fetch_limit,
|
||||
)
|
||||
result: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if current_session_id and row.get("id") == current_session_id:
|
||||
continue
|
||||
if not include_unnamed and not row.get("title"):
|
||||
continue
|
||||
result.append(row)
|
||||
if len(result) >= limit:
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
def format_gateway_session_listing(
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
include_source: bool = False,
|
||||
title: str = "Sessions",
|
||||
) -> str:
|
||||
"""Render a compact Markdown-ish session list for gateway messengers."""
|
||||
if not rows:
|
||||
return (
|
||||
"No sessions found.\n"
|
||||
"Use `/title My Session` to name this chat, or `/sessions full` "
|
||||
"to include unnamed sessions."
|
||||
)
|
||||
|
||||
lines = [f"📋 **{title}**", ""]
|
||||
for idx, row in enumerate(rows, start=1):
|
||||
session_id = str(row.get("id") or "")
|
||||
title_text = str(row.get("title") or "—")
|
||||
preview = str(row.get("preview") or "")[:40]
|
||||
source = str(row.get("source") or "")
|
||||
source_part = f" `{source}`" if include_source and source else ""
|
||||
preview_part = f" — _{preview}_" if preview else ""
|
||||
lines.append(f"{idx}. **{title_text}**{source_part} — `{session_id}`{preview_part}")
|
||||
lines.append("")
|
||||
lines.append("Resume: `/resume <session id>` or `/resume <number>` from `/resume`.")
|
||||
lines.append("More: `/sessions all`, `/sessions full`, `/sessions all full`.")
|
||||
return "\n".join(lines)
|
||||
@ -93,7 +93,7 @@ _DEFAULT_PROVIDER_MODELS = {
|
||||
"gemini-3.1-pro-preview", "gemini-3-pro-preview",
|
||||
"gemini-3-flash-preview", "gemini-3.1-flash-lite-preview",
|
||||
],
|
||||
"zai": ["glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
|
||||
"zai": ["glm-5.2", "glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
|
||||
"kimi-coding": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
|
||||
"kimi-coding-cn": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
|
||||
"stepfun": ["step-3.5-flash", "step-3.5-flash-2603"],
|
||||
|
||||
@ -25,7 +25,13 @@ PLATFORMS = {k: info.label for k, info in _PLATFORMS.items() if k != "api_server
|
||||
# ─── Config Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def get_disabled_skills(config: dict, platform: Optional[str] = None) -> Set[str]:
|
||||
"""Return disabled skill names. Platform-specific list falls back to global."""
|
||||
"""Return disabled skill names: the global list unioned with the
|
||||
platform-specific list when a platform is given.
|
||||
|
||||
A globally-disabled skill stays disabled on every platform, so the
|
||||
platform list adds to the global list rather than replacing it. This
|
||||
mirrors ``agent.skill_utils.get_disabled_skill_names``.
|
||||
"""
|
||||
skills_cfg = config.get("skills", {})
|
||||
global_disabled = set(skills_cfg.get("disabled", []))
|
||||
if platform is None:
|
||||
@ -33,7 +39,7 @@ def get_disabled_skills(config: dict, platform: Optional[str] = None) -> Set[str
|
||||
platform_disabled = cfg_get(skills_cfg, "platform_disabled", platform)
|
||||
if platform_disabled is None:
|
||||
return global_disabled
|
||||
return set(platform_disabled)
|
||||
return global_disabled | set(platform_disabled)
|
||||
|
||||
|
||||
def save_disabled_skills(config: dict, disabled: Set[str], platform: Optional[str] = None):
|
||||
|
||||
@ -60,6 +60,16 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
|
||||
action="store_true",
|
||||
help="Replace any existing gateway instance (useful for systemd)",
|
||||
)
|
||||
gateway_run.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Start a foreground gateway even when a systemd/launchd/s6 service "
|
||||
"already supervises this profile. Without --force, the command "
|
||||
"refuses because a second dispatcher escapes the service and can "
|
||||
"corrupt shared gateway state."
|
||||
),
|
||||
)
|
||||
gateway_run.add_argument(
|
||||
"--no-supervise",
|
||||
action="store_true",
|
||||
|
||||
@ -2180,13 +2180,8 @@ def _toolset_needs_configuration_prompt(
|
||||
tts_cfg = config.get("tts", {})
|
||||
return not isinstance(tts_cfg, dict) or "provider" not in tts_cfg
|
||||
if ts_key == "web":
|
||||
# Web works out of the box via Parallel's free Search MCP (no key), so
|
||||
# don't force setup just because ``web.backend`` is unset — only prompt
|
||||
# when web isn't actually usable (e.g. an explicit backend configured
|
||||
# without its credentials). Lazy import: web_tools is heavy and most
|
||||
# tools_config callers don't need it.
|
||||
from tools.web_tools import check_web_api_key
|
||||
return not check_web_api_key()
|
||||
web_cfg = config.get("web", {})
|
||||
return not isinstance(web_cfg, dict) or "backend" not in web_cfg
|
||||
if ts_key == "browser":
|
||||
browser_cfg = config.get("browser", {})
|
||||
return not isinstance(browser_cfg, dict) or "cloud_provider" not in browser_cfg
|
||||
|
||||
@ -247,6 +247,19 @@ def _has_valid_session_token(request: Request) -> bool:
|
||||
return hmac.compare_digest(auth.encode(), expected.encode())
|
||||
|
||||
|
||||
# Routes that may also authenticate via a ``?token=`` query param, for download
|
||||
# links opened by the OS shell or a new browser tab where the session header
|
||||
# can't be set. Kept narrow — same query-token tradeoff as the /api/pty WS.
|
||||
_QUERY_TOKEN_API_PATHS: frozenset[str] = frozenset({"/api/files/download"})
|
||||
|
||||
|
||||
def _has_valid_query_token(request: Request, path: str) -> bool:
|
||||
if path not in _QUERY_TOKEN_API_PATHS:
|
||||
return False
|
||||
token = request.query_params.get("token", "")
|
||||
return bool(token) and hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode())
|
||||
|
||||
|
||||
def _require_token(request: Request) -> None:
|
||||
"""Authorize a sensitive endpoint, raising 401 if the caller isn't allowed.
|
||||
|
||||
@ -403,7 +416,7 @@ async def auth_middleware(request: Request, call_next):
|
||||
return await call_next(request)
|
||||
path = request.url.path
|
||||
if path.startswith("/api/") and path not in _PUBLIC_API_PATHS:
|
||||
if not _has_valid_session_token(request):
|
||||
if not _has_valid_session_token(request) and not _has_valid_query_token(request, path):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": "Unauthorized"},
|
||||
@ -1253,6 +1266,22 @@ def _default_hermes_root_is_opt_data() -> bool:
|
||||
return root == _HOSTED_MANAGED_FILES_ROOT
|
||||
|
||||
|
||||
def _dashboard_local_update_managed_externally() -> bool:
|
||||
"""Return true when the dashboard should not offer ``hermes update``.
|
||||
|
||||
Containerized dashboards are updated by the outer launcher/image, not by an
|
||||
in-browser local update action. Keep this dashboard capability separate
|
||||
from install-method detection: manual git/pip installs inside containers can
|
||||
still behave like their actual install method in the CLI.
|
||||
"""
|
||||
try:
|
||||
from hermes_constants import is_container
|
||||
|
||||
return is_container()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _managed_files_policy(request: Request, *, create_root: bool = True) -> ManagedFilesPolicy:
|
||||
raw_forced_root = os.environ.get(_MANAGED_FILES_ROOT_ENV, "").strip()
|
||||
if raw_forced_root:
|
||||
@ -1422,6 +1451,40 @@ async def read_managed_file(request: Request, path: str):
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/files/download")
|
||||
async def download_managed_file(request: Request, path: str):
|
||||
"""Stream a managed file as an attachment download.
|
||||
|
||||
Remote clients (desktop app, browser dashboard) open agent-written files
|
||||
that live on *this* gateway's disk, not theirs. Auth-gated like every other
|
||||
managed-files route — ``auth_middleware`` additionally accepts the session
|
||||
token as a ``?token=`` query param here so a shell/browser-opened download
|
||||
(which can't set the session header) still authenticates. See ``/api/pty``
|
||||
for the same query-token precedent.
|
||||
"""
|
||||
policy, target, _display_path = _resolve_managed_path(path, request)
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
if not target.is_file():
|
||||
raise HTTPException(status_code=400, detail="Path is not a file")
|
||||
|
||||
try:
|
||||
size = target.stat().st_size
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}")
|
||||
if size > _MANAGED_FILE_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail="File is too large")
|
||||
|
||||
mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
|
||||
|
||||
return FileResponse(
|
||||
path=str(target),
|
||||
media_type=mime_type,
|
||||
filename=target.name,
|
||||
content_disposition_type="attachment",
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/files/upload")
|
||||
async def upload_managed_file(payload: ManagedFileUpload, request: Request):
|
||||
policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True)
|
||||
@ -1683,6 +1746,7 @@ async def get_status():
|
||||
"release_date": __release_date__,
|
||||
"config_version": current_ver,
|
||||
"latest_config_version": latest_ver,
|
||||
"can_update_hermes": not _dashboard_local_update_managed_externally(),
|
||||
"gateway_running": gateway_running,
|
||||
"gateway_state": gateway_state,
|
||||
"gateway_platforms": gateway_platforms,
|
||||
@ -2194,6 +2258,22 @@ async def restart_gateway():
|
||||
@app.post("/api/hermes/update")
|
||||
async def update_hermes():
|
||||
"""Kick off ``hermes update`` in the background."""
|
||||
if _dashboard_local_update_managed_externally():
|
||||
message = (
|
||||
"Hermes updates are managed outside this dashboard in "
|
||||
"containerized environments. The built-in local updater is "
|
||||
"disabled here."
|
||||
)
|
||||
_record_completed_action("hermes-update", message, exit_code=1)
|
||||
return {
|
||||
"ok": False,
|
||||
"pid": None,
|
||||
"name": "hermes-update",
|
||||
"error": "dashboard_update_managed_externally",
|
||||
"message": message,
|
||||
"update_command": "managed outside dashboard",
|
||||
}
|
||||
|
||||
install_method = detect_install_method(PROJECT_ROOT)
|
||||
if install_method == "docker":
|
||||
message = format_docker_update_message()
|
||||
@ -2293,6 +2373,20 @@ async def check_hermes_update(force: bool = False):
|
||||
desktop's remote update overlay renders this as "what's
|
||||
changed". Additive: existing consumers ignore it.
|
||||
"""
|
||||
if _dashboard_local_update_managed_externally():
|
||||
return {
|
||||
"install_method": "managed-runtime",
|
||||
"current_version": __version__,
|
||||
"behind": None,
|
||||
"update_available": False,
|
||||
"can_apply": False,
|
||||
"update_command": "managed outside dashboard",
|
||||
"message": (
|
||||
"Hermes updates are managed outside this dashboard in "
|
||||
"containerized environments."
|
||||
),
|
||||
}
|
||||
|
||||
install_method = detect_install_method(PROJECT_ROOT)
|
||||
update_command = recommended_update_command_for_method(install_method)
|
||||
|
||||
@ -3945,9 +4039,9 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = {
|
||||
),
|
||||
},
|
||||
"weixin": {
|
||||
"name": "WeChat (Official Account)",
|
||||
"description": "Connect a WeChat Official Account.",
|
||||
"docs_url": "https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Overview.html",
|
||||
"name": "Weixin / WeChat (Personal)",
|
||||
"description": "Connect a personal WeChat account through Tencent's iLink Bot API.",
|
||||
"docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/weixin/",
|
||||
"env_vars": ("WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL"),
|
||||
"required_env": ("WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN"),
|
||||
},
|
||||
@ -4118,17 +4212,17 @@ _MESSAGING_ENV_FALLBACKS: dict[str, dict[str, Any]] = {
|
||||
"password": True,
|
||||
},
|
||||
"WEIXIN_ACCOUNT_ID": {
|
||||
"description": "WeChat Official Account ID",
|
||||
"prompt": "Account ID",
|
||||
"description": "iLink Bot account ID obtained through QR login in hermes gateway setup",
|
||||
"prompt": "iLink Bot account ID",
|
||||
},
|
||||
"WEIXIN_TOKEN": {
|
||||
"description": "WeChat callback token",
|
||||
"prompt": "Token",
|
||||
"description": "iLink Bot token obtained through QR login in hermes gateway setup",
|
||||
"prompt": "iLink Bot token",
|
||||
"password": True,
|
||||
},
|
||||
"WEIXIN_BASE_URL": {
|
||||
"description": "WeChat platform base URL",
|
||||
"prompt": "Base URL",
|
||||
"description": "iLink API base URL saved by QR login (default: https://ilinkai.weixin.qq.com)",
|
||||
"prompt": "iLink API base URL",
|
||||
},
|
||||
"FEISHU_APP_ID": {"description": "Feishu / Lark app ID", "prompt": "App ID"},
|
||||
"FEISHU_APP_SECRET": {
|
||||
@ -5233,7 +5327,7 @@ def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str,
|
||||
|
||||
|
||||
@app.get("/api/providers/oauth")
|
||||
async def list_oauth_providers():
|
||||
async def list_oauth_providers(profile: Optional[str] = None):
|
||||
"""Enumerate every OAuth-capable LLM provider with current status.
|
||||
|
||||
Response shape (per provider):
|
||||
@ -5250,83 +5344,89 @@ async def list_oauth_providers():
|
||||
expires_at ISO timestamp string or null
|
||||
has_refresh_token bool
|
||||
"""
|
||||
providers = []
|
||||
for p in _OAUTH_PROVIDER_CATALOG:
|
||||
status = _resolve_provider_status(p["id"], p.get("status_fn"))
|
||||
disconnect_hint = _oauth_provider_disconnect_hint(p, status)
|
||||
providers.append({
|
||||
"id": p["id"],
|
||||
"name": p["name"],
|
||||
"flow": p["flow"],
|
||||
"cli_command": p["cli_command"],
|
||||
"docs_url": p["docs_url"],
|
||||
"disconnect_hint": disconnect_hint,
|
||||
"disconnectable": disconnect_hint is None,
|
||||
"status": status,
|
||||
})
|
||||
return {"providers": providers}
|
||||
with _profile_scope(profile):
|
||||
providers = []
|
||||
for p in _OAUTH_PROVIDER_CATALOG:
|
||||
status = _resolve_provider_status(p["id"], p.get("status_fn"))
|
||||
disconnect_hint = _oauth_provider_disconnect_hint(p, status)
|
||||
providers.append({
|
||||
"id": p["id"],
|
||||
"name": p["name"],
|
||||
"flow": p["flow"],
|
||||
"cli_command": p["cli_command"],
|
||||
"docs_url": p["docs_url"],
|
||||
"disconnect_hint": disconnect_hint,
|
||||
"disconnectable": disconnect_hint is None,
|
||||
"status": status,
|
||||
})
|
||||
return {"providers": providers}
|
||||
|
||||
|
||||
@app.delete("/api/providers/oauth/{provider_id}")
|
||||
async def disconnect_oauth_provider(provider_id: str, request: Request):
|
||||
async def disconnect_oauth_provider(
|
||||
provider_id: str,
|
||||
request: Request,
|
||||
profile: Optional[str] = None,
|
||||
):
|
||||
"""Disconnect an OAuth provider. Token-protected (matches /env/reveal)."""
|
||||
_require_token(request)
|
||||
|
||||
catalog_by_id = {p["id"]: p for p in _OAUTH_PROVIDER_CATALOG}
|
||||
provider = catalog_by_id.get(provider_id)
|
||||
if provider is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown provider: {provider_id}. "
|
||||
f"Available: {', '.join(sorted(catalog_by_id))}",
|
||||
)
|
||||
with _profile_scope(profile):
|
||||
catalog_by_id = {p["id"]: p for p in _OAUTH_PROVIDER_CATALOG}
|
||||
provider = catalog_by_id.get(provider_id)
|
||||
if provider is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown provider: {provider_id}. "
|
||||
f"Available: {', '.join(sorted(catalog_by_id))}",
|
||||
)
|
||||
|
||||
disconnect_hint = _oauth_provider_disconnect_hint(provider, {})
|
||||
if disconnect_hint:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}",
|
||||
)
|
||||
disconnect_hint = _oauth_provider_disconnect_hint(provider, {})
|
||||
if disconnect_hint:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}",
|
||||
)
|
||||
|
||||
status = _resolve_provider_status(provider_id, provider.get("status_fn"))
|
||||
disconnect_hint = _oauth_provider_disconnect_hint(provider, status)
|
||||
if disconnect_hint:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}",
|
||||
)
|
||||
status = _resolve_provider_status(provider_id, provider.get("status_fn"))
|
||||
disconnect_hint = _oauth_provider_disconnect_hint(provider, status)
|
||||
if disconnect_hint:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}",
|
||||
)
|
||||
|
||||
# Anthropic clears only the Hermes-managed PKCE file and auth-store entry.
|
||||
# The separate claude-code catalog row is external/read-only and rejected
|
||||
# above so we never pretend to remove ~/.claude/* credentials owned by the CLI.
|
||||
if provider_id == "anthropic":
|
||||
cleared = False
|
||||
try:
|
||||
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
|
||||
if _HERMES_OAUTH_FILE.exists():
|
||||
_HERMES_OAUTH_FILE.unlink()
|
||||
cleared = True
|
||||
except Exception:
|
||||
pass
|
||||
# Also clear the credential pool entry if present.
|
||||
try:
|
||||
from hermes_cli.auth import clear_provider_auth
|
||||
cleared = clear_provider_auth("anthropic") or cleared
|
||||
except Exception:
|
||||
pass
|
||||
_log.info("oauth/disconnect: %s", provider_id)
|
||||
return {"ok": bool(cleared), "provider": provider_id}
|
||||
|
||||
# Anthropic clears only the Hermes-managed PKCE file and auth-store entry.
|
||||
# The separate claude-code catalog row is external/read-only and rejected
|
||||
# above so we never pretend to remove ~/.claude/* credentials owned by the CLI.
|
||||
if provider_id == "anthropic":
|
||||
cleared = False
|
||||
try:
|
||||
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
|
||||
if _HERMES_OAUTH_FILE.exists():
|
||||
_HERMES_OAUTH_FILE.unlink()
|
||||
cleared = True
|
||||
except Exception:
|
||||
pass
|
||||
# Also clear the credential pool entry if present.
|
||||
try:
|
||||
from hermes_cli.auth import clear_provider_auth
|
||||
cleared = clear_provider_auth("anthropic") or cleared
|
||||
except Exception:
|
||||
pass
|
||||
_log.info("oauth/disconnect: %s", provider_id)
|
||||
return {"ok": bool(cleared), "provider": provider_id}
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import clear_provider_auth, invalidate_nous_auth_status_cache
|
||||
cleared = clear_provider_auth(provider_id)
|
||||
if provider_id == "nous":
|
||||
invalidate_nous_auth_status_cache()
|
||||
_log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared)
|
||||
return {"ok": bool(cleared), "provider": provider_id}
|
||||
except Exception as e:
|
||||
_log.exception("disconnect %s failed", provider_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
from hermes_cli.auth import clear_provider_auth, invalidate_nous_auth_status_cache
|
||||
cleared = clear_provider_auth(provider_id)
|
||||
if provider_id == "nous":
|
||||
invalidate_nous_auth_status_cache()
|
||||
_log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared)
|
||||
return {"ok": bool(cleared), "provider": provider_id}
|
||||
except Exception as e:
|
||||
_log.exception("disconnect %s failed", provider_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -5408,13 +5508,32 @@ def _gc_oauth_sessions() -> None:
|
||||
_oauth_sessions.pop(sid, None)
|
||||
|
||||
|
||||
def _new_oauth_session(provider_id: str, flow: str) -> tuple[str, Dict[str, Any]]:
|
||||
def _oauth_profile_name(profile: Optional[str]) -> Optional[str]:
|
||||
requested = (profile or "").strip()
|
||||
if not requested or requested.lower() == "current":
|
||||
return None
|
||||
return requested
|
||||
|
||||
|
||||
def _validate_oauth_profile(profile: Optional[str]) -> None:
|
||||
profile_name = _oauth_profile_name(profile)
|
||||
if profile_name:
|
||||
_resolve_profile_dir(profile_name)
|
||||
|
||||
|
||||
def _new_oauth_session(
|
||||
provider_id: str,
|
||||
flow: str,
|
||||
profile: Optional[str] = None,
|
||||
) -> tuple[str, Dict[str, Any]]:
|
||||
"""Create + register a new OAuth session, return (session_id, session_dict)."""
|
||||
sid = secrets.token_urlsafe(16)
|
||||
profile_name = _oauth_profile_name(profile)
|
||||
sess = {
|
||||
"session_id": sid,
|
||||
"provider": provider_id,
|
||||
"flow": flow,
|
||||
"profile": profile_name,
|
||||
"created_at": time.time(),
|
||||
"status": "pending", # pending | approved | denied | expired | error
|
||||
"error_message": None,
|
||||
@ -5424,6 +5543,17 @@ def _new_oauth_session(provider_id: str, flow: str) -> tuple[str, Dict[str, Any]
|
||||
return sid, sess
|
||||
|
||||
|
||||
def _oauth_session_profile(
|
||||
session_id: str,
|
||||
fallback: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Return the profile that owns an OAuth session, if one was provided."""
|
||||
with _oauth_sessions_lock:
|
||||
sess = _oauth_sessions.get(session_id)
|
||||
profile = sess.get("profile") if sess else None
|
||||
return profile or _oauth_profile_name(fallback)
|
||||
|
||||
|
||||
def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_at_ms: int) -> None:
|
||||
"""Persist Anthropic PKCE creds to both Hermes file AND credential pool.
|
||||
|
||||
@ -5491,12 +5621,12 @@ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_a
|
||||
_log.warning("anthropic pool add (dashboard) failed: %s", e)
|
||||
|
||||
|
||||
def _start_anthropic_pkce() -> Dict[str, Any]:
|
||||
def _start_anthropic_pkce(profile: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Begin PKCE flow. Returns the auth URL the UI should open."""
|
||||
if not _ANTHROPIC_OAUTH_AVAILABLE:
|
||||
raise HTTPException(status_code=501, detail="Anthropic OAuth not available (missing adapter)")
|
||||
verifier, challenge = _generate_pkce_pair()
|
||||
sid, sess = _new_oauth_session("anthropic", "pkce")
|
||||
sid, sess = _new_oauth_session("anthropic", "pkce", profile=profile)
|
||||
sess["verifier"] = verifier
|
||||
sess["state"] = verifier # Anthropic round-trips verifier as state
|
||||
params = {
|
||||
@ -5518,7 +5648,11 @@ def _start_anthropic_pkce() -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]:
|
||||
def _submit_anthropic_pkce(
|
||||
session_id: str,
|
||||
code_input: str,
|
||||
profile: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Exchange authorization code for tokens. Persists on success."""
|
||||
with _oauth_sessions_lock:
|
||||
sess = _oauth_sessions.get(session_id)
|
||||
@ -5572,7 +5706,8 @@ def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]:
|
||||
|
||||
expires_at_ms = int(time.time() * 1000) + (expires_in * 1000)
|
||||
try:
|
||||
_save_anthropic_oauth_creds(access_token, refresh_token, expires_at_ms)
|
||||
with _profile_scope(_oauth_session_profile(session_id, profile)):
|
||||
_save_anthropic_oauth_creds(access_token, refresh_token, expires_at_ms)
|
||||
except Exception as e:
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "error"
|
||||
@ -5584,7 +5719,10 @@ def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]:
|
||||
return {"ok": True, "status": "approved"}
|
||||
|
||||
|
||||
async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
|
||||
async def _start_device_code_flow(
|
||||
provider_id: str,
|
||||
profile: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Initiate a device-code flow (Nous, OpenAI Codex, or MiniMax).
|
||||
|
||||
Calls the provider's device-auth endpoint via the existing CLI helpers,
|
||||
@ -5624,7 +5762,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
|
||||
device_data, effective_scope = await asyncio.get_running_loop().run_in_executor(
|
||||
None, _do_nous_device_request
|
||||
)
|
||||
sid, sess = _new_oauth_session("nous", "device_code")
|
||||
sid, sess = _new_oauth_session("nous", "device_code", profile=profile)
|
||||
sess["device_code"] = str(device_data["device_code"])
|
||||
sess["interval"] = int(device_data["interval"])
|
||||
sess["expires_at"] = time.time() + int(device_data["expires_in"])
|
||||
@ -5645,7 +5783,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
|
||||
|
||||
if provider_id == "openai-codex":
|
||||
# Codex uses fixed OpenAI device-auth endpoints; reuse the helper.
|
||||
sid, _ = _new_oauth_session("openai-codex", "device_code")
|
||||
sid, _ = _new_oauth_session("openai-codex", "device_code", profile=profile)
|
||||
# Use the helper but in a thread because it polls inline.
|
||||
# We can't extract just the start step without refactoring auth.py,
|
||||
# so we run the full helper in a worker and proxy the user_code +
|
||||
@ -5712,7 +5850,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
|
||||
device_data = await asyncio.get_event_loop().run_in_executor(
|
||||
None, _do_minimax_request
|
||||
)
|
||||
sid, sess = _new_oauth_session("minimax-oauth", "device_code")
|
||||
sid, sess = _new_oauth_session("minimax-oauth", "device_code", profile=profile)
|
||||
# The CLI flow names this `interval_ms` because MiniMax's
|
||||
# `interval` field is in milliseconds (defensive default 2000ms
|
||||
# in _minimax_poll_token).
|
||||
@ -5766,7 +5904,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
|
||||
_XAI_LOOPBACK_TIMEOUT_SECONDS = 300.0
|
||||
|
||||
|
||||
def _start_xai_loopback_flow() -> Dict[str, Any]:
|
||||
def _start_xai_loopback_flow(profile: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Begin the xAI loopback PKCE flow.
|
||||
|
||||
Binds the local callback server, builds the authorize URL, and spawns a
|
||||
@ -5805,7 +5943,7 @@ def _start_xai_loopback_flow() -> Dict[str, Any]:
|
||||
pass
|
||||
raise
|
||||
|
||||
sid, sess = _new_oauth_session("xai-oauth", "loopback")
|
||||
sid, sess = _new_oauth_session("xai-oauth", "loopback", profile=profile)
|
||||
sess["server"] = server
|
||||
sess["thread"] = thread
|
||||
sess["callback_result"] = callback_result
|
||||
@ -5908,13 +6046,14 @@ def _xai_loopback_worker(session_id: str) -> None:
|
||||
}
|
||||
if _cancelled():
|
||||
return
|
||||
hauth._save_xai_oauth_tokens(
|
||||
tokens,
|
||||
discovery=sess.get("discovery"),
|
||||
redirect_uri=sess["redirect_uri"],
|
||||
last_refresh=last_refresh,
|
||||
)
|
||||
_add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh)
|
||||
with _profile_scope(_oauth_session_profile(session_id)):
|
||||
hauth._save_xai_oauth_tokens(
|
||||
tokens,
|
||||
discovery=sess.get("discovery"),
|
||||
redirect_uri=sess["redirect_uri"],
|
||||
last_refresh=last_refresh,
|
||||
)
|
||||
_add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh)
|
||||
except Exception as exc:
|
||||
_fail(f"xAI token exchange failed: {exc}")
|
||||
return
|
||||
@ -6017,13 +6156,14 @@ def _nous_poller(session_id: str) -> None:
|
||||
),
|
||||
"expires_in": token_ttl,
|
||||
}
|
||||
full_state = refresh_nous_oauth_from_state(
|
||||
auth_state,
|
||||
timeout_seconds=15.0,
|
||||
force_refresh=False,
|
||||
)
|
||||
from hermes_cli.auth import persist_nous_credentials
|
||||
persist_nous_credentials(full_state)
|
||||
with _profile_scope(_oauth_session_profile(session_id)):
|
||||
full_state = refresh_nous_oauth_from_state(
|
||||
auth_state,
|
||||
timeout_seconds=15.0,
|
||||
force_refresh=False,
|
||||
)
|
||||
from hermes_cli.auth import persist_nous_credentials
|
||||
persist_nous_credentials(full_state)
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "approved"
|
||||
_log.info("oauth/device: nous login completed (session=%s)", session_id)
|
||||
@ -6106,7 +6246,8 @@ def _minimax_poller(session_id: str) -> None:
|
||||
).isoformat(),
|
||||
"expires_in": expires_in_s,
|
||||
}
|
||||
_minimax_save_auth_state(auth_state)
|
||||
with _profile_scope(_oauth_session_profile(session_id)):
|
||||
_minimax_save_auth_state(auth_state)
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "approved"
|
||||
_log.info("oauth/device: minimax login completed (session=%s)", session_id)
|
||||
@ -6219,10 +6360,11 @@ def _codex_full_login_worker(session_id: str) -> None:
|
||||
|
||||
from hermes_cli.auth import _save_codex_tokens
|
||||
|
||||
_save_codex_tokens({
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
})
|
||||
with _profile_scope(_oauth_session_profile(session_id)):
|
||||
_save_codex_tokens({
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
})
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "approved"
|
||||
_log.info("oauth/device: openai-codex login completed (session=%s)", session_id)
|
||||
@ -6236,10 +6378,15 @@ def _codex_full_login_worker(session_id: str) -> None:
|
||||
|
||||
|
||||
@app.post("/api/providers/oauth/{provider_id}/start")
|
||||
async def start_oauth_login(provider_id: str, request: Request):
|
||||
async def start_oauth_login(
|
||||
provider_id: str,
|
||||
request: Request,
|
||||
profile: Optional[str] = None,
|
||||
):
|
||||
"""Initiate an OAuth login flow. Token-protected."""
|
||||
_require_token(request)
|
||||
_gc_oauth_sessions()
|
||||
_validate_oauth_profile(profile)
|
||||
valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
|
||||
if provider_id not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown provider {provider_id}")
|
||||
@ -6257,12 +6404,12 @@ async def start_oauth_login(provider_id: str, request: Request):
|
||||
# change for MiniMax). New PKCE providers must add their own
|
||||
# start function and an explicit branch here.
|
||||
if catalog_entry["flow"] == "pkce" and provider_id == "anthropic":
|
||||
return _start_anthropic_pkce()
|
||||
return _start_anthropic_pkce(profile=profile)
|
||||
if catalog_entry["flow"] == "device_code":
|
||||
return await _start_device_code_flow(provider_id)
|
||||
return await _start_device_code_flow(provider_id, profile=profile)
|
||||
if catalog_entry["flow"] == "loopback" and provider_id == "xai-oauth":
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
None, _start_xai_loopback_flow
|
||||
None, _start_xai_loopback_flow, profile,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
@ -6278,18 +6425,27 @@ class OAuthSubmitBody(BaseModel):
|
||||
|
||||
|
||||
@app.post("/api/providers/oauth/{provider_id}/submit")
|
||||
async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Request):
|
||||
async def submit_oauth_code(
|
||||
provider_id: str,
|
||||
body: OAuthSubmitBody,
|
||||
request: Request,
|
||||
profile: Optional[str] = None,
|
||||
):
|
||||
"""Submit the auth code for PKCE flows. Token-protected."""
|
||||
_require_token(request)
|
||||
if provider_id == "anthropic":
|
||||
return await asyncio.get_running_loop().run_in_executor(
|
||||
None, _submit_anthropic_pkce, body.session_id, body.code,
|
||||
None, _submit_anthropic_pkce, body.session_id, body.code, profile,
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=f"submit not supported for {provider_id}")
|
||||
|
||||
|
||||
@app.get("/api/providers/oauth/{provider_id}/poll/{session_id}")
|
||||
async def poll_oauth_session(provider_id: str, session_id: str):
|
||||
async def poll_oauth_session(
|
||||
provider_id: str,
|
||||
session_id: str,
|
||||
profile: Optional[str] = None,
|
||||
):
|
||||
"""Poll a session's status (no auth — read-only state).
|
||||
|
||||
Shared by the device-code flows (Nous, OpenAI Codex, MiniMax) and the
|
||||
@ -6312,7 +6468,11 @@ async def poll_oauth_session(provider_id: str, session_id: str):
|
||||
|
||||
|
||||
@app.delete("/api/providers/oauth/sessions/{session_id}")
|
||||
async def cancel_oauth_session(session_id: str, request: Request):
|
||||
async def cancel_oauth_session(
|
||||
session_id: str,
|
||||
request: Request,
|
||||
profile: Optional[str] = None,
|
||||
):
|
||||
"""Cancel a pending OAuth session. Token-protected."""
|
||||
_require_token(request)
|
||||
with _oauth_sessions_lock:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user