Merge remote-tracking branch 'origin/main' into feat/opentui-native-engine
This commit is contained in:
commit
23cc009879
5
.github/workflows/contributor-check.yml
vendored
5
.github/workflows/contributor-check.yml
vendored
@ -1,12 +1,11 @@
|
|||||||
name: Contributor Attribution Check
|
name: Contributor Attribution Check
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
# No paths filter — the job must always run so the required check
|
# No paths filter — the job must always run so the required check
|
||||||
# reports a status (path-gated workflows leave checks "pending" forever
|
# reports a status (path-gated workflows leave checks "pending" forever
|
||||||
# when no matching files change, which blocks merge).
|
# when no matching files change, which blocks merge).
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
|||||||
9
.github/workflows/docker-lint.yml
vendored
9
.github/workflows/docker-lint.yml
vendored
@ -18,13 +18,12 @@ on:
|
|||||||
- docker/**
|
- docker/**
|
||||||
- .hadolint.yaml
|
- .hadolint.yaml
|
||||||
- .github/workflows/docker-lint.yml
|
- .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:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
|
||||||
- Dockerfile
|
|
||||||
- docker/**
|
|
||||||
- .hadolint.yaml
|
|
||||||
- .github/workflows/docker-lint.yml
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
13
.github/workflows/docker-publish.yml
vendored
13
.github/workflows/docker-publish.yml
vendored
@ -11,16 +11,13 @@ on:
|
|||||||
- 'docker/**'
|
- 'docker/**'
|
||||||
- '.github/workflows/docker-publish.yml'
|
- '.github/workflows/docker-publish.yml'
|
||||||
- '.github/actions/hermes-smoke-test/**'
|
- '.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:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
|
||||||
- '**/*.py'
|
|
||||||
- 'pyproject.toml'
|
|
||||||
- 'uv.lock'
|
|
||||||
- 'Dockerfile'
|
|
||||||
- 'docker/**'
|
|
||||||
- '.github/workflows/docker-publish.yml'
|
|
||||||
- '.github/actions/hermes-smoke-test/**'
|
|
||||||
release:
|
release:
|
||||||
types: [published]
|
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
|
name: Docs Site Checks
|
||||||
|
|
||||||
on:
|
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:
|
pull_request:
|
||||||
paths:
|
branches: [main]
|
||||||
- 'website/**'
|
|
||||||
- '.github/workflows/docs-site-checks.yml'
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@ -14,9 +16,9 @@ jobs:
|
|||||||
docs-site-checks:
|
docs-site-checks:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: npm
|
cache: npm
|
||||||
@ -26,9 +28,9 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
working-directory: website
|
working-directory: website
|
||||||
|
|
||||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: "3.11"
|
||||||
|
|
||||||
- name: Install ascii-guard
|
- name: Install ascii-guard
|
||||||
run: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3
|
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.
|
# the PR head and main to be non-empty.
|
||||||
|
|
||||||
on:
|
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:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
@ -24,9 +27,9 @@ jobs:
|
|||||||
check-common-ancestor:
|
check-common-ancestor:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
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
|
- name: Reject PRs with no common ancestor on main
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
9
.github/workflows/lint.yml
vendored
9
.github/workflows/lint.yml
vendored
@ -15,12 +15,12 @@ on:
|
|||||||
- "**/*.md"
|
- "**/*.md"
|
||||||
- "docs/**"
|
- "docs/**"
|
||||||
- "website/**"
|
- "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:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths-ignore:
|
|
||||||
- "**/*.md"
|
|
||||||
- "docs/**"
|
|
||||||
- "website/**"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@ -154,7 +154,6 @@ jobs:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
ruff-blocking:
|
ruff-blocking:
|
||||||
# Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently
|
# Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently
|
||||||
# PLW1514 (unspecified-encoding) — catches bare ``open()`` /
|
# 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.
|
# vulnerabilities in pinned deps that we may need to patch deliberately.
|
||||||
|
|
||||||
on:
|
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:
|
pull_request:
|
||||||
branches: [main]
|
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:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'uv.lock'
|
- "uv.lock"
|
||||||
- 'pyproject.toml'
|
- "pyproject.toml"
|
||||||
- 'package.json'
|
- "package.json"
|
||||||
- 'package-lock.json'
|
- "package-lock.json"
|
||||||
- 'website/package-lock.json'
|
- "website/package-lock.json"
|
||||||
schedule:
|
schedule:
|
||||||
# Weekly scan against main — catches CVEs published after merge for
|
# Weekly scan against main — catches CVEs published after merge for
|
||||||
# deps that haven't changed since.
|
# deps that haven't changed since.
|
||||||
- cron: '0 9 * * 1'
|
- cron: "0 9 * * 1"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@ -54,7 +48,7 @@ permissions:
|
|||||||
jobs:
|
jobs:
|
||||||
scan:
|
scan:
|
||||||
name: Scan lockfiles
|
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:
|
with:
|
||||||
# Scan explicit lockfiles rather than recursing, so we only look at
|
# Scan explicit lockfiles rather than recursing, so we only look at
|
||||||
# the three sources of truth and skip vendored / test / worktree dirs.
|
# 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
|
name: Supply Chain Audit
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
|
||||||
types: [opened, synchronize, reopened]
|
|
||||||
# No paths filter — the jobs must always run so required checks
|
# No paths filter — the jobs must always run so required checks
|
||||||
# report a status (path-gated workflows leave checks "pending" forever
|
# report a status (path-gated workflows leave checks "pending" forever
|
||||||
# when no matching files change, which blocks merge).
|
# when no matching files change, which blocks merge).
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
@ -32,7 +32,7 @@ jobs:
|
|||||||
# True when the curated MCP catalog / bundled MCP manifests changed.
|
# True when the curated MCP catalog / bundled MCP manifests changed.
|
||||||
mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }}
|
mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Check for relevant file changes
|
- name: Check for relevant file changes
|
||||||
@ -72,7 +72,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@ -207,7 +207,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@ -286,7 +286,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
@ -6,11 +6,11 @@ on:
|
|||||||
paths-ignore:
|
paths-ignore:
|
||||||
- "**/*.md"
|
- "**/*.md"
|
||||||
- "docs/**"
|
- "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:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths-ignore:
|
|
||||||
- "**/*.md"
|
|
||||||
- "docs/**"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
20
.github/workflows/typecheck.yml
vendored
20
.github/workflows/typecheck.yml
vendored
@ -4,6 +4,9 @@ name: Typecheck
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
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:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
@ -23,3 +26,20 @@ jobs:
|
|||||||
cache: npm
|
cache: npm
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run --prefix ${{ matrix.package }} typecheck
|
- 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:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'pyproject.toml'
|
- "pyproject.toml"
|
||||||
- 'uv.lock'
|
- "uv.lock"
|
||||||
- '.github/workflows/uv-lockfile-check.yml'
|
- ".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:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
|
||||||
- 'pyproject.toml'
|
|
||||||
- 'uv.lock'
|
|
||||||
- '.github/workflows/uv-lockfile-check.yml'
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@ -71,10 +71,10 @@ jobs:
|
|||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install uv
|
- 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
|
# `uv lock --check` re-resolves the project from pyproject.toml and
|
||||||
# compares the result to uv.lock, exiting non-zero if they disagree.
|
# 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/)) |
|
| **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) |
|
| **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
|
```bash
|
||||||
git clone https://github.com/NousResearch/hermes-agent.git
|
git clone https://github.com/NousResearch/hermes-agent.git
|
||||||
@ -109,15 +143,19 @@ echo "OPENROUTER_API_KEY=***" >> ~/.hermes/.env
|
|||||||
### Run
|
### Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Symlink for global access
|
# The standard installer already put `hermes` on PATH.
|
||||||
mkdir -p ~/.local/bin
|
|
||||||
ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
|
|
||||||
|
|
||||||
# Verify
|
|
||||||
hermes doctor
|
hermes doctor
|
||||||
hermes chat -q "Hello"
|
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
|
### Run tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
16
README.md
16
README.md
@ -183,16 +183,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.
|
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
|
```bash
|
||||||
git clone https://github.com/NousResearch/hermes-agent.git
|
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||||
cd hermes-agent
|
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
|
||||||
./setup-hermes.sh # installs uv, creates venv, installs .[all], symlinks ~/.local/bin/hermes
|
uv pip install -e ".[all,dev]"
|
||||||
./hermes # auto-detects the venv, no need to `source` first
|
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
|
```bash
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
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 流程。
|
欢迎贡献!请参阅 [贡献指南](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
|
```bash
|
||||||
git clone https://github.com/NousResearch/hermes-agent.git
|
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||||
cd hermes-agent
|
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
|
||||||
./setup-hermes.sh # 安装 uv、创建 venv、安装 .[all]、创建符号链接 ~/.local/bin/hermes
|
uv pip install -e ".[all,dev]"
|
||||||
./hermes # 自动检测 venv,无需先 source
|
scripts/run_tests.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
手动安装(等效于上述命令):
|
手动克隆备用路径(用于一次性 clone / CI,或你明确不想使用 managed install layout 时):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
|||||||
@ -299,6 +299,7 @@ def init_agent(
|
|||||||
# would mangle the escape sequences. None = use builtins.print.
|
# would mangle the escape sequences. None = use builtins.print.
|
||||||
agent._print_fn = None
|
agent._print_fn = None
|
||||||
agent.background_review_callback = None # Optional sync callback for gateway delivery
|
agent.background_review_callback = None # Optional sync callback for gateway delivery
|
||||||
|
agent.memory_notifications = "on" # Memory update notifications: "off", "on", "verbose"
|
||||||
agent.skip_context_files = skip_context_files
|
agent.skip_context_files = skip_context_files
|
||||||
agent.load_soul_identity = load_soul_identity
|
agent.load_soul_identity = load_soul_identity
|
||||||
agent.pass_session_id = pass_session_id
|
agent.pass_session_id = pass_session_id
|
||||||
|
|||||||
@ -1217,12 +1217,23 @@ def dump_api_request_debug(
|
|||||||
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||||
dump_file = agent.logs_dir / f"request_dump_{agent.session_id}_{timestamp}.json"
|
dump_file = 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}")
|
agent._vprint(f"{agent.log_prefix}🧾 Request debug dump written to: {dump_file}")
|
||||||
|
|
||||||
if env_var_enabled("HERMES_DUMP_REQUEST_STDOUT"):
|
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
|
return dump_file
|
||||||
except Exception as dump_error:
|
except Exception as dump_error:
|
||||||
|
|||||||
@ -237,18 +237,25 @@ _COMBINED_REVIEW_PROMPT = (
|
|||||||
def summarize_background_review_actions(
|
def summarize_background_review_actions(
|
||||||
review_messages: List[Dict],
|
review_messages: List[Dict],
|
||||||
prior_snapshot: List[Dict],
|
prior_snapshot: List[Dict],
|
||||||
|
notification_mode: str = "on",
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Build the human-facing action summary for a background review pass.
|
"""Build the human-facing action summary for a background review pass.
|
||||||
|
|
||||||
Walks the review agent's session messages and collects "successful tool
|
Walks the review agent's session messages and collects successful memory
|
||||||
action" descriptions to surface to the user (e.g. "Memory updated").
|
and skill-management actions to surface to the user. Tool messages already
|
||||||
Tool messages already present in ``prior_snapshot`` are skipped so we
|
present in ``prior_snapshot`` are skipped so stale inherited results are
|
||||||
don't re-surface stale results from the prior conversation that the
|
not re-surfaced as fresh background work (issue #14944).
|
||||||
review agent inherited via ``conversation_history`` (issue #14944).
|
|
||||||
|
|
||||||
Matching is by ``tool_call_id`` when available, with a content-equality
|
``notification_mode`` controls display detail:
|
||||||
fallback for tool messages that lack one.
|
- ``off``: return no actions.
|
||||||
|
- ``on``: generic "Memory updated"/tool messages.
|
||||||
|
- ``verbose``: include compact content previews from tool-call arguments.
|
||||||
"""
|
"""
|
||||||
|
mode = str(notification_mode or "on").lower()
|
||||||
|
if mode == "off":
|
||||||
|
return []
|
||||||
|
verbose = mode == "verbose"
|
||||||
|
|
||||||
existing_tool_call_ids = set()
|
existing_tool_call_ids = set()
|
||||||
existing_tool_contents = set()
|
existing_tool_contents = set()
|
||||||
for prior in prior_snapshot or []:
|
for prior in prior_snapshot or []:
|
||||||
@ -262,6 +269,42 @@ def summarize_background_review_actions(
|
|||||||
if isinstance(content, str):
|
if isinstance(content, str):
|
||||||
existing_tool_contents.add(content)
|
existing_tool_contents.add(content)
|
||||||
|
|
||||||
|
# Map review-agent tool results back to the calls that produced them. The
|
||||||
|
# result JSON only says "Entry added"; the call arguments contain action,
|
||||||
|
# target, and content previews. Restricting to notify_tools also prevents
|
||||||
|
# helper tools from surfacing as memory work just because they succeeded.
|
||||||
|
notify_tools = {"memory", "skill_manage"}
|
||||||
|
all_tool_call_ids: set = set()
|
||||||
|
call_details: dict = {}
|
||||||
|
for msg in review_messages or []:
|
||||||
|
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||||
|
continue
|
||||||
|
for tc in msg.get("tool_calls", []) or []:
|
||||||
|
if not isinstance(tc, dict):
|
||||||
|
continue
|
||||||
|
fn = tc.get("function", {}) or {}
|
||||||
|
fn_name = fn.get("name", "")
|
||||||
|
tcid = tc.get("id")
|
||||||
|
if tcid:
|
||||||
|
all_tool_call_ids.add(tcid)
|
||||||
|
if fn_name not in notify_tools:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
args = json.loads(fn.get("arguments", "{}"))
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
args = {}
|
||||||
|
if tcid:
|
||||||
|
call_details[tcid] = {
|
||||||
|
"tool": fn_name,
|
||||||
|
"action": args.get("action", "?"),
|
||||||
|
"target": args.get("target", "memory"),
|
||||||
|
"content": args.get("content", ""),
|
||||||
|
"old_text": args.get("old_text", ""),
|
||||||
|
"name": args.get("name", ""),
|
||||||
|
"old_string": args.get("old_string", ""),
|
||||||
|
"new_string": args.get("new_string", ""),
|
||||||
|
}
|
||||||
|
|
||||||
actions: List[str] = []
|
actions: List[str] = []
|
||||||
for msg in review_messages or []:
|
for msg in review_messages or []:
|
||||||
if not isinstance(msg, dict) or msg.get("role") != "tool":
|
if not isinstance(msg, dict) or msg.get("role") != "tool":
|
||||||
@ -273,6 +316,8 @@ def summarize_background_review_actions(
|
|||||||
content_str = msg.get("content")
|
content_str = msg.get("content")
|
||||||
if isinstance(content_str, str) and content_str in existing_tool_contents:
|
if isinstance(content_str, str) and content_str in existing_tool_contents:
|
||||||
continue
|
continue
|
||||||
|
if tcid and all_tool_call_ids and tcid not in call_details:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
data = json.loads(msg.get("content", "{}"))
|
data = json.loads(msg.get("content", "{}"))
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
@ -280,19 +325,75 @@ def summarize_background_review_actions(
|
|||||||
if not isinstance(data, dict) or not data.get("success"):
|
if not isinstance(data, dict) or not data.get("success"):
|
||||||
continue
|
continue
|
||||||
message = data.get("message", "")
|
message = data.get("message", "")
|
||||||
target = data.get("target", "")
|
detail = call_details.get(tcid, {})
|
||||||
if "created" in message.lower():
|
target = data.get("target", "") or detail.get("target", "")
|
||||||
actions.append(message)
|
is_skill = detail.get("tool") == "skill_manage"
|
||||||
elif "updated" in message.lower():
|
|
||||||
actions.append(message)
|
message_lower = message.lower()
|
||||||
elif "added" in message.lower() or (target and "add" in message.lower()):
|
if not verbose:
|
||||||
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
|
if "created" in message_lower:
|
||||||
actions.append(f"{label} updated")
|
actions.append(message)
|
||||||
elif "Entry added" in message:
|
continue
|
||||||
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
|
if "updated" in message_lower:
|
||||||
actions.append(f"{label} updated")
|
actions.append(message)
|
||||||
elif "removed" in message.lower() or "replaced" in message.lower():
|
continue
|
||||||
|
if is_skill and "patched" in message_lower:
|
||||||
|
actions.append(message)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if is_skill:
|
||||||
|
label = "Skill"
|
||||||
|
elif target:
|
||||||
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
|
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
action = detail.get("action", "")
|
||||||
|
content = detail.get("content", "")
|
||||||
|
old_text = detail.get("old_text", "")
|
||||||
|
skill_name = detail.get("name", "")
|
||||||
|
max_preview = 120
|
||||||
|
if is_skill:
|
||||||
|
change = data.get("_change", {})
|
||||||
|
old_string = change.get("old", "") or detail.get("old_string", "")
|
||||||
|
new_string = change.get("new", "") or detail.get("new_string", "")
|
||||||
|
description = change.get("description", "")
|
||||||
|
if action == "patch" and (old_string or new_string):
|
||||||
|
old_preview = old_string[:80].replace("\n", " ") + (
|
||||||
|
"…" if len(old_string) > 80 else ""
|
||||||
|
)
|
||||||
|
new_preview = new_string[:80].replace("\n", " ") + (
|
||||||
|
"…" if len(new_string) > 80 else ""
|
||||||
|
)
|
||||||
|
actions.append(
|
||||||
|
f"📝 Skill '{skill_name}' patched: "
|
||||||
|
f"\"{old_preview}\" → \"{new_preview}\""
|
||||||
|
)
|
||||||
|
elif action == "create" and description:
|
||||||
|
actions.append(f"📝 Skill '{skill_name}' created: {description}")
|
||||||
|
elif action == "edit" and description:
|
||||||
|
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
|
||||||
|
else:
|
||||||
|
actions.append(f"📝 {message}" if message else f"Skill {action}")
|
||||||
|
elif action == "add" and content:
|
||||||
|
preview = content[:max_preview] + ("…" if len(content) > max_preview else "")
|
||||||
|
actions.append(f"{label} ➕ {preview}")
|
||||||
|
elif action == "replace" and content:
|
||||||
|
preview = content[:max_preview] + ("…" if len(content) > max_preview else "")
|
||||||
|
actions.append(f"{label} ✏️ {preview}")
|
||||||
|
elif action == "remove" and old_text:
|
||||||
|
preview = old_text[:60] + ("…" if len(old_text) > 60 else "")
|
||||||
|
actions.append(f"{label} ➖ {preview}")
|
||||||
|
else:
|
||||||
|
actions.append(f"{label} updated")
|
||||||
|
elif (
|
||||||
|
"added" in message_lower
|
||||||
|
or "replaced" in message_lower
|
||||||
|
or "removed" in message_lower
|
||||||
|
or (target and "add" in message.lower())
|
||||||
|
or "Entry added" in message
|
||||||
|
):
|
||||||
actions.append(f"{label} updated")
|
actions.append(f"{label} updated")
|
||||||
return actions
|
return actions
|
||||||
|
|
||||||
@ -522,6 +623,7 @@ def _run_review_in_thread(
|
|||||||
actions = summarize_background_review_actions(
|
actions = summarize_background_review_actions(
|
||||||
review_messages,
|
review_messages,
|
||||||
messages_snapshot,
|
messages_snapshot,
|
||||||
|
notification_mode=getattr(agent, "memory_notifications", "on"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if actions:
|
if actions:
|
||||||
|
|||||||
@ -58,17 +58,34 @@ _bedrock_runtime_client_cache: Dict[str, Any] = {}
|
|||||||
_bedrock_control_client_cache: Dict[str, Any] = {}
|
_bedrock_control_client_cache: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
_MIN_BOTO3_VERSION = (1, 34, 59)
|
||||||
|
|
||||||
|
|
||||||
def _require_boto3():
|
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:
|
try:
|
||||||
import boto3
|
import boto3
|
||||||
return boto3
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"The 'boto3' package is required for the AWS Bedrock provider. "
|
"The 'boto3' package is required for the AWS Bedrock provider. "
|
||||||
"Install it with: pip install boto3\n"
|
"Install it with: pip install boto3\n"
|
||||||
"Or install Hermes with Bedrock support: pip install -e '.[bedrock]'"
|
"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):
|
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
|
report["attempted"] = True # we tried but there was nothing to do
|
||||||
return report
|
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:
|
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:
|
except ImportError as e:
|
||||||
report["error"] = f"cron module unavailable: {e}"
|
report["error"] = f"cron module unavailable: {e}"
|
||||||
return report
|
return report
|
||||||
|
|
||||||
report["attempted"] = True
|
report["attempted"] = True
|
||||||
try:
|
try:
|
||||||
with _jobs_file_lock:
|
with _jobs_lock():
|
||||||
live_jobs = load_jobs()
|
live_jobs = load_jobs()
|
||||||
changed = False
|
changed = False
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import time
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from difflib import unified_diff
|
from difflib import unified_diff
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from utils import safe_json_loads
|
from utils import safe_json_loads
|
||||||
from agent.tool_result_classification import file_mutation_result_landed
|
from agent.tool_result_classification import file_mutation_result_landed
|
||||||
@ -168,6 +169,27 @@ def _oneline(text: str) -> str:
|
|||||||
return " ".join(text.split())
|
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:
|
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.
|
"""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",
|
"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":
|
if tool_name == "process":
|
||||||
action = args.get("action", "")
|
action = args.get("action", "")
|
||||||
sid = args.get("session_id", "")
|
sid = args.get("session_id", "")
|
||||||
@ -1019,7 +1057,10 @@ def get_cute_tool_message(
|
|||||||
if tool_name == "delegate_task":
|
if tool_name == "delegate_task":
|
||||||
tasks = args.get("tasks")
|
tasks = args.get("tasks")
|
||||||
if tasks and isinstance(tasks, list):
|
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}")
|
return _wrap(f"┊ 🔀 delegate {_trunc(args.get('goal', ''), 35)} {dur}")
|
||||||
|
|
||||||
preview = build_tool_preview(tool_name, args) or ""
|
preview = build_tool_preview(tool_name, args) or ""
|
||||||
|
|||||||
@ -104,6 +104,7 @@ _PREFIX_PATTERNS = [
|
|||||||
r"mem0_[A-Za-z0-9]{10,}", # Mem0 Platform API key
|
r"mem0_[A-Za-z0-9]{10,}", # Mem0 Platform API key
|
||||||
r"brv_[A-Za-z0-9]{10,}", # ByteRover API key
|
r"brv_[A-Za-z0-9]{10,}", # ByteRover API key
|
||||||
r"xai-[A-Za-z0-9]{30,}", # xAI (Grok) 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
|
# ENV assignment patterns: KEY=value where KEY contains a secret-like name
|
||||||
|
|||||||
@ -538,6 +538,7 @@ class ChatCompletionsTransport(ProviderTransport):
|
|||||||
supports_reasoning=params.get("supports_reasoning", False),
|
supports_reasoning=params.get("supports_reasoning", False),
|
||||||
qwen_session_metadata=params.get("qwen_session_metadata"),
|
qwen_session_metadata=params.get("qwen_session_metadata"),
|
||||||
model=model,
|
model=model,
|
||||||
|
base_url=params.get("base_url"),
|
||||||
ollama_num_ctx=params.get("ollama_num_ctx"),
|
ollama_num_ctx=params.get("ollama_num_ctx"),
|
||||||
session_id=params.get("session_id"),
|
session_id=params.get("session_id"),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nous-research/ui": "0.16.0",
|
"@nous-research/ui": "0.16.0",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tauri-apps/api": "^2.0.0",
|
"@tauri-apps/api": "^2.0.0",
|
||||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||||
@ -40,8 +40,8 @@
|
|||||||
"@tauri-apps/cli": "^2.0.0",
|
"@tauri-apps/cli": "^2.0.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.2.0",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^7.3.1"
|
"vite": "^8.0.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2023",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
|||||||
@ -34,7 +34,7 @@ It builds and launches the GUI against your existing install — same config, ke
|
|||||||
|
|
||||||
### Prebuilt installers
|
### 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 }
|
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) {
|
function tokenPreview(value) {
|
||||||
const raw = String(value || '')
|
const raw = String(value || '')
|
||||||
|
|
||||||
@ -247,6 +280,7 @@ module.exports = {
|
|||||||
cookiesHaveLiveSession,
|
cookiesHaveLiveSession,
|
||||||
normAuthMode,
|
normAuthMode,
|
||||||
normalizeRemoteBaseUrl,
|
normalizeRemoteBaseUrl,
|
||||||
|
pathWithGlobalRemoteProfile,
|
||||||
profileRemoteOverride,
|
profileRemoteOverride,
|
||||||
resolveAuthMode,
|
resolveAuthMode,
|
||||||
resolveTestWsUrl,
|
resolveTestWsUrl,
|
||||||
|
|||||||
@ -24,6 +24,7 @@ const {
|
|||||||
cookiesHaveLiveSession,
|
cookiesHaveLiveSession,
|
||||||
normAuthMode,
|
normAuthMode,
|
||||||
normalizeRemoteBaseUrl,
|
normalizeRemoteBaseUrl,
|
||||||
|
pathWithGlobalRemoteProfile,
|
||||||
profileRemoteOverride,
|
profileRemoteOverride,
|
||||||
resolveAuthMode,
|
resolveAuthMode,
|
||||||
resolveTestWsUrl,
|
resolveTestWsUrl,
|
||||||
@ -90,6 +91,72 @@ test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
|
|||||||
assert.equal(profileRemoteOverride(null, 'coder'), null)
|
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 ---
|
// --- normalizeRemoteBaseUrl ---
|
||||||
|
|
||||||
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
|
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 { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||||
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
|
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
|
||||||
|
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
|
||||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||||
const { gitRootForIpc } = require('./git-root.cjs')
|
const { gitRootForIpc } = require('./git-root.cjs')
|
||||||
const { worktreesForIpc } = require('./git-worktrees.cjs')
|
const { worktreesForIpc } = require('./git-worktrees.cjs')
|
||||||
@ -62,6 +63,7 @@ const {
|
|||||||
cookiesHaveLiveSession,
|
cookiesHaveLiveSession,
|
||||||
normAuthMode,
|
normAuthMode,
|
||||||
normalizeRemoteBaseUrl,
|
normalizeRemoteBaseUrl,
|
||||||
|
pathWithGlobalRemoteProfile,
|
||||||
profileRemoteOverride,
|
profileRemoteOverride,
|
||||||
resolveAuthMode,
|
resolveAuthMode,
|
||||||
resolveTestWsUrl,
|
resolveTestWsUrl,
|
||||||
@ -242,6 +244,16 @@ if (INSTALL_STAMP) {
|
|||||||
function resolveHermesHome() {
|
function resolveHermesHome() {
|
||||||
if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME)
|
if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME)
|
||||||
if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home')
|
if (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) {
|
if (IS_WINDOWS && process.env.LOCALAPPDATA) {
|
||||||
const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes')
|
const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes')
|
||||||
const legacy = path.join(app.getPath('home'), '.hermes')
|
const legacy = path.join(app.getPath('home'), '.hermes')
|
||||||
@ -5072,65 +5084,75 @@ function focusWindow(win) {
|
|||||||
win.focus()
|
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.
|
// Open (or focus) a standalone window for a single chat session.
|
||||||
function createSessionWindow(sessionId, { watch = false } = {}) {
|
function createSessionWindow(sessionId, { watch = false } = {}) {
|
||||||
return sessionWindows.openOrFocus(sessionId, () => {
|
return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch }))
|
||||||
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) {
|
// Open a fresh compact window on the new-session draft (#/). Not registry-keyed:
|
||||||
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
|
// 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() {
|
||||||
win.once('ready-to-show', () => {
|
return spawnSecondaryWindow({ newSession: true })
|
||||||
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
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
@ -5317,6 +5339,11 @@ ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
|
|||||||
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
ipcMain.handle('hermes:window:openNewSession', async () => {
|
||||||
|
createNewSessionWindow()
|
||||||
|
|
||||||
|
return { ok: true }
|
||||||
|
})
|
||||||
ipcMain.handle('hermes:bootstrap:reset', async () => {
|
ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||||
// Renderer's "Reload and retry" path. Clear the latched failure and
|
// Renderer's "Reload and retry" path. Clear the latched failure and
|
||||||
// reset connection state so the next startHermes() call restarts the
|
// reset connection state so the next startHermes() call restarts the
|
||||||
@ -5586,9 +5613,14 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
|||||||
|
|
||||||
await prepareProfileDeleteRequest(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 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
|
// OAuth gateways authenticate REST via the HttpOnly session cookie held in
|
||||||
// the OAuth partition — route through Electron's net stack bound to that
|
// the OAuth partition — route through Electron's net stack bound to that
|
||||||
// session so the cookie attaches automatically. Token/local modes keep using
|
// 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),
|
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
||||||
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
||||||
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
|
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
|
||||||
|
openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'),
|
||||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||||
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
||||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
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
|
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
|
||||||
// treated as the route by HashRouter and would break routeSessionId(). The
|
// treated as the route by HashRouter and would break routeSessionId(). The
|
||||||
// renderer reads the flag from window.location.search to suppress the install /
|
// renderer reads the flag from window.location.search to suppress the install /
|
||||||
// onboarding overlays and the global session sidebar. `watch=1` marks a
|
// onboarding overlays and the global session sidebar. `new=1` marks the compact
|
||||||
// spectator window (e.g. a running subagent's session): the renderer resumes
|
// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's
|
||||||
// it lazily so the gateway never builds an agent just to stream into it.
|
// session): the renderer resumes it lazily so the gateway never builds an agent
|
||||||
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch } = {}) {
|
// just to stream into it.
|
||||||
const query = `?win=secondary${watch ? '&watch=1' : ''}`
|
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession } = {}) {
|
||||||
const route = `#/${encodeURIComponent(sessionId)}`
|
const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}`
|
||||||
|
const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`
|
||||||
|
|
||||||
if (devServer) {
|
if (devServer) {
|
||||||
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : 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')
|
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', () => {
|
test('registry opens one window per session and focuses on re-open', () => {
|
||||||
const registry = createSessionWindowRegistry()
|
const registry = createSessionWindowRegistry()
|
||||||
let built = 0
|
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 .",
|
"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",
|
"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",
|
"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",
|
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
|
||||||
"pack": "npm run build && npm run builder -- --dir",
|
"pack": "npm run build && npm run builder -- --dir",
|
||||||
"dist": "npm run build && npm run builder",
|
"dist": "npm run build && npm run builder",
|
||||||
@ -36,7 +37,7 @@
|
|||||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
"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",
|
"typecheck": "tsc -p . --noEmit",
|
||||||
"lint": "eslint src/ electron/",
|
"lint": "eslint src/ electron/",
|
||||||
"lint:fix": "eslint src/ electron/ --fix",
|
"lint:fix": "eslint src/ electron/ --fix",
|
||||||
@ -134,6 +135,7 @@
|
|||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"electronVersion": "40.9.3",
|
"electronVersion": "40.9.3",
|
||||||
|
"electronDist": "../../node_modules/electron/dist",
|
||||||
"appId": "com.nousresearch.hermes",
|
"appId": "com.nousresearch.hermes",
|
||||||
"productName": "Hermes",
|
"productName": "Hermes",
|
||||||
"executableName": "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 { sessionTitle } from '@/lib/chat-runtime'
|
||||||
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
|
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
|
||||||
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
|
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
|
||||||
|
import { mediaExternalUrl } from '@/lib/media'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { notifyError } from '@/store/notifications'
|
import { notifyError } from '@/store/notifications'
|
||||||
import type { SessionInfo, SessionMessage } from '@/types/hermes'
|
import type { SessionInfo, SessionMessage } from '@/types/hermes'
|
||||||
@ -124,17 +125,12 @@ function artifactKind(value: string): ArtifactKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function artifactHref(value: string): string {
|
function artifactHref(value: string): string {
|
||||||
if (
|
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('data:')) {
|
||||||
value.startsWith('http://') ||
|
|
||||||
value.startsWith('https://') ||
|
|
||||||
value.startsWith('file://') ||
|
|
||||||
value.startsWith('data:')
|
|
||||||
) {
|
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.startsWith('/')) {
|
if (value.startsWith('file://') || value.startsWith('/')) {
|
||||||
return `file://${encodeURI(value)}`
|
return mediaExternalUrl(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|||||||
@ -42,6 +42,7 @@ import {
|
|||||||
$sessions,
|
$sessions,
|
||||||
sessionPinId
|
sessionPinId
|
||||||
} from '@/store/session'
|
} from '@/store/session'
|
||||||
|
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
|
||||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||||
|
|
||||||
import { routeSessionId } from '../routes'
|
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
|
// 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)
|
// just a dead "New session" label + chevron. Drop it (and its border)
|
||||||
// entirely until there's a real session to act on.
|
// entirely until there's a real session to act on.
|
||||||
if (!selectedSessionId && !activeSessionId && !isRoutedSessionView) {
|
if (isNewSessionWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -302,7 +303,10 @@ export function ChatView({
|
|||||||
// waiting for the resume effect (which paints a frame later) to clear them.
|
// waiting for the resume effect (which paints a frame later) to clear them.
|
||||||
const routeSessionMismatch = isRoutedSessionView && routedSessionId !== selectedSessionId
|
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
|
// Session is still loading if the route references a session we haven't
|
||||||
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
|
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
|
||||||
|
|||||||
@ -77,6 +77,7 @@ import {
|
|||||||
setSessionsLoading,
|
setSessionsLoading,
|
||||||
setSessionsTotal
|
setSessionsTotal
|
||||||
} from '../store/session'
|
} from '../store/session'
|
||||||
|
import { onSessionsChanged } from '../store/session-sync'
|
||||||
import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos'
|
import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos'
|
||||||
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
|
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
|
||||||
import { isSecondaryWindow } from '../store/windows'
|
import { isSecondaryWindow } from '../store/windows'
|
||||||
@ -464,6 +465,17 @@ export function DesktopController() {
|
|||||||
void refreshSessions()
|
void refreshSessions()
|
||||||
}, [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
|
// 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.
|
// page and merge it in place, leaving every other profile's rows untouched.
|
||||||
const loadMoreSessionsForProfile = useCallback(async (profile: string) => {
|
const loadMoreSessionsForProfile = useCallback(async (profile: string) => {
|
||||||
|
|||||||
@ -37,6 +37,7 @@ import {
|
|||||||
switcherActive,
|
switcherActive,
|
||||||
switcherJustClosed
|
switcherJustClosed
|
||||||
} from '@/store/session-switcher'
|
} from '@/store/session-switcher'
|
||||||
|
import { openNewSessionInNewWindow } from '@/store/windows'
|
||||||
import { useTheme } from '@/themes/context'
|
import { useTheme } from '@/themes/context'
|
||||||
|
|
||||||
import { requestComposerFocus } from '../chat/composer/focus'
|
import { requestComposerFocus } from '../chat/composer/focus'
|
||||||
@ -132,6 +133,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||||||
deps.startFreshSession()
|
deps.startFreshSession()
|
||||||
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
|
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
|
||||||
},
|
},
|
||||||
|
'session.newWindow': () => void openNewSessionInNewWindow(),
|
||||||
'session.next': () => stepSession(1),
|
'session.next': () => stepSession(1),
|
||||||
'session.prev': () => stepSession(-1),
|
'session.prev': () => stepSession(-1),
|
||||||
...sessionSlotHandlers,
|
...sessionSlotHandlers,
|
||||||
|
|||||||
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
|
onRefresh: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sidebar palette + hover-reveal: refresh tracks label hover; collapse-all
|
// Sidebar palette + hover-reveal: header actions stay reachable while moving
|
||||||
// stays visible while any folder is expanded.
|
// from the project label to the action buttons.
|
||||||
const HEADER_ACTION_CLASS =
|
const HEADER_ACTION_CLASS =
|
||||||
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
|
'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({
|
function FilesystemTab({
|
||||||
canCollapse,
|
canCollapse,
|
||||||
@ -158,7 +158,7 @@ function FilesystemTab({
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<RightSidebarSectionHeader>
|
<RightSidebarSectionHeader>
|
||||||
<div className="peer/project-label flex min-w-0 flex-1">
|
<div className="flex min-w-0 flex-1">
|
||||||
<button
|
<button
|
||||||
className="flex w-full min-w-0 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
className="flex w-full min-w-0 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||||
onClick={() => void onChangeFolder()}
|
onClick={() => void onChangeFolder()}
|
||||||
@ -216,7 +216,7 @@ function FilesystemTab({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
|
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 {
|
interface FileTreeBodyProps {
|
||||||
|
|||||||
@ -47,6 +47,7 @@ import {
|
|||||||
setTurnStartedAt,
|
setTurnStartedAt,
|
||||||
setYoloActive
|
setYoloActive
|
||||||
} from '@/store/session'
|
} from '@/store/session'
|
||||||
|
import { broadcastSessionsChanged } from '@/store/session-sync'
|
||||||
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
|
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
|
||||||
import { setSessionTodos } from '@/store/todos'
|
import { setSessionTodos } from '@/store/todos'
|
||||||
import { recordToolDiff } from '@/store/tool-diffs'
|
import { recordToolDiff } from '@/store/tool-diffs'
|
||||||
@ -641,6 +642,9 @@ export function useMessageStream({
|
|||||||
})
|
})
|
||||||
|
|
||||||
void refreshSessions().catch(() => undefined)
|
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)) {
|
if (compactedTurnRef.current.delete(sessionId)) {
|
||||||
shouldHydrate = false
|
shouldHydrate = false
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { renderHook } from '@testing-library/react'
|
|
||||||
import { QueryClient } from '@tanstack/react-query'
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
import { cleanup, render, renderHook } from '@testing-library/react'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { getGlobalModelInfo } from '@/hermes'
|
import { getGlobalModelInfo } from '@/hermes'
|
||||||
@ -13,12 +13,51 @@ import {
|
|||||||
|
|
||||||
import { useModelControls } from './use-model-controls'
|
import { useModelControls } from './use-model-controls'
|
||||||
|
|
||||||
|
const setGlobalModel = vi.fn()
|
||||||
|
const notifyError = vi.fn()
|
||||||
|
|
||||||
vi.mock('@/hermes', () => ({
|
vi.mock('@/hermes', () => ({
|
||||||
getGlobalModelInfo: vi.fn(),
|
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(() => {
|
beforeEach(() => {
|
||||||
$activeSessionId.set(null)
|
$activeSessionId.set(null)
|
||||||
setCurrentModel('')
|
setCurrentModel('')
|
||||||
@ -26,6 +65,7 @@ describe('useModelControls.refreshCurrentModel', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
$activeSessionId.set(null)
|
$activeSessionId.set(null)
|
||||||
setCurrentModel('')
|
setCurrentModel('')
|
||||||
@ -74,4 +114,55 @@ describe('useModelControls.refreshCurrentModel', () => {
|
|||||||
expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro')
|
expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro')
|
||||||
expect($currentProvider.get()).toBe('deepseek')
|
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 {
|
try {
|
||||||
if (activeSessionId) {
|
if (activeSessionId) {
|
||||||
await requestGateway('slash.exec', {
|
await requestGateway('config.set', {
|
||||||
session_id: activeSessionId,
|
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) {
|
if (selection.persistGlobal) {
|
||||||
|
|||||||
@ -42,6 +42,7 @@ import {
|
|||||||
setYoloActive,
|
setYoloActive,
|
||||||
workspaceCwdForNewSession
|
workspaceCwdForNewSession
|
||||||
} from '@/store/session'
|
} from '@/store/session'
|
||||||
|
import { broadcastSessionsChanged } from '@/store/session-sync'
|
||||||
import { reportBackendContract } from '@/store/updates'
|
import { reportBackendContract } from '@/store/updates'
|
||||||
import { isWatchWindow } from '@/store/windows'
|
import { isWatchWindow } from '@/store/windows'
|
||||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
|
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.
|
// server later returns its own preview/title and supersedes this.
|
||||||
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
|
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
|
||||||
navigate(sessionRoute(stored), { replace: true })
|
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)
|
setFreshDraftReady(false)
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
} from '@/store/layout'
|
} from '@/store/layout'
|
||||||
import { $paneWidthOverride } from '@/store/panes'
|
import { $paneWidthOverride } from '@/store/panes'
|
||||||
import { $connection } from '@/store/session'
|
import { $connection } from '@/store/session'
|
||||||
import { isSecondaryWindow } from '@/store/windows'
|
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
|
||||||
|
|
||||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
||||||
|
|
||||||
@ -80,6 +80,7 @@ export function AppShell({
|
|||||||
const connection = useStore($connection)
|
const connection = useStore($connection)
|
||||||
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
|
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
|
||||||
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
|
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
|
||||||
|
const hideTitlebarControls = isNewSessionWindow()
|
||||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
|
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
|
||||||
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
|
// 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
|
// on macOS, where window controls sit on the left and are reported via
|
||||||
@ -162,7 +163,9 @@ export function AppShell({
|
|||||||
} as CSSProperties
|
} 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">
|
<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">
|
<PaneShell className="min-h-0 flex-1">
|
||||||
@ -183,7 +186,9 @@ export function AppShell({
|
|||||||
the panes' z-20 resize handles, keeping every pane resizable. */}
|
the panes' z-20 resize handles, keeping every pane resizable. */}
|
||||||
{mainOverlays}
|
{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>
|
</main>
|
||||||
|
|
||||||
{overlays}
|
{overlays}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
|
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
|
||||||
import {
|
import {
|
||||||
|
type CSSProperties,
|
||||||
type ComponentProps,
|
type ComponentProps,
|
||||||
type FC,
|
type FC,
|
||||||
memo,
|
memo,
|
||||||
@ -21,6 +22,7 @@ import {
|
|||||||
resetThreadScroll,
|
resetThreadScroll,
|
||||||
setThreadAtBottom
|
setThreadAtBottom
|
||||||
} from '@/store/thread-scroll'
|
} from '@/store/thread-scroll'
|
||||||
|
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
|
||||||
|
|
||||||
import { MessageRenderBoundary } from './message-render-boundary'
|
import { MessageRenderBoundary } from './message-render-boundary'
|
||||||
|
|
||||||
@ -132,6 +134,13 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||||||
const hiddenCount = firstVisible
|
const hiddenCount = firstVisible
|
||||||
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
|
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
|
||||||
const restoreFromBottomRef = useRef<number | null>(null)
|
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(() => setThreadAtBottom(isAtBottom), [isAtBottom])
|
||||||
useEffect(() => () => resetThreadScroll(), [])
|
useEffect(() => () => resetThreadScroll(), [])
|
||||||
@ -235,7 +244,12 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
|
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
|
<div
|
||||||
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
|
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||||
@ -252,9 +266,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn('mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6', threadContentTopPad)}
|
||||||
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
|
|
||||||
)}
|
|
||||||
data-slot="aui_thread-content"
|
data-slot="aui_thread-content"
|
||||||
ref={contentRef as React.RefCallback<HTMLDivElement>}
|
ref={contentRef as React.RefCallback<HTMLDivElement>}
|
||||||
>
|
>
|
||||||
|
|||||||
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 spectator window (lazy resume — no agent build) for live-streaming
|
||||||
// a running subagent's session.
|
// a running subagent's session.
|
||||||
openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }>
|
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>
|
getBootProgress: () => Promise<DesktopBootProgress>
|
||||||
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
|
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
|
||||||
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||||
|
|||||||
@ -189,6 +189,7 @@ export const en: Translations = {
|
|||||||
'nav.cron': 'Open scheduled jobs',
|
'nav.cron': 'Open scheduled jobs',
|
||||||
'nav.agents': 'Open agents',
|
'nav.agents': 'Open agents',
|
||||||
'session.new': 'New session',
|
'session.new': 'New session',
|
||||||
|
'session.newWindow': 'New session in window',
|
||||||
'session.next': 'Next session',
|
'session.next': 'Next session',
|
||||||
'session.prev': 'Previous session',
|
'session.prev': 'Previous session',
|
||||||
'session.slot.1': 'Switch to recent session 1',
|
'session.slot.1': 'Switch to recent session 1',
|
||||||
|
|||||||
@ -185,6 +185,7 @@ export const zh: Translations = {
|
|||||||
'nav.cron': '打开定时任务',
|
'nav.cron': '打开定时任务',
|
||||||
'nav.agents': '打开智能体',
|
'nav.agents': '打开智能体',
|
||||||
'session.new': '新建会话',
|
'session.new': '新建会话',
|
||||||
|
'session.newWindow': '在新窗口中新建会话',
|
||||||
'session.next': '下一个会话',
|
'session.next': '下一个会话',
|
||||||
'session.prev': '上一个会话',
|
'session.prev': '上一个会话',
|
||||||
'session.slot.1': '切换到最近会话 1',
|
'session.slot.1': '切换到最近会话 1',
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import type { ChatMessage, ChatMessagePart } from './chat-messages'
|
import type { ChatMessage, ChatMessagePart } from './chat-messages'
|
||||||
import {
|
import {
|
||||||
appendAssistantTextPart,
|
appendAssistantTextPart,
|
||||||
|
appendReasoningPart,
|
||||||
chatMessageText,
|
chatMessageText,
|
||||||
preserveLocalAssistantErrors,
|
preserveLocalAssistantErrors,
|
||||||
renderMediaTags,
|
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', () => {
|
describe('preserveLocalAssistantErrors', () => {
|
||||||
it('preserves a local user+error pair when hydration omits the failed turn', () => {
|
it('preserves a local user+error pair when hydration omits the failed turn', () => {
|
||||||
const nextMessages: ChatMessage[] = [
|
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
|
return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText
|
||||||
}
|
}
|
||||||
|
|
||||||
export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
const STREAM_PART: Record<'reasoning' | 'text', (text: string) => ChatMessagePart> = {
|
||||||
const next = [...parts]
|
reasoning: reasoningPart,
|
||||||
const last = next.at(-1)
|
text: textPart
|
||||||
|
|
||||||
if (last?.type === 'text') {
|
|
||||||
next[next.length - 1] = { ...last, text: `${last.text}${delta}` }
|
|
||||||
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
next.push(textPart(delta))
|
|
||||||
|
|
||||||
return next
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
// Coalesce a streaming delta into the most recent same-type part within the
|
||||||
const next = appendTextPart(parts, delta)
|
// current segment, where a segment is bounded by any non-streaming part (a
|
||||||
const last = next.at(-1)
|
// 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') {
|
for (let i = next.length - 1; i >= 0; i--) {
|
||||||
const current = last.text
|
const part = next[i]
|
||||||
|
|
||||||
const deltaMayContainMedia =
|
if (part.type === type) {
|
||||||
delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:')
|
next[i] = { ...part, text: `${(part as { text: string }).text}${delta}` } as ChatMessagePart
|
||||||
|
|
||||||
const needsMediaPass = deltaMayContainMedia || current.includes('MEDIA:')
|
return { index: i, parts: next }
|
||||||
const nextText = needsMediaPass ? renderMediaTags(current) : current
|
}
|
||||||
next[next.length - 1] = nextText === current ? last : { ...last, text: nextText }
|
|
||||||
|
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[] {
|
export function appendReasoningPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
||||||
const next = [...parts]
|
return appendStreamPart(parts, 'reasoning', delta).parts
|
||||||
const last = next.at(-1)
|
}
|
||||||
|
|
||||||
if (last?.type === 'reasoning') {
|
export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
||||||
next[next.length - 1] = { ...last, text: `${last.text}${delta}` }
|
const { index, parts: next } = appendStreamPart(parts, 'text', delta)
|
||||||
|
const part = next[index]
|
||||||
|
|
||||||
|
if (part?.type !== 'text') {
|
||||||
return next
|
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
|
return next
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,6 +66,7 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
|
|||||||
|
|
||||||
// ── Session ──────────────────────────────────────────────────────────────
|
// ── Session ──────────────────────────────────────────────────────────────
|
||||||
{ id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] },
|
{ 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
|
// ⌃Tab / ⌃⇧Tab — the universal tab-cycle chord. Literally Control, not Cmd
|
||||||
// (macOS reserves Cmd+Tab for app switching); see `ctrl` in combo.ts.
|
// (macOS reserves Cmd+Tab for app switching); see `ctrl` in combo.ts.
|
||||||
{ id: 'session.next', category: 'session', defaults: ['ctrl+tab'] },
|
{ 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 { $connection } from '@/store/session'
|
||||||
|
|
||||||
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway } from './media'
|
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway, mediaExternalUrl } from './media'
|
||||||
|
|
||||||
describe('isRemoteGateway', () => {
|
describe('isRemoteGateway', () => {
|
||||||
afterEach(() => {
|
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', () => {
|
describe('gatewayMediaDataUrl', () => {
|
||||||
const api = vi.fn(async () => ({ data_url: 'data:image/png;base64,ZHVtbXk=' }))
|
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)}`
|
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 {
|
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
|
// 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
|
storedStringRecord
|
||||||
} from '@/lib/storage'
|
} from '@/lib/storage'
|
||||||
import { $gateway, ensureGatewayForProfile } from '@/store/gateway'
|
import { $gateway, ensureGatewayForProfile } from '@/store/gateway'
|
||||||
|
import { setConnection } from '@/store/session'
|
||||||
import type { ProfileInfo } from '@/types/hermes'
|
import type { ProfileInfo } from '@/types/hermes'
|
||||||
|
|
||||||
// Canonical key for a profile: trimmed, empty → "default". Used everywhere we
|
// 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
|
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
|
// 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
|
// isn't live yet. Unlike the old single-socket swap, background profiles keep
|
||||||
// their sockets — so their sessions keep streaming concurrently. A null/empty
|
// 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.
|
// the active gateway at it — without closing the profile you came from.
|
||||||
await ensureGatewayForProfile(target)
|
await ensureGatewayForProfile(target)
|
||||||
$activeGatewayProfile.set(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 {
|
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 { 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 desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
|
||||||
const initialHermesDesktop = desktopWindow.hermesDesktop
|
const initialHermesDesktop = desktopWindow.hermesDesktop
|
||||||
@ -11,9 +11,13 @@ vi.mock('./notifications', () => ({
|
|||||||
notifyError: (...args: unknown[]) => notifyError(...args)
|
notifyError: (...args: unknown[]) => notifyError(...args)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function installBridge(openSessionWindow?: Window['hermesDesktop']['openSessionWindow']) {
|
function installBridge(
|
||||||
|
openSessionWindow?: Window['hermesDesktop']['openSessionWindow'],
|
||||||
|
openNewSessionWindow?: Window['hermesDesktop']['openNewSessionWindow']
|
||||||
|
) {
|
||||||
desktopWindow.hermesDesktop = {
|
desktopWindow.hermesDesktop = {
|
||||||
...(openSessionWindow ? { openSessionWindow } : {})
|
...(openSessionWindow ? { openSessionWindow } : {}),
|
||||||
|
...(openNewSessionWindow ? { openNewSessionWindow } : {})
|
||||||
} as unknown as Window['hermesDesktop']
|
} as unknown as Window['hermesDesktop']
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,3 +105,39 @@ describe('openSessionInNewWindow', () => {
|
|||||||
expect(notifyError).toHaveBeenCalledTimes(1)
|
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
|
// never from the router. A "secondary" window renders a single chat without the
|
||||||
// global session sidebar or the install / onboarding overlays.
|
// global session sidebar or the install / onboarding overlays.
|
||||||
const SECONDARY_WINDOW_FLAG = 'secondary'
|
const SECONDARY_WINDOW_FLAG = 'secondary'
|
||||||
|
const NEW_SESSION_WINDOW_FLAG = '1'
|
||||||
|
|
||||||
let secondaryWindowCache: boolean | null = null
|
let secondaryWindowCache: boolean | null = null
|
||||||
|
|
||||||
@ -27,6 +28,26 @@ export function isSecondaryWindow(): boolean {
|
|||||||
return result
|
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
|
let watchWindowCache: boolean | null = null
|
||||||
|
|
||||||
// A "watch" window spectates a session that is being driven elsewhere (a
|
// 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'
|
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
|
// Open (or focus) a standalone OS window for a single chat session. No-ops
|
||||||
// gracefully outside Electron so callers can wire it unconditionally.
|
// gracefully outside Electron so callers can wire it unconditionally.
|
||||||
// `watch: true` opens a spectator window (lazy resume, live-mirror stream).
|
// `watch: true` opens a spectator window (lazy resume, live-mirror stream).
|
||||||
@ -65,13 +102,14 @@ export async function openSessionInNewWindow(sessionId: string, opts?: { watch?:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await openWindow(() => window.hermesDesktop.openSessionWindow(sessionId, opts), 'Could not open chat in a new window')
|
||||||
const result = await window.hermesDesktop.openSessionWindow(sessionId, opts)
|
}
|
||||||
|
|
||||||
if (!result?.ok) {
|
// Open a fresh compact window on the new-session draft.
|
||||||
notifyError(new Error(result?.error || 'unknown error'), 'Could not open chat in a new window')
|
export async function openNewSessionInNewWindow(): Promise<void> {
|
||||||
}
|
if (!canOpenSessionWindow() || typeof window.hermesDesktop.openNewSessionWindow !== 'function') {
|
||||||
} catch (err) {
|
return
|
||||||
notifyError(err, 'Could not open chat in a new window')
|
}
|
||||||
}
|
|
||||||
|
await openWindow(() => window.hermesDesktop.openNewSessionWindow(), 'Could not open new session window')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2023",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
"lib": ["DOM", "DOM.Iterable", "ES2023"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
|||||||
@ -724,7 +724,7 @@ platform_toolsets:
|
|||||||
# # allowed_chats: ["-1001234567890"]
|
# # allowed_chats: ["-1001234567890"]
|
||||||
# extra:
|
# extra:
|
||||||
# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages
|
# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages
|
||||||
# rich_messages: false # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
|
# rich_messages: false # Bot API 10.1 rich messages (tables/task lists/details/math); default true, set false to force legacy MarkdownV2
|
||||||
#
|
#
|
||||||
# Discord-specific settings (config.yaml top-level, not under platforms:):
|
# 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()
|
_cleanup_all_terminals()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
from tools.async_delegation import interrupt_all as _interrupt_async_delegations
|
||||||
|
_interrupt_async_delegations(reason="CLI shutdown")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
_cleanup_all_browsers()
|
_cleanup_all_browsers()
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -5920,14 +5925,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||||||
if not self._session_db:
|
if not self._session_db:
|
||||||
return []
|
return []
|
||||||
try:
|
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",
|
source="cli",
|
||||||
exclude_sources=["tool"],
|
current_session_id=self.session_id,
|
||||||
|
include_all_sources=False,
|
||||||
|
include_unnamed=True,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
exclude_sources=["tool"],
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
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:
|
def _show_recent_sessions(self, *, reason: str = "history", limit: int = 10) -> bool:
|
||||||
"""Render recent sessions inline from the active chat TUI.
|
"""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
|
Output is saved to ~/.hermes/cron/output/{job_id}/{timestamp}.md
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@ -14,6 +15,19 @@ import threading
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import uuid
|
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 datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from hermes_constants import get_hermes_home
|
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.
|
# In-process lock protecting load_jobs→modify→save_jobs cycles.
|
||||||
# Required when tick() runs jobs in parallel threads — without this,
|
# Required when tick() runs jobs in parallel threads — without this,
|
||||||
# concurrent mark_job_run / advance_next_run calls can clobber each other.
|
# 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"
|
OUTPUT_DIR = CRON_DIR / "output"
|
||||||
ONESHOT_GRACE_SECONDS = 120
|
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
|
# 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
|
# as a filesystem path component under ``OUTPUT_DIR``; allowing it to be
|
||||||
# updated lets an unsafe value (``../escape``, absolute path, nested) leak
|
# 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]]):
|
def _save_jobs_unlocked(jobs: List[Dict[str, Any]]):
|
||||||
"""Save all jobs to storage."""
|
"""Save all jobs to storage. Caller must hold _jobs_lock()."""
|
||||||
ensure_dirs()
|
ensure_dirs()
|
||||||
fd, tmp_path = tempfile.mkstemp(dir=str(JOBS_FILE.parent), suffix='.tmp', prefix='.jobs_')
|
fd, tmp_path = tempfile.mkstemp(dir=str(JOBS_FILE.parent), suffix='.tmp', prefix='.jobs_')
|
||||||
try:
|
try:
|
||||||
@ -487,6 +570,12 @@ def save_jobs(jobs: List[Dict[str, Any]]):
|
|||||||
raise
|
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]:
|
def _normalize_workdir(workdir: Optional[str]) -> Optional[str]:
|
||||||
"""Normalize and validate a cron job workdir.
|
"""Normalize and validate a cron job workdir.
|
||||||
|
|
||||||
@ -670,9 +759,10 @@ def create_job(
|
|||||||
"workdir": normalized_workdir,
|
"workdir": normalized_workdir,
|
||||||
}
|
}
|
||||||
|
|
||||||
jobs = load_jobs()
|
with _jobs_lock():
|
||||||
jobs.append(job)
|
jobs = load_jobs()
|
||||||
save_jobs(jobs)
|
jobs.append(job)
|
||||||
|
save_jobs(jobs)
|
||||||
|
|
||||||
return job
|
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))}"
|
f"Cron job field(s) cannot be updated: {', '.join(sorted(bad_fields))}"
|
||||||
)
|
)
|
||||||
|
|
||||||
jobs = load_jobs()
|
with _jobs_lock():
|
||||||
for i, job in enumerate(jobs):
|
jobs = load_jobs()
|
||||||
if job["id"] != job_id:
|
for i, job in enumerate(jobs):
|
||||||
continue
|
if job["id"] != job_id:
|
||||||
|
continue
|
||||||
|
|
||||||
# Validate / normalize workdir if present in updates. Empty string or
|
# Validate / normalize workdir if present in updates. Empty string
|
||||||
# None both mean "clear the field" (restore old behaviour).
|
# or None both mean "clear the field" (restore old behaviour).
|
||||||
if "workdir" in updates:
|
if "workdir" in updates:
|
||||||
_wd = updates["workdir"]
|
_wd = updates["workdir"]
|
||||||
if _wd in {None, "", False}:
|
if _wd in {None, "", False}:
|
||||||
updates["workdir"] = None
|
updates["workdir"] = None
|
||||||
else:
|
else:
|
||||||
updates["workdir"] = _normalize_workdir(_wd)
|
updates["workdir"] = _normalize_workdir(_wd)
|
||||||
|
|
||||||
updated = _apply_skill_fields({**job, **updates})
|
updated = _apply_skill_fields({**job, **updates})
|
||||||
schedule_changed = "schedule" in updates
|
schedule_changed = "schedule" in updates
|
||||||
|
|
||||||
if "skills" in updates or "skill" in updates:
|
if "skills" in updates or "skill" in updates:
|
||||||
normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills"))
|
normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills"))
|
||||||
updated["skills"] = normalized_skills
|
updated["skills"] = normalized_skills
|
||||||
updated["skill"] = normalized_skills[0] if normalized_skills else None
|
updated["skill"] = normalized_skills[0] if normalized_skills else None
|
||||||
|
|
||||||
if schedule_changed:
|
if schedule_changed:
|
||||||
updated_schedule = updated["schedule"]
|
updated_schedule = updated["schedule"]
|
||||||
# The API may pass schedule as a raw string (e.g. "every 10m")
|
# The API may pass schedule as a raw string (e.g. "every 10m")
|
||||||
# instead of a pre-parsed dict. Normalize it the same way
|
# instead of a pre-parsed dict. Normalize it the same way
|
||||||
# create_job() does so downstream code can call .get() safely.
|
# create_job() does so downstream code can call .get() safely.
|
||||||
if isinstance(updated_schedule, str):
|
if isinstance(updated_schedule, str):
|
||||||
updated_schedule = parse_schedule(updated_schedule)
|
updated_schedule = parse_schedule(updated_schedule)
|
||||||
updated["schedule"] = updated_schedule
|
updated["schedule"] = updated_schedule
|
||||||
updated["schedule_display"] = updates.get(
|
updated["schedule_display"] = updates.get(
|
||||||
"schedule_display",
|
"schedule_display",
|
||||||
updated_schedule.get("display", updated.get("schedule_display")),
|
updated_schedule.get("display", updated.get("schedule_display")),
|
||||||
)
|
)
|
||||||
if updated.get("state") != "paused":
|
if updated.get("state") != "paused":
|
||||||
updated["next_run_at"] = compute_next_run(updated_schedule)
|
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"):
|
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"])
|
updated["next_run_at"] = compute_next_run(updated["schedule"])
|
||||||
|
|
||||||
jobs[i] = updated
|
jobs[i] = updated
|
||||||
save_jobs(jobs)
|
save_jobs(jobs)
|
||||||
return _normalize_job_record(jobs[i])
|
return _normalize_job_record(jobs[i])
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@ -847,19 +938,20 @@ def remove_job(job_id: str) -> bool:
|
|||||||
if not job:
|
if not job:
|
||||||
return False
|
return False
|
||||||
canonical_id = job["id"]
|
canonical_id = job["id"]
|
||||||
jobs = load_jobs()
|
with _jobs_lock():
|
||||||
original_len = len(jobs)
|
jobs = load_jobs()
|
||||||
jobs = [j for j in jobs if j["id"] != canonical_id]
|
original_len = len(jobs)
|
||||||
if len(jobs) < original_len:
|
jobs = [j for j in jobs if j["id"] != canonical_id]
|
||||||
# Resolve the output dir BEFORE saving so a legacy unsafe ID (e.g.
|
if len(jobs) < original_len:
|
||||||
# left over from before the create-time guard) fails closed without
|
# Resolve the output dir BEFORE saving so a legacy unsafe ID (e.g.
|
||||||
# half-applying the removal.
|
# left over from before the create-time guard) fails closed without
|
||||||
job_output_dir = _job_output_dir(canonical_id)
|
# half-applying the removal.
|
||||||
save_jobs(jobs)
|
job_output_dir = _job_output_dir(canonical_id)
|
||||||
# Clean up output directory to prevent orphaned dirs accumulating
|
save_jobs(jobs)
|
||||||
if job_output_dir.exists():
|
# Clean up output directory to prevent orphaned dirs accumulating
|
||||||
shutil.rmtree(job_output_dir)
|
if job_output_dir.exists():
|
||||||
return True
|
shutil.rmtree(job_output_dir)
|
||||||
|
return True
|
||||||
return False
|
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
|
``delivery_error`` is tracked separately from the agent error — a job
|
||||||
can succeed (agent produced output) but fail delivery (platform down).
|
can succeed (agent produced output) but fail delivery (platform down).
|
||||||
"""
|
"""
|
||||||
with _jobs_file_lock:
|
with _jobs_lock():
|
||||||
jobs = load_jobs()
|
jobs = load_jobs()
|
||||||
for i, job in enumerate(jobs):
|
for i, job in enumerate(jobs):
|
||||||
if job["id"] == job_id:
|
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.
|
Returns True if next_run_at was advanced, False otherwise.
|
||||||
"""
|
"""
|
||||||
with _jobs_file_lock:
|
with _jobs_lock():
|
||||||
jobs = load_jobs()
|
jobs = load_jobs()
|
||||||
for job in jobs:
|
for job in jobs:
|
||||||
if job["id"] == job_id:
|
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
|
the job is fast-forwarded to the next future run instead of firing
|
||||||
immediately. This prevents a burst of missed jobs on gateway restart.
|
immediately. This prevents a burst of missed jobs on gateway restart.
|
||||||
"""
|
"""
|
||||||
with _jobs_file_lock:
|
with _jobs_lock():
|
||||||
return _get_due_jobs_locked()
|
return _get_due_jobs_locked()
|
||||||
|
|
||||||
|
|
||||||
def _get_due_jobs_locked() -> List[Dict[str, Any]]:
|
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()
|
now = _hermes_now()
|
||||||
raw_jobs = load_jobs()
|
raw_jobs = load_jobs()
|
||||||
jobs = [_apply_skill_fields(j) for j in copy.deepcopy(raw_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:
|
if not consolidated and not pruned_set:
|
||||||
return {"rewrites": [], "jobs_updated": 0, "jobs_scanned": 0}
|
return {"rewrites": [], "jobs_updated": 0, "jobs_scanned": 0}
|
||||||
|
|
||||||
with _jobs_file_lock:
|
with _jobs_lock():
|
||||||
jobs = load_jobs()
|
jobs = load_jobs()
|
||||||
rewrites: List[Dict[str, Any]] = []
|
rewrites: List[Dict[str, Any]] = []
|
||||||
changed = False
|
changed = False
|
||||||
|
|||||||
@ -17,6 +17,57 @@ from utils import atomic_json_write
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DIRECTORY_PATH = get_hermes_home() / "channel_directory.json"
|
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:
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Overlay user-maintained friendly names before persisting.
|
||||||
|
_apply_channel_aliases(platforms)
|
||||||
|
|
||||||
directory = {
|
directory = {
|
||||||
"updated_at": datetime.now().isoformat(),
|
"updated_at": datetime.now().isoformat(),
|
||||||
"platforms": platforms,
|
"platforms": platforms,
|
||||||
@ -247,12 +301,20 @@ def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:
|
|||||||
def load_directory() -> Dict[str, Any]:
|
def load_directory() -> Dict[str, Any]:
|
||||||
"""Load the cached channel directory from disk."""
|
"""Load the cached channel directory from disk."""
|
||||||
if not DIRECTORY_PATH.exists():
|
if not DIRECTORY_PATH.exists():
|
||||||
return {"updated_at": None, "platforms": {}}
|
base = {"updated_at": None, "platforms": {}}
|
||||||
|
_apply_channel_aliases(base["platforms"])
|
||||||
|
return base
|
||||||
try:
|
try:
|
||||||
with open(DIRECTORY_PATH, encoding="utf-8") as f:
|
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:
|
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]:
|
def lookup_channel_type(platform_name: str, chat_id: str) -> Optional[str]:
|
||||||
|
|||||||
@ -32,6 +32,7 @@ from typing import Any
|
|||||||
|
|
||||||
_GLOBAL_DEFAULTS: dict[str, Any] = {
|
_GLOBAL_DEFAULTS: dict[str, Any] = {
|
||||||
"tool_progress": "all",
|
"tool_progress": "all",
|
||||||
|
"tool_progress_grouping": "accumulate", # "accumulate" = edit one bubble; "separate" = one msg per tool
|
||||||
"show_reasoning": False,
|
"show_reasoning": False,
|
||||||
"tool_preview_length": 0,
|
"tool_preview_length": 0,
|
||||||
"streaming": None, # None = follow top-level streaming config
|
"streaming": None, # None = follow top-level streaming config
|
||||||
@ -238,6 +239,9 @@ def _normalise(setting: str, value: Any) -> Any:
|
|||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
return value.lower() in {"true", "1", "yes", "on"}
|
return value.lower() in {"true", "1", "yes", "on"}
|
||||||
return bool(value)
|
return bool(value)
|
||||||
|
if setting == "tool_progress_grouping":
|
||||||
|
val = str(value).lower()
|
||||||
|
return val if val in ("accumulate", "separate") else "accumulate"
|
||||||
if setting == "tool_preview_length":
|
if setting == "tool_preview_length":
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return int(value)
|
||||||
|
|||||||
@ -419,11 +419,13 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||||||
self._mention_patterns = self._compile_mention_patterns()
|
self._mention_patterns = self._compile_mention_patterns()
|
||||||
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
|
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)
|
self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
|
||||||
# Bot API 10.1 Rich Messages: when explicitly enabled, send final
|
# Bot API 10.1 Rich Messages: render constructs the legacy MarkdownV2
|
||||||
# replies via sendRichMessage with the raw agent markdown so
|
# path degrades (tables → bullet lists, task lists, <details>, block
|
||||||
# tables/task lists/etc. render natively. Disabled by default because
|
# math) via sendRichMessage / editMessageText's rich_message param using
|
||||||
# several Telegram clients accept but render rich messages poorly.
|
# the raw agent markdown. Enabled by default; users can opt out for
|
||||||
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False)
|
# clients that accept but render rich messages poorly via
|
||||||
|
# platforms.telegram.extra.rich_messages: false.
|
||||||
|
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", True)
|
||||||
# Latched off after a capability failure on sendRichMessage /
|
# Latched off after a capability failure on sendRichMessage /
|
||||||
# sendRichMessageDraft (e.g. older python-telegram-bot without the
|
# sendRichMessageDraft (e.g. older python-telegram-bot without the
|
||||||
# endpoint) so later sends skip the doomed rich attempt entirely.
|
# endpoint) so later sends skip the doomed rich attempt entirely.
|
||||||
@ -979,18 +981,54 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _needs_rich_rendering(self, content: str) -> bool:
|
||||||
|
"""Return True for markdown constructs that the legacy path degrades.
|
||||||
|
|
||||||
|
Keep ordinary replies on the pre-rich MarkdownV2 path so Telegram
|
||||||
|
clients render a consistent font weight/spacing. The rich endpoint is
|
||||||
|
reserved for constructs where raw markdown materially improves output:
|
||||||
|
pipe tables (MarkdownV2 has no table syntax and rewrites them into
|
||||||
|
bullet lists), GFM task lists, collapsible ``<details>`` blocks, and
|
||||||
|
block math. Adapted from #45995 (@YonganZhang).
|
||||||
|
"""
|
||||||
|
if not content:
|
||||||
|
return False
|
||||||
|
if any(_TABLE_SEPARATOR_RE.match(line) for line in content.splitlines()):
|
||||||
|
return True
|
||||||
|
if re.search(r"(?m)^\s*[-*]\s+\[[ xX]\]\s+", content):
|
||||||
|
return True
|
||||||
|
if re.search(r"(?m)^<details\b|^</details>|^<summary\b|^</summary>", content):
|
||||||
|
return True
|
||||||
|
if "$$" in content:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _rich_eligible(self, content: str) -> bool:
|
||||||
|
"""Capability/content eligibility for rich, ignoring ``expect_edits``.
|
||||||
|
|
||||||
|
Shared core of :meth:`_should_attempt_rich` minus the per-call
|
||||||
|
``expect_edits`` metadata gate. The rich EDIT-finalize path
|
||||||
|
(:meth:`_try_edit_rich`) needs this: a streamed preview is sent with
|
||||||
|
``expect_edits=True`` to stay on the editable path mid-stream, but the
|
||||||
|
FINAL edit should still upgrade to rich when the content warrants it.
|
||||||
|
"""
|
||||||
|
return bool(
|
||||||
|
getattr(self, "_rich_messages_enabled", True)
|
||||||
|
and not getattr(self, "_rich_send_disabled", False)
|
||||||
|
and content
|
||||||
|
and content.strip()
|
||||||
|
and self._needs_rich_rendering(content)
|
||||||
|
and not self._has_telegram_desktop_details_math_crash_shape(content)
|
||||||
|
and self._content_fits_rich_limits(content)
|
||||||
|
and self._bot_supports_rich()
|
||||||
|
)
|
||||||
|
|
||||||
def _should_attempt_rich(
|
def _should_attempt_rich(
|
||||||
self, content: str, metadata: Optional[Dict[str, Any]] = None
|
self, content: str, metadata: Optional[Dict[str, Any]] = None
|
||||||
) -> bool:
|
) -> bool:
|
||||||
return bool(
|
return bool(
|
||||||
getattr(self, "_rich_messages_enabled", False)
|
not (metadata or {}).get("expect_edits")
|
||||||
and not getattr(self, "_rich_send_disabled", False)
|
and self._rich_eligible(content)
|
||||||
and not (metadata or {}).get("expect_edits")
|
|
||||||
and content
|
|
||||||
and content.strip()
|
|
||||||
and not self._has_telegram_desktop_details_math_crash_shape(content)
|
|
||||||
and self._content_fits_rich_limits(content)
|
|
||||||
and self._bot_supports_rich()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def prefers_fresh_final_streaming(
|
def prefers_fresh_final_streaming(
|
||||||
@ -998,12 +1036,13 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Whether to replace a streamed preview with a fresh rich final.
|
"""Whether to replace a streamed preview with a fresh rich final.
|
||||||
|
|
||||||
Keep this disabled for Telegram. The fresh-final path briefly shows two
|
Disabled for Telegram. The fresh-final path briefly shows two copies of
|
||||||
copies of the final answer, then deletes the streaming preview after the
|
the final answer, then deletes the streaming preview after the rich send
|
||||||
rich send succeeds. That is especially visible on clients that support
|
succeeds — it looks like duplicate delivery at the end of every streamed
|
||||||
rich messages well, and it looks like duplicate delivery at the end of
|
turn (the reason #46206 reverted it). Rich finalize is instead handled
|
||||||
every streamed turn. Until Telegram rich edits are wired directly, final
|
by editing the existing preview in place via Bot API 10.1's
|
||||||
streamed replies should edit the existing preview in place.
|
``editMessageText`` ``rich_message`` parameter (see
|
||||||
|
:meth:`_try_edit_rich`), so no fresh re-send / delete is needed.
|
||||||
"""
|
"""
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -1019,7 +1058,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||||||
streams split exactly as before.
|
streams split exactly as before.
|
||||||
"""
|
"""
|
||||||
if (
|
if (
|
||||||
getattr(self, "_rich_messages_enabled", False)
|
getattr(self, "_rich_messages_enabled", True)
|
||||||
and not getattr(self, "_rich_send_disabled", False)
|
and not getattr(self, "_rich_send_disabled", False)
|
||||||
and self._bot_supports_rich()
|
and self._bot_supports_rich()
|
||||||
):
|
):
|
||||||
@ -1207,9 +1246,74 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||||||
message_id=str(message_id) if message_id is not None else None,
|
message_id=str(message_id) if message_id is not None else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _try_edit_rich(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
message_id: str,
|
||||||
|
content: str,
|
||||||
|
) -> Optional[SendResult]:
|
||||||
|
"""Edit an existing message in place as a rich message (Bot API 10.1).
|
||||||
|
|
||||||
|
Uses ``editMessageText`` with the ``rich_message`` parameter so a
|
||||||
|
streamed preview can finalize as rich (tables/task lists/details/math)
|
||||||
|
WITHOUT a fresh send + delete — no duplicate preview. Mirrors
|
||||||
|
:meth:`_try_send_rich`'s error contract:
|
||||||
|
|
||||||
|
- success → ``SendResult(success=True, message_id=...)``
|
||||||
|
- permanent / capability error → ``None`` (caller falls back to the
|
||||||
|
legacy MarkdownV2 edit; capability errors latch rich off)
|
||||||
|
- transient / unknown → ``SendResult(success=False)`` with retry
|
||||||
|
semantics (the message may already be edited; do NOT legacy-resend)
|
||||||
|
"""
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"chat_id": int(chat_id),
|
||||||
|
"message_id": int(message_id),
|
||||||
|
"rich_message": self._rich_message_payload(content),
|
||||||
|
}
|
||||||
|
if getattr(self, "_disable_link_previews", False):
|
||||||
|
payload["link_preview_options"] = {"is_disabled": True}
|
||||||
|
try:
|
||||||
|
# Raw Bot API result; do not request return_type=Message (PTB does
|
||||||
|
# not fully model the 10.1 response shape yet — a post-edit parse
|
||||||
|
# error must not be mistaken for a failed edit).
|
||||||
|
await self._bot.do_api_request("editMessageText", api_kwargs=payload)
|
||||||
|
except Exception as exc:
|
||||||
|
if self._is_rich_fallback_error(exc):
|
||||||
|
if self._is_rich_capability_error(exc):
|
||||||
|
self._rich_send_disabled = True
|
||||||
|
# "Message is not modified" — content identical to the current
|
||||||
|
# rich message; treat as a successful no-op so the caller does
|
||||||
|
# not fall through to a redundant legacy edit.
|
||||||
|
if "not modified" in str(exc).lower():
|
||||||
|
return SendResult(success=True, message_id=message_id)
|
||||||
|
logger.debug(
|
||||||
|
"[%s] rich editMessageText rejected (%s) — falling back to MarkdownV2 edit",
|
||||||
|
self.name, exc,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if "not modified" in str(exc).lower():
|
||||||
|
return SendResult(success=True, message_id=message_id)
|
||||||
|
err_str = str(exc).lower()
|
||||||
|
try:
|
||||||
|
from telegram.error import TimedOut as _TimedOut
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
_TimedOut = None
|
||||||
|
is_timeout = (_TimedOut and isinstance(exc, _TimedOut)) or "timed out" in err_str
|
||||||
|
is_connect_timeout = self._looks_like_connect_timeout(exc)
|
||||||
|
logger.warning(
|
||||||
|
"[%s] rich editMessageText transient failure (no legacy resend): %s",
|
||||||
|
self.name, exc,
|
||||||
|
)
|
||||||
|
return SendResult(
|
||||||
|
success=False,
|
||||||
|
error=str(exc),
|
||||||
|
retryable=(is_connect_timeout or not is_timeout),
|
||||||
|
)
|
||||||
|
return SendResult(success=True, message_id=message_id)
|
||||||
|
|
||||||
def _should_attempt_rich_draft(self, content: str) -> bool:
|
def _should_attempt_rich_draft(self, content: str) -> bool:
|
||||||
return bool(
|
return bool(
|
||||||
getattr(self, "_rich_messages_enabled", False)
|
getattr(self, "_rich_messages_enabled", True)
|
||||||
and not getattr(self, "_rich_send_disabled", False)
|
and not getattr(self, "_rich_send_disabled", False)
|
||||||
and not getattr(self, "_rich_draft_disabled", False)
|
and not getattr(self, "_rich_draft_disabled", False)
|
||||||
and content
|
and content
|
||||||
@ -2555,6 +2659,21 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||||||
if not self._bot:
|
if not self._bot:
|
||||||
return SendResult(success=False, error="Not connected")
|
return SendResult(success=False, error="Not connected")
|
||||||
|
|
||||||
|
# Rich finalize (Bot API 10.1): when the completed content has
|
||||||
|
# constructs the legacy MarkdownV2 edit degrades (tables → bullet
|
||||||
|
# lists, task lists, <details>, block math) and rich is available,
|
||||||
|
# edit the preview IN PLACE via editMessageText's rich_message param.
|
||||||
|
# No fresh send + delete → no duplicate preview (the problem #46206
|
||||||
|
# reverted the fresh-final path for). Attempted before the 4,096
|
||||||
|
# overflow pre-flight because the rich text cap is 32,768 — a rich
|
||||||
|
# table that exceeds the MarkdownV2 limit must not be split into legacy
|
||||||
|
# chunks. Falls back to the legacy edit path (overflow split included)
|
||||||
|
# on capability/permanent rejection.
|
||||||
|
if finalize and self._rich_eligible(content):
|
||||||
|
rich_result = await self._try_edit_rich(chat_id, message_id, content)
|
||||||
|
if rich_result is not None:
|
||||||
|
return rich_result
|
||||||
|
|
||||||
# Pre-flight: if content already exceeds the limit, split-and-deliver
|
# Pre-flight: if content already exceeds the limit, split-and-deliver
|
||||||
# without round-tripping a doomed edit.
|
# without round-tripping a doomed edit.
|
||||||
if utf16_len(content) > self.MAX_MESSAGE_LENGTH:
|
if utf16_len(content) > self.MAX_MESSAGE_LENGTH:
|
||||||
|
|||||||
188
gateway/run.py
188
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)
|
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:
|
def _telegramize_command_mentions(text: str, platform: Any) -> str:
|
||||||
"""Rewrite slash-command mentions to Telegram-valid command names.
|
"""Rewrite slash-command mentions to Telegram-valid command names.
|
||||||
|
|
||||||
@ -1921,9 +1932,42 @@ def _format_gateway_process_notification(evt: dict) -> "str | None":
|
|||||||
text += "]"
|
text += "]"
|
||||||
return 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
|
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.
|
# Module-level weak reference to the active GatewayRunner instance.
|
||||||
# Used by tools (e.g. send_message) that need to route through a live
|
# Used by tools (e.g. send_message) that need to route through a live
|
||||||
# adapter for plugin platforms. Set in GatewayRunner.__init__().
|
# adapter for plugin platforms. Set in GatewayRunner.__init__().
|
||||||
@ -5353,6 +5397,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||||||
# turn so the agent kicks off the new chat.
|
# turn so the agent kicks off the new chat.
|
||||||
asyncio.create_task(self._handoff_watcher())
|
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")
|
logger.info("Press Ctrl+C to stop")
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@ -5989,6 +6039,16 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||||||
)
|
)
|
||||||
except Exception as _e:
|
except Exception as _e:
|
||||||
logger.debug("process_registry.kill_all (%s) error: %s", phase, _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:
|
try:
|
||||||
from tools.terminal_tool import cleanup_all_environments
|
from tools.terminal_tool import cleanup_all_environments
|
||||||
cleanup_all_environments()
|
cleanup_all_environments()
|
||||||
@ -7554,6 +7614,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||||||
if canonical == "resume":
|
if canonical == "resume":
|
||||||
return await self._handle_resume_command(event)
|
return await self._handle_resume_command(event)
|
||||||
|
|
||||||
|
if canonical == "sessions":
|
||||||
|
return await self._handle_sessions_command(event)
|
||||||
|
|
||||||
if canonical == "branch":
|
if canonical == "branch":
|
||||||
return await self._handle_branch_command(event)
|
return await self._handle_branch_command(event)
|
||||||
|
|
||||||
@ -8992,18 +9055,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||||||
logger.error("Process watcher setup error: %s", e)
|
logger.error("Process watcher setup error: %s", e)
|
||||||
|
|
||||||
# Drain watch pattern notifications that arrived during the agent run.
|
# Drain watch pattern notifications that arrived during the agent run.
|
||||||
# Watch events and completions share the same queue; completions are
|
# Watch events and completions share the same queue; process
|
||||||
# already handled by the per-process watcher task above, so we only
|
# completions are already handled by the per-process watcher task
|
||||||
# inject watch-type events here.
|
# 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:
|
try:
|
||||||
from tools.process_registry import process_registry as _pr
|
from tools.process_registry import process_registry as _pr
|
||||||
_watch_events = []
|
_watch_events = _drain_gateway_watch_events(_pr.completion_queue)
|
||||||
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
|
|
||||||
for evt in _watch_events:
|
for evt in _watch_events:
|
||||||
synth_text = _format_gateway_process_notification(evt)
|
synth_text = _format_gateway_process_notification(evt)
|
||||||
if synth_text:
|
if synth_text:
|
||||||
@ -12262,6 +12324,74 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Watch notification injection error: %s", 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:
|
async def _run_process_watcher(self, watcher: dict) -> None:
|
||||||
"""
|
"""
|
||||||
Periodically check a background process and push updates to the user.
|
Periodically check a background process and push updates to the user.
|
||||||
@ -12318,7 +12448,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||||||
if session.exited:
|
if session.exited:
|
||||||
# --- Agent-triggered completion: inject synthetic message ---
|
# --- Agent-triggered completion: inject synthetic message ---
|
||||||
# Skip if the agent already consumed the result via wait/poll/log
|
# 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):
|
if agent_notify and not _pr_check.is_completion_consumed(session_id):
|
||||||
from tools.ansi_strip import strip_ansi
|
from tools.ansi_strip import strip_ansi
|
||||||
_raw = strip_ansi(session.output_buffer) if session.output_buffer else ""
|
_raw = strip_ansi(session.output_buffer) if session.output_buffer else ""
|
||||||
@ -12334,12 +12464,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||||||
_out = f"[… output truncated — showing last {len(_tail)} chars]\n{_tail}"
|
_out = f"[… output truncated — showing last {len(_tail)} chars]\n{_tail}"
|
||||||
else:
|
else:
|
||||||
_out = _raw
|
_out = _raw
|
||||||
synth_text = (
|
synth_text = format_process_notification({
|
||||||
f"[IMPORTANT: Background process {session_id} completed "
|
"type": "completion",
|
||||||
f"(exit code {session.exit_code}).\n"
|
"session_id": session_id,
|
||||||
f"Command: {session.command}\n"
|
"command": session.command,
|
||||||
f"Output:\n{_out}]"
|
"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({
|
source = self._build_process_event_source({
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"session_key": session_key,
|
"session_key": session_key,
|
||||||
@ -13489,6 +13624,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||||||
if _env_tp and not _tool_progress_configured
|
if _env_tp and not _tool_progress_configured
|
||||||
else (_resolved_tp or _env_tp or "all")
|
else (_resolved_tp or _env_tp or "all")
|
||||||
)
|
)
|
||||||
|
# Tool progress grouping: "accumulate" (edit one bubble) or "separate" (one msg per tool)
|
||||||
|
progress_grouping = resolve_display_setting(user_config, platform_key, "tool_progress_grouping") or "accumulate"
|
||||||
# Disable tool progress for webhooks - they don't support message editing,
|
# Disable tool progress for webhooks - they don't support message editing,
|
||||||
# so each progress line would be sent as a separate message.
|
# so each progress line would be sent as a separate message.
|
||||||
from gateway.config import Platform
|
from gateway.config import Platform
|
||||||
@ -13760,10 +13897,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||||||
# - Feishu only honors reply_in_thread when sending a reply, so topic
|
# - Feishu only honors reply_in_thread when sending a reply, so topic
|
||||||
# progress uses the triggering event message as the reply target
|
# progress uses the triggering event message as the reply target
|
||||||
# - Other platforms should use explicit source.thread_id only
|
# - Other platforms should use explicit source.thread_id only
|
||||||
if source.platform == Platform.SLACK:
|
_progress_thread_id = _resolve_progress_thread_id(
|
||||||
_progress_thread_id = source.thread_id or event_message_id
|
source.platform, source.thread_id, event_message_id,
|
||||||
else:
|
)
|
||||||
_progress_thread_id = source.thread_id
|
|
||||||
_progress_metadata = (
|
_progress_metadata = (
|
||||||
self._thread_metadata_for_source(source, event_message_id)
|
self._thread_metadata_for_source(source, event_message_id)
|
||||||
if _progress_thread_id == source.thread_id
|
if _progress_thread_id == source.thread_id
|
||||||
@ -13796,7 +13932,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||||||
|
|
||||||
progress_lines = [] # Accumulated tool lines for the CURRENT editable bubble
|
progress_lines = [] # Accumulated tool lines for the CURRENT editable bubble
|
||||||
progress_msg_id = None # ID of the current progress message to edit
|
progress_msg_id = None # ID of the current progress message to edit
|
||||||
can_edit = True # False once an edit fails (platform doesn't support it)
|
can_edit = progress_grouping != "separate" # "separate" = one message per tool (pre-v0.9 behavior)
|
||||||
_last_edit_ts = 0.0 # Throttle edits to avoid Telegram flood control
|
_last_edit_ts = 0.0 # Throttle edits to avoid Telegram flood control
|
||||||
_PROGRESS_EDIT_INTERVAL = 1.5 # Minimum seconds between edits
|
_PROGRESS_EDIT_INTERVAL = 1.5 # Minimum seconds between edits
|
||||||
|
|
||||||
@ -14563,6 +14699,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||||||
_pdc = getattr(_status_adapter, "_post_delivery_callbacks", None)
|
_pdc = getattr(_status_adapter, "_post_delivery_callbacks", None)
|
||||||
if _pdc is not None:
|
if _pdc is not None:
|
||||||
_pdc[session_key] = _release_bg_review_messages
|
_pdc[session_key] = _release_bg_review_messages
|
||||||
|
# Memory update notifications in chat. Config: display.memory_notifications
|
||||||
|
# off — no chat notification (still logged to stdout)
|
||||||
|
# on — generic "💾 Memory updated" (default)
|
||||||
|
# verbose — content preview: "💾 Memory ➕ Hermes Repo..."
|
||||||
|
_mem_notif = user_config.get("display", {}).get("memory_notifications")
|
||||||
|
if isinstance(_mem_notif, bool):
|
||||||
|
_mem_notif = "on" if _mem_notif else "off"
|
||||||
|
agent.memory_notifications = str(_mem_notif).lower() if _mem_notif else "on"
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Clarify callback: present a clarify prompt and block on a response.
|
# Clarify callback: present a clarify prompt and block on a response.
|
||||||
|
|||||||
@ -394,20 +394,35 @@ class GatewaySlashCommandsMixin:
|
|||||||
|
|
||||||
async def _handle_status_command(self, event: MessageEvent) -> str:
|
async def _handle_status_command(self, event: MessageEvent) -> str:
|
||||||
"""Handle /status command."""
|
"""Handle /status command."""
|
||||||
|
from gateway.run import _AGENT_PENDING_SENTINEL, _load_gateway_config, _resolve_gateway_model
|
||||||
|
|
||||||
source = event.source
|
source = event.source
|
||||||
session_entry = self.session_store.get_or_create_session(source)
|
session_entry = self.session_store.get_or_create_session(source)
|
||||||
|
|
||||||
connected_platforms = [p.value for p in self.adapters.keys()]
|
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
|
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).
|
# Count pending /queue follow-ups (slot + overflow).
|
||||||
adapter = self.adapters.get(source.platform) if source else None
|
adapter = self.adapters.get(source.platform) if source else None
|
||||||
queue_depth = self._queue_depth(session_key, adapter=adapter)
|
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
|
title = None
|
||||||
|
session_row: dict[str, Any] = {}
|
||||||
# Pull token totals from the SQLite session DB rather than the
|
# Pull token totals from the SQLite session DB rather than the
|
||||||
# in-memory SessionStore. The agent's per-turn token deltas are
|
# in-memory SessionStore. The agent's per-turn token deltas are
|
||||||
# persisted into sessions_db (run_agent.py), not into SessionEntry,
|
# persisted into sessions_db (run_agent.py), not into SessionEntry,
|
||||||
@ -422,17 +437,92 @@ class GatewaySlashCommandsMixin:
|
|||||||
title = None
|
title = None
|
||||||
try:
|
try:
|
||||||
row = self._session_db.get_session(session_entry.session_id)
|
row = self._session_db.get_session(session_entry.session_id)
|
||||||
if row:
|
if isinstance(row, dict):
|
||||||
|
session_row = row
|
||||||
db_total_tokens = (
|
db_total_tokens = (
|
||||||
(row.get("input_tokens") or 0)
|
_int_value(row.get("input_tokens"))
|
||||||
+ (row.get("output_tokens") or 0)
|
+ _int_value(row.get("output_tokens"))
|
||||||
+ (row.get("cache_read_tokens") or 0)
|
+ _int_value(row.get("cache_read_tokens"))
|
||||||
+ (row.get("cache_write_tokens") or 0)
|
+ _int_value(row.get("cache_write_tokens"))
|
||||||
+ (row.get("reasoning_tokens") or 0)
|
+ _int_value(row.get("reasoning_tokens"))
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
db_total_tokens = 0
|
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 = [
|
lines = [
|
||||||
t("gateway.status.header"),
|
t("gateway.status.header"),
|
||||||
"",
|
"",
|
||||||
@ -443,6 +533,12 @@ class GatewaySlashCommandsMixin:
|
|||||||
lines.extend([
|
lines.extend([
|
||||||
t("gateway.status.created", timestamp=session_entry.created_at.strftime('%Y-%m-%d %H:%M')),
|
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')),
|
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.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")),
|
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_one", title=title, count=msg_count)
|
||||||
return t("gateway.resume.resumed_many", 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:
|
async def _handle_branch_command(self, event: MessageEvent) -> str:
|
||||||
"""Handle /branch [name] — fork the current session into a new independent copy.
|
"""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)
|
live_cmdline = _read_process_cmdline(existing_pid)
|
||||||
if live_cmdline is not None or not _record_looks_like_gateway(existing):
|
if live_cmdline is not None or not _record_looks_like_gateway(existing):
|
||||||
stale = True
|
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
|
# Check if process is stopped (Ctrl+Z / SIGTSTP) — stopped
|
||||||
# processes still appear alive to _pid_exists but are not
|
# processes still appear alive to _pid_exists but are not
|
||||||
# actually running. Treat them as stale so --replace works.
|
# actually running. Treat them as stale so --replace works.
|
||||||
|
|||||||
@ -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_HOST = "127.0.0.1"
|
||||||
XAI_OAUTH_REDIRECT_PORT = 56121
|
XAI_OAUTH_REDIRECT_PORT = 56121
|
||||||
XAI_OAUTH_REDIRECT_PATH = "/callback"
|
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_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"
|
||||||
QWEN_OAUTH_TOKEN_URL = "https://chat.qwen.ai/api/v1/oauth2/token"
|
QWEN_OAUTH_TOKEN_URL = "https://chat.qwen.ai/api/v1/oauth2/token"
|
||||||
QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
||||||
@ -1079,8 +1084,13 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]:
|
|||||||
return {"version": AUTH_STORE_VERSION, "providers": {}}
|
return {"version": AUTH_STORE_VERSION, "providers": {}}
|
||||||
|
|
||||||
|
|
||||||
def _save_auth_store(auth_store: Dict[str, Any]) -> Path:
|
def _save_auth_store(auth_store: Dict[str, Any], target_path: Optional[Path] = None) -> Path:
|
||||||
auth_file = _auth_file_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)
|
auth_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
# Tighten parent dir to 0o700 so siblings can't traverse to creds.
|
# Tighten parent dir to 0o700 so siblings can't traverse to creds.
|
||||||
# No-op on Windows (POSIX mode bits not enforced); ignore failures.
|
# No-op on Windows (POSIX mode bits not enforced); ignore failures.
|
||||||
@ -3796,6 +3806,26 @@ def resolve_codex_runtime_credentials(
|
|||||||
"last_refresh": None,
|
"last_refresh": None,
|
||||||
"auth_mode": "chatgpt",
|
"auth_mode": "chatgpt",
|
||||||
}
|
}
|
||||||
|
pool_rate_limit = _codex_pool_rate_limit_status()
|
||||||
|
if pool_rate_limit:
|
||||||
|
reset_at = pool_rate_limit.get("reset_at")
|
||||||
|
if isinstance(reset_at, (int, float)) and reset_at > time.time():
|
||||||
|
remaining = int(reset_at - time.time())
|
||||||
|
message = (
|
||||||
|
f"Codex provider quota exhausted (429); retry after {remaining}s. "
|
||||||
|
"Credentials are still valid."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
"Codex provider quota exhausted (429). Credentials are still valid; "
|
||||||
|
"retry after the usage limit resets."
|
||||||
|
)
|
||||||
|
raise AuthError(
|
||||||
|
message,
|
||||||
|
provider="openai-codex",
|
||||||
|
code=CODEX_RATE_LIMITED_CODE,
|
||||||
|
relogin_required=False,
|
||||||
|
)
|
||||||
if read_error is not None:
|
if read_error is not None:
|
||||||
raise read_error
|
raise read_error
|
||||||
raise AuthError(
|
raise AuthError(
|
||||||
@ -3842,6 +3872,79 @@ def resolve_codex_runtime_credentials(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _codex_pool_rate_limit_status() -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return metadata for a pool-only Codex credential in quota cooldown."""
|
||||||
|
def _parse_reset_at(value: Any) -> Optional[float]:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
numeric = float(value)
|
||||||
|
if numeric <= 0:
|
||||||
|
return None
|
||||||
|
return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric
|
||||||
|
if isinstance(value, str):
|
||||||
|
raw = value.strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
numeric = float(raw)
|
||||||
|
except ValueError:
|
||||||
|
numeric = None
|
||||||
|
if numeric is not None:
|
||||||
|
return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _auth_store_lock():
|
||||||
|
auth_store = _load_auth_store()
|
||||||
|
pool = auth_store.get("credential_pool")
|
||||||
|
if not isinstance(pool, dict):
|
||||||
|
return None
|
||||||
|
entries = pool.get("openai-codex")
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
return None
|
||||||
|
now = time.time()
|
||||||
|
for entry in entries:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
token = entry.get("access_token")
|
||||||
|
if not isinstance(token, str) or not token.strip():
|
||||||
|
continue
|
||||||
|
if entry.get("last_status") != "exhausted":
|
||||||
|
continue
|
||||||
|
code = entry.get("last_error_code")
|
||||||
|
reason = str(entry.get("last_error_reason") or "").lower()
|
||||||
|
message = str(entry.get("last_error_message") or "").lower()
|
||||||
|
is_rate_limited = (
|
||||||
|
code == 429
|
||||||
|
or "rate_limit" in reason
|
||||||
|
or "usage_limit" in reason
|
||||||
|
or "quota" in reason
|
||||||
|
or "rate limit" in message
|
||||||
|
or "usage limit" in message
|
||||||
|
or "quota" in message
|
||||||
|
)
|
||||||
|
if not is_rate_limited:
|
||||||
|
continue
|
||||||
|
reset_at = _parse_reset_at(entry.get("last_error_reset_at"))
|
||||||
|
if reset_at is not None and reset_at <= now:
|
||||||
|
continue
|
||||||
|
return {
|
||||||
|
"label": entry.get("label"),
|
||||||
|
"last_refresh": entry.get("last_refresh"),
|
||||||
|
"reset_at": reset_at,
|
||||||
|
"reason": entry.get("last_error_reason"),
|
||||||
|
"message": entry.get("last_error_message"),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Codex pool rate-limit lookup failed", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _pool_codex_access_token() -> str:
|
def _pool_codex_access_token() -> str:
|
||||||
"""Return the most-recent usable access_token from the openai-codex pool.
|
"""Return the most-recent usable access_token from the openai-codex pool.
|
||||||
|
|
||||||
@ -3886,13 +3989,64 @@ def _pool_codex_access_token() -> str:
|
|||||||
# xAI Grok OAuth — tokens stored in ~/.hermes/auth.json
|
# 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]:
|
def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]:
|
||||||
if _lock:
|
if _lock:
|
||||||
with _auth_store_lock():
|
with _auth_store_lock():
|
||||||
auth_store = _load_auth_store()
|
auth_store = _load_auth_store()
|
||||||
else:
|
else:
|
||||||
auth_store = _load_auth_store()
|
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:
|
if not state:
|
||||||
raise AuthError(
|
raise AuthError(
|
||||||
"No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok / Premium+) in `hermes model`.",
|
"No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok / Premium+) in `hermes model`.",
|
||||||
@ -3932,6 +4086,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(
|
def _save_xai_oauth_tokens(
|
||||||
tokens: Dict[str, Any],
|
tokens: Dict[str, Any],
|
||||||
*,
|
*,
|
||||||
@ -3943,6 +4153,11 @@ def _save_xai_oauth_tokens(
|
|||||||
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
with _auth_store_lock():
|
with _auth_store_lock():
|
||||||
auth_store = _load_auth_store()
|
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 = _load_provider_state(auth_store, "xai-oauth") or {}
|
||||||
state["tokens"] = tokens
|
state["tokens"] = tokens
|
||||||
state["last_refresh"] = last_refresh
|
state["last_refresh"] = last_refresh
|
||||||
@ -3953,6 +4168,8 @@ def _save_xai_oauth_tokens(
|
|||||||
state["redirect_uri"] = redirect_uri
|
state["redirect_uri"] = redirect_uri
|
||||||
_save_provider_state(auth_store, "xai-oauth", state)
|
_save_provider_state(auth_store, "xai-oauth", state)
|
||||||
_save_auth_store(auth_store)
|
_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:
|
def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> bool:
|
||||||
@ -5639,18 +5856,24 @@ def _snapshot_nous_pool_status() -> Dict[str, Any]:
|
|||||||
# subscription-feature checks) call it many times per render — `hermes tools` → "All Platforms"
|
# 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
|
# 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
|
# 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_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:
|
try:
|
||||||
return _auth_file_path().stat().st_mtime
|
auth_file_key = str(auth_file.resolve(strict=False))
|
||||||
except FileNotFoundError:
|
|
||||||
return None
|
|
||||||
except Exception:
|
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:
|
def invalidate_nous_auth_status_cache() -> None:
|
||||||
@ -5682,18 +5905,19 @@ def get_nous_auth_status() -> Dict[str, Any]:
|
|||||||
"""
|
"""
|
||||||
global _nous_auth_status_cache
|
global _nous_auth_status_cache
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
mtime = _auth_file_mtime()
|
auth_file_key, mtime = _auth_file_cache_key()
|
||||||
cached = _nous_auth_status_cache
|
cached = _nous_auth_status_cache
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
cached_at, cached_mtime, cached_status = cached
|
cached_at, cached_auth_file_key, cached_mtime, cached_status = cached
|
||||||
if (
|
if (
|
||||||
cached_mtime == mtime
|
cached_auth_file_key == auth_file_key
|
||||||
|
and cached_mtime == mtime
|
||||||
and (now - cached_at) < _NOUS_AUTH_STATUS_CACHE_TTL
|
and (now - cached_at) < _NOUS_AUTH_STATUS_CACHE_TTL
|
||||||
):
|
):
|
||||||
return dict(cached_status)
|
return dict(cached_status)
|
||||||
|
|
||||||
status = _compute_nous_auth_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
|
return status
|
||||||
|
|
||||||
|
|
||||||
@ -5776,6 +6000,22 @@ def get_codex_auth_status() -> Dict[str, Any]:
|
|||||||
"source": f"pool:{getattr(entry, 'label', 'unknown')}",
|
"source": f"pool:{getattr(entry, 'label', 'unknown')}",
|
||||||
"api_key": api_key,
|
"api_key": api_key,
|
||||||
}
|
}
|
||||||
|
rate_limit = _codex_pool_rate_limit_status()
|
||||||
|
if rate_limit:
|
||||||
|
return {
|
||||||
|
"logged_in": True,
|
||||||
|
"auth_store": str(_auth_file_path()),
|
||||||
|
"last_refresh": rate_limit.get("last_refresh"),
|
||||||
|
"auth_mode": "chatgpt",
|
||||||
|
"source": f"pool:{rate_limit.get('label') or 'unknown'}",
|
||||||
|
"rate_limited": True,
|
||||||
|
"error_code": CODEX_RATE_LIMITED_CODE,
|
||||||
|
"error": (
|
||||||
|
rate_limit.get("message")
|
||||||
|
or "Codex provider quota exhausted; retry after the usage limit resets."
|
||||||
|
),
|
||||||
|
"reset_at": rate_limit.get("reset_at"),
|
||||||
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@ -510,6 +510,7 @@ _QUICK_STATE_FILES = (
|
|||||||
"cron/jobs.json",
|
"cron/jobs.json",
|
||||||
"gateway_state.json",
|
"gateway_state.json",
|
||||||
"channel_directory.json",
|
"channel_directory.json",
|
||||||
|
"channel_aliases.json",
|
||||||
"processes.json",
|
"processes.json",
|
||||||
# Pairing stores (generic + per-platform JSONs outside state.db)
|
# Pairing stores (generic + per-platform JSONs outside state.db)
|
||||||
"pairing", # legacy location (gateway/pairing.py)
|
"pairing", # legacy location (gateway/pairing.py)
|
||||||
|
|||||||
@ -225,7 +225,8 @@ class CLICommandsMixin:
|
|||||||
print(" Usage: /snapshot [list|create [label]|restore <id>|prune [N]]")
|
print(" Usage: /snapshot [list|create [label]|restore <id>|prune [N]]")
|
||||||
|
|
||||||
def _handle_stop_command(self):
|
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)
|
Inspired by OpenAI Codex's separation of interrupt (stop current turn)
|
||||||
from /stop (clean up background processes). See openai/codex#14602.
|
from /stop (clean up background processes). See openai/codex#14602.
|
||||||
@ -235,13 +236,26 @@ class CLICommandsMixin:
|
|||||||
processes = process_registry.list_sessions()
|
processes = process_registry.list_sessions()
|
||||||
running = [p for p in processes if p.get("status") == "running"]
|
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.")
|
print(" No running background processes.")
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f" Stopping {len(running)} background process(es)...")
|
if running:
|
||||||
killed = process_registry.kill_all()
|
print(f" Stopping {len(running)} background process(es)...")
|
||||||
print(f" ✅ Stopped {killed} 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):
|
def _handle_agents_command(self):
|
||||||
"""Handle /agents — show background processes and agent status."""
|
"""Handle /agents — show background processes and agent status."""
|
||||||
@ -261,6 +275,22 @@ class CLICommandsMixin:
|
|||||||
if finished:
|
if finished:
|
||||||
_cprint(f" Recently finished: {len(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)
|
agent_running = getattr(self, "_agent_running", False)
|
||||||
_cprint(f" Agent: {'running' if agent_running else 'idle'}")
|
_cprint(f" Agent: {'running' if agent_running else 'idle'}")
|
||||||
|
|
||||||
|
|||||||
@ -109,7 +109,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
|||||||
args_hint="[text | pause | resume | clear | status]"),
|
args_hint="[text | pause | resume | clear | status]"),
|
||||||
CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session",
|
CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session",
|
||||||
args_hint="[text | remove N | clear]"),
|
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("whoami", "Show your slash command access (admin / user)", "Info"),
|
||||||
CommandDef("profile", "Show active profile name and home directory", "Info"),
|
CommandDef("profile", "Show active profile name and home directory", "Info"),
|
||||||
CommandDef("sethome", "Set this chat as the home channel", "Session",
|
CommandDef("sethome", "Set this chat as the home channel", "Session",
|
||||||
@ -1053,7 +1053,8 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg")
|
|||||||
# the telegram-parity test reads it so an entry here is a deliberate
|
# the telegram-parity test reads it so an entry here is a deliberate
|
||||||
# "Slack-via-/hermes" decision, not a silent clamp.
|
# "Slack-via-/hermes" decision, not a silent clamp.
|
||||||
# - credits: the billing/top-up surface; reached via /hermes credits on Slack.
|
# - credits: the billing/top-up surface; reached via /hermes credits on Slack.
|
||||||
_SLACK_VIA_HERMES_ONLY = frozenset({"credits"})
|
# - debug: the log/report upload surface; reached via /hermes debug on Slack.
|
||||||
|
_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "debug"})
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_slack_name(raw: str) -> str:
|
def _sanitize_slack_name(raw: str) -> str:
|
||||||
|
|||||||
@ -1428,6 +1428,12 @@ DEFAULT_CONFIG = {
|
|||||||
"tui_agents_nudge": True,
|
"tui_agents_nudge": True,
|
||||||
"bell_on_complete": False,
|
"bell_on_complete": False,
|
||||||
"show_reasoning": False,
|
"show_reasoning": False,
|
||||||
|
# Background self-improvement review notifications surfaced in chat.
|
||||||
|
# "off" — no chat notification (the review still runs and writes)
|
||||||
|
# "on" — generic "💾 Memory updated" line (default)
|
||||||
|
# "verbose" — include a compact content preview of what changed
|
||||||
|
# Per-platform overrides via display.platforms.<platform>.memory_notifications.
|
||||||
|
"memory_notifications": "on",
|
||||||
"streaming": False,
|
"streaming": False,
|
||||||
"timestamps": False, # Show [HH:MM] on user and assistant labels
|
"timestamps": False, # Show [HH:MM] on user and assistant labels
|
||||||
"final_response_markdown": "strip", # render | strip | raw
|
"final_response_markdown": "strip", # render | strip | raw
|
||||||
@ -1479,6 +1485,12 @@ DEFAULT_CONFIG = {
|
|||||||
"tool_progress_command": False, # Enable /verbose command in messaging gateway
|
"tool_progress_command": False, # Enable /verbose command in messaging gateway
|
||||||
"tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead
|
"tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead
|
||||||
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
|
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
|
||||||
|
# How gateway tool-progress is grouped on platforms that support message
|
||||||
|
# editing: "accumulate" (default) edits one bubble in place; "separate"
|
||||||
|
# sends one message per tool (the pre-v0.9 behavior, noisier). Only
|
||||||
|
# applies where tool_progress is already enabled. Per-platform override
|
||||||
|
# via display.platforms.<platform>.tool_progress_grouping.
|
||||||
|
"tool_progress_grouping": "accumulate",
|
||||||
# Auto-delete system-notice replies (e.g. "✨ New session started!",
|
# Auto-delete system-notice replies (e.g. "✨ New session started!",
|
||||||
# "♻ Restarting gateway…", "⚡ Stopped…") after N seconds on platforms
|
# "♻ Restarting gateway…", "⚡ Stopped…") after N seconds on platforms
|
||||||
# that support message deletion (currently Telegram; other platforms
|
# that support message deletion (currently Telegram; other platforms
|
||||||
@ -1775,6 +1787,7 @@ DEFAULT_CONFIG = {
|
|||||||
"reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium",
|
"reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium",
|
||||||
# "low", "minimal", "none" (empty = inherit parent's level)
|
# "low", "minimal", "none" (empty = inherit parent's level)
|
||||||
"max_concurrent_children": 3, # max parallel children per batch; floor of 1 enforced, no ceiling
|
"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
|
# Orchestrator role controls (see tools/delegate_tool.py:_get_max_spawn_depth
|
||||||
# and _get_orchestrator_enabled). Floored at 1, no upper ceiling —
|
# and _get_orchestrator_enabled). Floored at 1, no upper ceiling —
|
||||||
# raise deliberately, each level multiplies API cost.
|
# raise deliberately, each level multiplies API cost.
|
||||||
@ -1990,7 +2003,7 @@ DEFAULT_CONFIG = {
|
|||||||
"channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group)
|
"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)
|
"allowed_chats": "", # If set, bot ONLY responds in these group/supergroup chat IDs (whitelist)
|
||||||
"extra": {
|
"extra": {
|
||||||
"rich_messages": False, # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
|
"rich_messages": True, # Bot API 10.1 rich messages (tables/task lists/details/math) render natively; set False to force legacy MarkdownV2
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -207,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)
|
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:
|
def _strip_container_argv_prefix(argv: Sequence[str]) -> list[str]:
|
||||||
"""Return True for Docker commands equivalent to `gateway run`."""
|
"""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)
|
args = list(argv)
|
||||||
if args and Path(args[0]).name == "init":
|
if args and Path(args[0]).name == "init":
|
||||||
args = args[1:]
|
args = args[1:]
|
||||||
@ -216,11 +223,38 @@ def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool:
|
|||||||
args = args[1:]
|
args = args[1:]
|
||||||
if args and Path(args[0]).name == "hermes":
|
if args and Path(args[0]).name == "hermes":
|
||||||
args = args[1:]
|
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:
|
if "--no-supervise" in args:
|
||||||
return False
|
return False
|
||||||
return len(args) >= 2 and args[0] == "gateway" and args[1] == "run"
|
return len(args) >= 2 and args[0] == "gateway" and args[1] == "run"
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def _read_desired_state(profile_dir: Path) -> str | None:
|
||||||
"""Read the persisted gateway desired state for reconciliation.
|
"""Read the persisted gateway desired state for reconciliation.
|
||||||
|
|
||||||
@ -393,6 +427,22 @@ _LOG_ROTATE_BYTES = 256 * 1024
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
"""Entry point invoked from /etc/cont-init.d/02-reconcile-profiles."""
|
"""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"))
|
hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data"))
|
||||||
scandir = Path(os.environ.get("S6_PROFILE_GATEWAY_SCANDIR", "/run/service"))
|
scandir = Path(os.environ.get("S6_PROFILE_GATEWAY_SCANDIR", "/run/service"))
|
||||||
actions = reconcile_profile_gateways(
|
actions = reconcile_profile_gateways(
|
||||||
|
|||||||
@ -796,6 +796,7 @@ def run_doctor(args):
|
|||||||
"huggingface",
|
"huggingface",
|
||||||
"lmstudio",
|
"lmstudio",
|
||||||
"nous",
|
"nous",
|
||||||
|
"nvidia",
|
||||||
}
|
}
|
||||||
provider_accepts_vendor_slug = (
|
provider_accepts_vendor_slug = (
|
||||||
provider_policy_id in providers_accepting_vendor_slugs
|
provider_policy_id in providers_accepting_vendor_slugs
|
||||||
|
|||||||
@ -252,9 +252,24 @@ def run_dump(args):
|
|||||||
except Exception:
|
except Exception:
|
||||||
profile = "(default)"
|
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", {})
|
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
|
# OpenAI SDK version
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -157,6 +157,36 @@ def build_models_payload(
|
|||||||
max_models=max_models,
|
max_models=max_models,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- 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:
|
if include_unconfigured:
|
||||||
rows = list(rows) + _append_unconfigured_rows(rows, ctx)
|
rows = list(rows) + _append_unconfigured_rows(rows, ctx)
|
||||||
if picker_hints:
|
if picker_hints:
|
||||||
|
|||||||
@ -8647,10 +8647,16 @@ def _discard_lockfile_churn(git_cmd, repo_root):
|
|||||||
)
|
)
|
||||||
if diff.returncode != 0:
|
if diff.returncode != 0:
|
||||||
return
|
return
|
||||||
|
dirty_package_dirs = {
|
||||||
|
Path(line.strip()).parent
|
||||||
|
for line in diff.stdout.splitlines()
|
||||||
|
if line.strip().endswith("package.json")
|
||||||
|
}
|
||||||
dirty = [
|
dirty = [
|
||||||
line.strip()
|
line.strip()
|
||||||
for line in diff.stdout.splitlines()
|
for line in diff.stdout.splitlines()
|
||||||
if line.strip().endswith("package-lock.json")
|
if line.strip().endswith("package-lock.json")
|
||||||
|
and Path(line.strip()).parent not in dirty_package_dirs
|
||||||
]
|
]
|
||||||
if not dirty:
|
if not dirty:
|
||||||
return
|
return
|
||||||
|
|||||||
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)
|
||||||
@ -247,6 +247,19 @@ def _has_valid_session_token(request: Request) -> bool:
|
|||||||
return hmac.compare_digest(auth.encode(), expected.encode())
|
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:
|
def _require_token(request: Request) -> None:
|
||||||
"""Authorize a sensitive endpoint, raising 401 if the caller isn't allowed.
|
"""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)
|
return await call_next(request)
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
if path.startswith("/api/") and path not in _PUBLIC_API_PATHS:
|
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(
|
return JSONResponse(
|
||||||
status_code=401,
|
status_code=401,
|
||||||
content={"detail": "Unauthorized"},
|
content={"detail": "Unauthorized"},
|
||||||
@ -1224,6 +1237,22 @@ def _default_hermes_root_is_opt_data() -> bool:
|
|||||||
return root == _HOSTED_MANAGED_FILES_ROOT
|
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:
|
def _managed_files_policy(request: Request, *, create_root: bool = True) -> ManagedFilesPolicy:
|
||||||
raw_forced_root = os.environ.get(_MANAGED_FILES_ROOT_ENV, "").strip()
|
raw_forced_root = os.environ.get(_MANAGED_FILES_ROOT_ENV, "").strip()
|
||||||
if raw_forced_root:
|
if raw_forced_root:
|
||||||
@ -1393,6 +1422,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")
|
@app.post("/api/files/upload")
|
||||||
async def upload_managed_file(payload: ManagedFileUpload, request: Request):
|
async def upload_managed_file(payload: ManagedFileUpload, request: Request):
|
||||||
policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True)
|
policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True)
|
||||||
@ -1654,6 +1717,7 @@ async def get_status():
|
|||||||
"release_date": __release_date__,
|
"release_date": __release_date__,
|
||||||
"config_version": current_ver,
|
"config_version": current_ver,
|
||||||
"latest_config_version": latest_ver,
|
"latest_config_version": latest_ver,
|
||||||
|
"can_update_hermes": not _dashboard_local_update_managed_externally(),
|
||||||
"gateway_running": gateway_running,
|
"gateway_running": gateway_running,
|
||||||
"gateway_state": gateway_state,
|
"gateway_state": gateway_state,
|
||||||
"gateway_platforms": gateway_platforms,
|
"gateway_platforms": gateway_platforms,
|
||||||
@ -2165,6 +2229,22 @@ async def restart_gateway():
|
|||||||
@app.post("/api/hermes/update")
|
@app.post("/api/hermes/update")
|
||||||
async def update_hermes():
|
async def update_hermes():
|
||||||
"""Kick off ``hermes update`` in the background."""
|
"""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)
|
install_method = detect_install_method(PROJECT_ROOT)
|
||||||
if install_method == "docker":
|
if install_method == "docker":
|
||||||
message = format_docker_update_message()
|
message = format_docker_update_message()
|
||||||
@ -2264,6 +2344,20 @@ async def check_hermes_update(force: bool = False):
|
|||||||
desktop's remote update overlay renders this as "what's
|
desktop's remote update overlay renders this as "what's
|
||||||
changed". Additive: existing consumers ignore it.
|
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)
|
install_method = detect_install_method(PROJECT_ROOT)
|
||||||
update_command = recommended_update_command_for_method(install_method)
|
update_command = recommended_update_command_for_method(install_method)
|
||||||
|
|
||||||
@ -5144,7 +5238,7 @@ def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str,
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/providers/oauth")
|
@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.
|
"""Enumerate every OAuth-capable LLM provider with current status.
|
||||||
|
|
||||||
Response shape (per provider):
|
Response shape (per provider):
|
||||||
@ -5161,83 +5255,89 @@ async def list_oauth_providers():
|
|||||||
expires_at ISO timestamp string or null
|
expires_at ISO timestamp string or null
|
||||||
has_refresh_token bool
|
has_refresh_token bool
|
||||||
"""
|
"""
|
||||||
providers = []
|
with _profile_scope(profile):
|
||||||
for p in _OAUTH_PROVIDER_CATALOG:
|
providers = []
|
||||||
status = _resolve_provider_status(p["id"], p.get("status_fn"))
|
for p in _OAUTH_PROVIDER_CATALOG:
|
||||||
disconnect_hint = _oauth_provider_disconnect_hint(p, status)
|
status = _resolve_provider_status(p["id"], p.get("status_fn"))
|
||||||
providers.append({
|
disconnect_hint = _oauth_provider_disconnect_hint(p, status)
|
||||||
"id": p["id"],
|
providers.append({
|
||||||
"name": p["name"],
|
"id": p["id"],
|
||||||
"flow": p["flow"],
|
"name": p["name"],
|
||||||
"cli_command": p["cli_command"],
|
"flow": p["flow"],
|
||||||
"docs_url": p["docs_url"],
|
"cli_command": p["cli_command"],
|
||||||
"disconnect_hint": disconnect_hint,
|
"docs_url": p["docs_url"],
|
||||||
"disconnectable": disconnect_hint is None,
|
"disconnect_hint": disconnect_hint,
|
||||||
"status": status,
|
"disconnectable": disconnect_hint is None,
|
||||||
})
|
"status": status,
|
||||||
return {"providers": providers}
|
})
|
||||||
|
return {"providers": providers}
|
||||||
|
|
||||||
|
|
||||||
@app.delete("/api/providers/oauth/{provider_id}")
|
@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)."""
|
"""Disconnect an OAuth provider. Token-protected (matches /env/reveal)."""
|
||||||
_require_token(request)
|
_require_token(request)
|
||||||
|
|
||||||
catalog_by_id = {p["id"]: p for p in _OAUTH_PROVIDER_CATALOG}
|
with _profile_scope(profile):
|
||||||
provider = catalog_by_id.get(provider_id)
|
catalog_by_id = {p["id"]: p for p in _OAUTH_PROVIDER_CATALOG}
|
||||||
if provider is None:
|
provider = catalog_by_id.get(provider_id)
|
||||||
raise HTTPException(
|
if provider is None:
|
||||||
status_code=400,
|
raise HTTPException(
|
||||||
detail=f"Unknown provider: {provider_id}. "
|
status_code=400,
|
||||||
f"Available: {', '.join(sorted(catalog_by_id))}",
|
detail=f"Unknown provider: {provider_id}. "
|
||||||
)
|
f"Available: {', '.join(sorted(catalog_by_id))}",
|
||||||
|
)
|
||||||
|
|
||||||
disconnect_hint = _oauth_provider_disconnect_hint(provider, {})
|
disconnect_hint = _oauth_provider_disconnect_hint(provider, {})
|
||||||
if disconnect_hint:
|
if disconnect_hint:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}",
|
detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}",
|
||||||
)
|
)
|
||||||
|
|
||||||
status = _resolve_provider_status(provider_id, provider.get("status_fn"))
|
status = _resolve_provider_status(provider_id, provider.get("status_fn"))
|
||||||
disconnect_hint = _oauth_provider_disconnect_hint(provider, status)
|
disconnect_hint = _oauth_provider_disconnect_hint(provider, status)
|
||||||
if disconnect_hint:
|
if disconnect_hint:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}",
|
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:
|
try:
|
||||||
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
|
from hermes_cli.auth import clear_provider_auth, invalidate_nous_auth_status_cache
|
||||||
if _HERMES_OAUTH_FILE.exists():
|
cleared = clear_provider_auth(provider_id)
|
||||||
_HERMES_OAUTH_FILE.unlink()
|
if provider_id == "nous":
|
||||||
cleared = True
|
invalidate_nous_auth_status_cache()
|
||||||
except Exception:
|
_log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared)
|
||||||
pass
|
return {"ok": bool(cleared), "provider": provider_id}
|
||||||
# Also clear the credential pool entry if present.
|
except Exception as e:
|
||||||
try:
|
_log.exception("disconnect %s failed", provider_id)
|
||||||
from hermes_cli.auth import clear_provider_auth
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
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))
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -5319,13 +5419,32 @@ def _gc_oauth_sessions() -> None:
|
|||||||
_oauth_sessions.pop(sid, 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)."""
|
"""Create + register a new OAuth session, return (session_id, session_dict)."""
|
||||||
sid = secrets.token_urlsafe(16)
|
sid = secrets.token_urlsafe(16)
|
||||||
|
profile_name = _oauth_profile_name(profile)
|
||||||
sess = {
|
sess = {
|
||||||
"session_id": sid,
|
"session_id": sid,
|
||||||
"provider": provider_id,
|
"provider": provider_id,
|
||||||
"flow": flow,
|
"flow": flow,
|
||||||
|
"profile": profile_name,
|
||||||
"created_at": time.time(),
|
"created_at": time.time(),
|
||||||
"status": "pending", # pending | approved | denied | expired | error
|
"status": "pending", # pending | approved | denied | expired | error
|
||||||
"error_message": None,
|
"error_message": None,
|
||||||
@ -5335,6 +5454,17 @@ def _new_oauth_session(provider_id: str, flow: str) -> tuple[str, Dict[str, Any]
|
|||||||
return sid, sess
|
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:
|
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.
|
"""Persist Anthropic PKCE creds to both Hermes file AND credential pool.
|
||||||
|
|
||||||
@ -5402,12 +5532,12 @@ def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_a
|
|||||||
_log.warning("anthropic pool add (dashboard) failed: %s", e)
|
_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."""
|
"""Begin PKCE flow. Returns the auth URL the UI should open."""
|
||||||
if not _ANTHROPIC_OAUTH_AVAILABLE:
|
if not _ANTHROPIC_OAUTH_AVAILABLE:
|
||||||
raise HTTPException(status_code=501, detail="Anthropic OAuth not available (missing adapter)")
|
raise HTTPException(status_code=501, detail="Anthropic OAuth not available (missing adapter)")
|
||||||
verifier, challenge = _generate_pkce_pair()
|
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["verifier"] = verifier
|
||||||
sess["state"] = verifier # Anthropic round-trips verifier as state
|
sess["state"] = verifier # Anthropic round-trips verifier as state
|
||||||
params = {
|
params = {
|
||||||
@ -5429,7 +5559,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."""
|
"""Exchange authorization code for tokens. Persists on success."""
|
||||||
with _oauth_sessions_lock:
|
with _oauth_sessions_lock:
|
||||||
sess = _oauth_sessions.get(session_id)
|
sess = _oauth_sessions.get(session_id)
|
||||||
@ -5483,7 +5617,8 @@ def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]:
|
|||||||
|
|
||||||
expires_at_ms = int(time.time() * 1000) + (expires_in * 1000)
|
expires_at_ms = int(time.time() * 1000) + (expires_in * 1000)
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
with _oauth_sessions_lock:
|
with _oauth_sessions_lock:
|
||||||
sess["status"] = "error"
|
sess["status"] = "error"
|
||||||
@ -5495,7 +5630,10 @@ def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]:
|
|||||||
return {"ok": True, "status": "approved"}
|
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).
|
"""Initiate a device-code flow (Nous, OpenAI Codex, or MiniMax).
|
||||||
|
|
||||||
Calls the provider's device-auth endpoint via the existing CLI helpers,
|
Calls the provider's device-auth endpoint via the existing CLI helpers,
|
||||||
@ -5535,7 +5673,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(
|
device_data, effective_scope = await asyncio.get_running_loop().run_in_executor(
|
||||||
None, _do_nous_device_request
|
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["device_code"] = str(device_data["device_code"])
|
||||||
sess["interval"] = int(device_data["interval"])
|
sess["interval"] = int(device_data["interval"])
|
||||||
sess["expires_at"] = time.time() + int(device_data["expires_in"])
|
sess["expires_at"] = time.time() + int(device_data["expires_in"])
|
||||||
@ -5556,7 +5694,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
|
|||||||
|
|
||||||
if provider_id == "openai-codex":
|
if provider_id == "openai-codex":
|
||||||
# Codex uses fixed OpenAI device-auth endpoints; reuse the helper.
|
# 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.
|
# Use the helper but in a thread because it polls inline.
|
||||||
# We can't extract just the start step without refactoring auth.py,
|
# 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 +
|
# so we run the full helper in a worker and proxy the user_code +
|
||||||
@ -5623,7 +5761,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
|
|||||||
device_data = await asyncio.get_event_loop().run_in_executor(
|
device_data = await asyncio.get_event_loop().run_in_executor(
|
||||||
None, _do_minimax_request
|
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
|
# The CLI flow names this `interval_ms` because MiniMax's
|
||||||
# `interval` field is in milliseconds (defensive default 2000ms
|
# `interval` field is in milliseconds (defensive default 2000ms
|
||||||
# in _minimax_poll_token).
|
# in _minimax_poll_token).
|
||||||
@ -5677,7 +5815,7 @@ async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
|
|||||||
_XAI_LOOPBACK_TIMEOUT_SECONDS = 300.0
|
_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.
|
"""Begin the xAI loopback PKCE flow.
|
||||||
|
|
||||||
Binds the local callback server, builds the authorize URL, and spawns a
|
Binds the local callback server, builds the authorize URL, and spawns a
|
||||||
@ -5716,7 +5854,7 @@ def _start_xai_loopback_flow() -> Dict[str, Any]:
|
|||||||
pass
|
pass
|
||||||
raise
|
raise
|
||||||
|
|
||||||
sid, sess = _new_oauth_session("xai-oauth", "loopback")
|
sid, sess = _new_oauth_session("xai-oauth", "loopback", profile=profile)
|
||||||
sess["server"] = server
|
sess["server"] = server
|
||||||
sess["thread"] = thread
|
sess["thread"] = thread
|
||||||
sess["callback_result"] = callback_result
|
sess["callback_result"] = callback_result
|
||||||
@ -5819,13 +5957,14 @@ def _xai_loopback_worker(session_id: str) -> None:
|
|||||||
}
|
}
|
||||||
if _cancelled():
|
if _cancelled():
|
||||||
return
|
return
|
||||||
hauth._save_xai_oauth_tokens(
|
with _profile_scope(_oauth_session_profile(session_id)):
|
||||||
tokens,
|
hauth._save_xai_oauth_tokens(
|
||||||
discovery=sess.get("discovery"),
|
tokens,
|
||||||
redirect_uri=sess["redirect_uri"],
|
discovery=sess.get("discovery"),
|
||||||
last_refresh=last_refresh,
|
redirect_uri=sess["redirect_uri"],
|
||||||
)
|
last_refresh=last_refresh,
|
||||||
_add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh)
|
)
|
||||||
|
_add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_fail(f"xAI token exchange failed: {exc}")
|
_fail(f"xAI token exchange failed: {exc}")
|
||||||
return
|
return
|
||||||
@ -5928,13 +6067,14 @@ def _nous_poller(session_id: str) -> None:
|
|||||||
),
|
),
|
||||||
"expires_in": token_ttl,
|
"expires_in": token_ttl,
|
||||||
}
|
}
|
||||||
full_state = refresh_nous_oauth_from_state(
|
with _profile_scope(_oauth_session_profile(session_id)):
|
||||||
auth_state,
|
full_state = refresh_nous_oauth_from_state(
|
||||||
timeout_seconds=15.0,
|
auth_state,
|
||||||
force_refresh=False,
|
timeout_seconds=15.0,
|
||||||
)
|
force_refresh=False,
|
||||||
from hermes_cli.auth import persist_nous_credentials
|
)
|
||||||
persist_nous_credentials(full_state)
|
from hermes_cli.auth import persist_nous_credentials
|
||||||
|
persist_nous_credentials(full_state)
|
||||||
with _oauth_sessions_lock:
|
with _oauth_sessions_lock:
|
||||||
sess["status"] = "approved"
|
sess["status"] = "approved"
|
||||||
_log.info("oauth/device: nous login completed (session=%s)", session_id)
|
_log.info("oauth/device: nous login completed (session=%s)", session_id)
|
||||||
@ -6017,7 +6157,8 @@ def _minimax_poller(session_id: str) -> None:
|
|||||||
).isoformat(),
|
).isoformat(),
|
||||||
"expires_in": expires_in_s,
|
"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:
|
with _oauth_sessions_lock:
|
||||||
sess["status"] = "approved"
|
sess["status"] = "approved"
|
||||||
_log.info("oauth/device: minimax login completed (session=%s)", session_id)
|
_log.info("oauth/device: minimax login completed (session=%s)", session_id)
|
||||||
@ -6130,10 +6271,11 @@ def _codex_full_login_worker(session_id: str) -> None:
|
|||||||
|
|
||||||
from hermes_cli.auth import _save_codex_tokens
|
from hermes_cli.auth import _save_codex_tokens
|
||||||
|
|
||||||
_save_codex_tokens({
|
with _profile_scope(_oauth_session_profile(session_id)):
|
||||||
"access_token": access_token,
|
_save_codex_tokens({
|
||||||
"refresh_token": refresh_token,
|
"access_token": access_token,
|
||||||
})
|
"refresh_token": refresh_token,
|
||||||
|
})
|
||||||
with _oauth_sessions_lock:
|
with _oauth_sessions_lock:
|
||||||
sess["status"] = "approved"
|
sess["status"] = "approved"
|
||||||
_log.info("oauth/device: openai-codex login completed (session=%s)", session_id)
|
_log.info("oauth/device: openai-codex login completed (session=%s)", session_id)
|
||||||
@ -6147,10 +6289,15 @@ def _codex_full_login_worker(session_id: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/providers/oauth/{provider_id}/start")
|
@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."""
|
"""Initiate an OAuth login flow. Token-protected."""
|
||||||
_require_token(request)
|
_require_token(request)
|
||||||
_gc_oauth_sessions()
|
_gc_oauth_sessions()
|
||||||
|
_validate_oauth_profile(profile)
|
||||||
valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
|
valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
|
||||||
if provider_id not in valid:
|
if provider_id not in valid:
|
||||||
raise HTTPException(status_code=400, detail=f"Unknown provider {provider_id}")
|
raise HTTPException(status_code=400, detail=f"Unknown provider {provider_id}")
|
||||||
@ -6168,12 +6315,12 @@ async def start_oauth_login(provider_id: str, request: Request):
|
|||||||
# change for MiniMax). New PKCE providers must add their own
|
# change for MiniMax). New PKCE providers must add their own
|
||||||
# start function and an explicit branch here.
|
# start function and an explicit branch here.
|
||||||
if catalog_entry["flow"] == "pkce" and provider_id == "anthropic":
|
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":
|
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":
|
if catalog_entry["flow"] == "loopback" and provider_id == "xai-oauth":
|
||||||
return await asyncio.get_running_loop().run_in_executor(
|
return await asyncio.get_running_loop().run_in_executor(
|
||||||
None, _start_xai_loopback_flow
|
None, _start_xai_loopback_flow, profile,
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@ -6189,18 +6336,27 @@ class OAuthSubmitBody(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
@app.post("/api/providers/oauth/{provider_id}/submit")
|
@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."""
|
"""Submit the auth code for PKCE flows. Token-protected."""
|
||||||
_require_token(request)
|
_require_token(request)
|
||||||
if provider_id == "anthropic":
|
if provider_id == "anthropic":
|
||||||
return await asyncio.get_running_loop().run_in_executor(
|
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}")
|
raise HTTPException(status_code=400, detail=f"submit not supported for {provider_id}")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/providers/oauth/{provider_id}/poll/{session_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).
|
"""Poll a session's status (no auth — read-only state).
|
||||||
|
|
||||||
Shared by the device-code flows (Nous, OpenAI Codex, MiniMax) and the
|
Shared by the device-code flows (Nous, OpenAI Codex, MiniMax) and the
|
||||||
@ -6223,7 +6379,11 @@ async def poll_oauth_session(provider_id: str, session_id: str):
|
|||||||
|
|
||||||
|
|
||||||
@app.delete("/api/providers/oauth/sessions/{session_id}")
|
@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."""
|
"""Cancel a pending OAuth session. Token-protected."""
|
||||||
_require_token(request)
|
_require_token(request)
|
||||||
with _oauth_sessions_lock:
|
with _oauth_sessions_lock:
|
||||||
|
|||||||
@ -1123,11 +1123,16 @@ class SessionDB:
|
|||||||
# backfills, index changes tied to a specific version step) stay
|
# backfills, index changes tied to a specific version step) stay
|
||||||
# in a version-gated chain. Column additions are handled by
|
# in a version-gated chain. Column additions are handled by
|
||||||
# _reconcile_columns() above and no longer need entries here.
|
# _reconcile_columns() above and no longer need entries here.
|
||||||
if current_version < 10:
|
if current_version < 10 and SCHEMA_VERSION == 10:
|
||||||
# v10: trigram FTS5 table for CJK/substring search. The
|
# v10: trigram FTS5 table for CJK/substring search. The
|
||||||
# virtual table + triggers are created unconditionally via
|
# virtual table + triggers are created unconditionally via
|
||||||
# FTS_TRIGRAM_SQL below, but existing rows need a one-time
|
# FTS_TRIGRAM_SQL below, but existing rows need a one-time
|
||||||
# backfill into the FTS index.
|
# backfill into the FTS index.
|
||||||
|
#
|
||||||
|
# Only run this when v10 itself is the target schema. Current
|
||||||
|
# v11+ code drops and rebuilds both FTS tables below, so doing
|
||||||
|
# the v10-only trigram backfill first only burns startup time
|
||||||
|
# and WAL space before v11 throws the work away.
|
||||||
if fts5_available:
|
if fts5_available:
|
||||||
_fts_trigram_exists = self._fts_table_probe(
|
_fts_trigram_exists = self._fts_table_probe(
|
||||||
cursor, "messages_fts_trigram"
|
cursor, "messages_fts_trigram"
|
||||||
|
|||||||
@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another
|
|||||||
title: "**Titel:** {title}"
|
title: "**Titel:** {title}"
|
||||||
created: "**Geskep:** {timestamp}"
|
created: "**Geskep:** {timestamp}"
|
||||||
last_activity: "**Laaste aktiwiteit:** {timestamp}"
|
last_activity: "**Laaste aktiwiteit:** {timestamp}"
|
||||||
|
model: "**Model:** `{model}`"
|
||||||
|
model_provider: "**Model:** `{model}` ({provider})"
|
||||||
|
context: "**Context:** {used} / {total} ({pct}%)"
|
||||||
|
context_used: "**Context:** ~{used} tokens"
|
||||||
tokens: "**Kumulatiewe API-tokens (elke oproep weer gestuur):** {tokens}"
|
tokens: "**Kumulatiewe API-tokens (elke oproep weer gestuur):** {tokens}"
|
||||||
agent_running: "**Agent loop:** {state}"
|
agent_running: "**Agent loop:** {state}"
|
||||||
state_yes: "Ja ⚡"
|
state_yes: "Ja ⚡"
|
||||||
|
|||||||
@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another
|
|||||||
title: "**Titel:** {title}"
|
title: "**Titel:** {title}"
|
||||||
created: "**Erstellt:** {timestamp}"
|
created: "**Erstellt:** {timestamp}"
|
||||||
last_activity: "**Letzte Aktivität:** {timestamp}"
|
last_activity: "**Letzte Aktivität:** {timestamp}"
|
||||||
|
model: "**Modell:** `{model}`"
|
||||||
|
model_provider: "**Modell:** `{model}` ({provider})"
|
||||||
|
context: "**Kontext:** {used} / {total} ({pct}%)"
|
||||||
|
context_used: "**Kontext:** ~{used} Tokens"
|
||||||
tokens: "**Kumulierte API-Tokens (bei jedem Aufruf erneut gesendet):** {tokens}"
|
tokens: "**Kumulierte API-Tokens (bei jedem Aufruf erneut gesendet):** {tokens}"
|
||||||
agent_running: "**Agent läuft:** {state}"
|
agent_running: "**Agent läuft:** {state}"
|
||||||
state_yes: "Ja ⚡"
|
state_yes: "Ja ⚡"
|
||||||
|
|||||||
@ -281,6 +281,10 @@ gateway:
|
|||||||
title: "**Title:** {title}"
|
title: "**Title:** {title}"
|
||||||
created: "**Created:** {timestamp}"
|
created: "**Created:** {timestamp}"
|
||||||
last_activity: "**Last Activity:** {timestamp}"
|
last_activity: "**Last Activity:** {timestamp}"
|
||||||
|
model: "**Model:** `{model}`"
|
||||||
|
model_provider: "**Model:** `{model}` ({provider})"
|
||||||
|
context: "**Context:** {used} / {total} ({pct}%)"
|
||||||
|
context_used: "**Context:** ~{used} tokens"
|
||||||
tokens: "**Cumulative API tokens (re-sent each call):** {tokens}"
|
tokens: "**Cumulative API tokens (re-sent each call):** {tokens}"
|
||||||
agent_running: "**Agent Running:** {state}"
|
agent_running: "**Agent Running:** {state}"
|
||||||
state_yes: "Yes ⚡"
|
state_yes: "Yes ⚡"
|
||||||
|
|||||||
@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another
|
|||||||
title: "**Título:** {title}"
|
title: "**Título:** {title}"
|
||||||
created: "**Creado:** {timestamp}"
|
created: "**Creado:** {timestamp}"
|
||||||
last_activity: "**Última actividad:** {timestamp}"
|
last_activity: "**Última actividad:** {timestamp}"
|
||||||
|
model: "**Model:** `{model}`"
|
||||||
|
model_provider: "**Model:** `{model}` ({provider})"
|
||||||
|
context: "**Context:** {used} / {total} ({pct}%)"
|
||||||
|
context_used: "**Context:** ~{used} tokens"
|
||||||
tokens: "**Tokens de API acumulados (reenviados en cada llamada):** {tokens}"
|
tokens: "**Tokens de API acumulados (reenviados en cada llamada):** {tokens}"
|
||||||
agent_running: "**Agente activo:** {state}"
|
agent_running: "**Agente activo:** {state}"
|
||||||
state_yes: "Sí ⚡"
|
state_yes: "Sí ⚡"
|
||||||
|
|||||||
@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another
|
|||||||
title: "**Titre :** {title}"
|
title: "**Titre :** {title}"
|
||||||
created: "**Créé :** {timestamp}"
|
created: "**Créé :** {timestamp}"
|
||||||
last_activity: "**Dernière activité :** {timestamp}"
|
last_activity: "**Dernière activité :** {timestamp}"
|
||||||
|
model: "**Model:** `{model}`"
|
||||||
|
model_provider: "**Model:** `{model}` ({provider})"
|
||||||
|
context: "**Context:** {used} / {total} ({pct}%)"
|
||||||
|
context_used: "**Context:** ~{used} tokens"
|
||||||
tokens: "**Jetons :** {tokens}"
|
tokens: "**Jetons :** {tokens}"
|
||||||
agent_running: "**Agent en cours :** {state}"
|
agent_running: "**Agent en cours :** {state}"
|
||||||
state_yes: "Oui ⚡"
|
state_yes: "Oui ⚡"
|
||||||
|
|||||||
@ -273,6 +273,10 @@ Future messages in this room will use that transcript until `/reset` or another
|
|||||||
title: "**Teideal:** {title}"
|
title: "**Teideal:** {title}"
|
||||||
created: "**Cruthaithe:** {timestamp}"
|
created: "**Cruthaithe:** {timestamp}"
|
||||||
last_activity: "**Gníomhaíocht is déanaí:** {timestamp}"
|
last_activity: "**Gníomhaíocht is déanaí:** {timestamp}"
|
||||||
|
model: "**Model:** `{model}`"
|
||||||
|
model_provider: "**Model:** `{model}` ({provider})"
|
||||||
|
context: "**Context:** {used} / {total} ({pct}%)"
|
||||||
|
context_used: "**Context:** ~{used} tokens"
|
||||||
tokens: "**Comharthaí:** {tokens}"
|
tokens: "**Comharthaí:** {tokens}"
|
||||||
agent_running: "**Gníomhaire ag rith:** {state}"
|
agent_running: "**Gníomhaire ag rith:** {state}"
|
||||||
state_yes: "Tá ⚡"
|
state_yes: "Tá ⚡"
|
||||||
|
|||||||
@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another
|
|||||||
title: "**Cím:** {title}"
|
title: "**Cím:** {title}"
|
||||||
created: "**Létrehozva:** {timestamp}"
|
created: "**Létrehozva:** {timestamp}"
|
||||||
last_activity: "**Utolsó tevékenység:** {timestamp}"
|
last_activity: "**Utolsó tevékenység:** {timestamp}"
|
||||||
|
model: "**Model:** `{model}`"
|
||||||
|
model_provider: "**Model:** `{model}` ({provider})"
|
||||||
|
context: "**Context:** {used} / {total} ({pct}%)"
|
||||||
|
context_used: "**Context:** ~{used} tokens"
|
||||||
tokens: "**Tokenek:** {tokens}"
|
tokens: "**Tokenek:** {tokens}"
|
||||||
agent_running: "**Ügynök fut:** {state}"
|
agent_running: "**Ügynök fut:** {state}"
|
||||||
state_yes: "Igen ⚡"
|
state_yes: "Igen ⚡"
|
||||||
|
|||||||
@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another
|
|||||||
title: "**Titolo:** {title}"
|
title: "**Titolo:** {title}"
|
||||||
created: "**Creata:** {timestamp}"
|
created: "**Creata:** {timestamp}"
|
||||||
last_activity: "**Ultima attività:** {timestamp}"
|
last_activity: "**Ultima attività:** {timestamp}"
|
||||||
|
model: "**Model:** `{model}`"
|
||||||
|
model_provider: "**Model:** `{model}` ({provider})"
|
||||||
|
context: "**Context:** {used} / {total} ({pct}%)"
|
||||||
|
context_used: "**Context:** ~{used} tokens"
|
||||||
tokens: "**Token:** {tokens}"
|
tokens: "**Token:** {tokens}"
|
||||||
agent_running: "**Agente in esecuzione:** {state}"
|
agent_running: "**Agente in esecuzione:** {state}"
|
||||||
state_yes: "Sì ⚡"
|
state_yes: "Sì ⚡"
|
||||||
|
|||||||
@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another
|
|||||||
title: "**タイトル:** {title}"
|
title: "**タイトル:** {title}"
|
||||||
created: "**作成日時:** {timestamp}"
|
created: "**作成日時:** {timestamp}"
|
||||||
last_activity: "**最終アクティビティ:** {timestamp}"
|
last_activity: "**最終アクティビティ:** {timestamp}"
|
||||||
|
model: "**Model:** `{model}`"
|
||||||
|
model_provider: "**Model:** `{model}` ({provider})"
|
||||||
|
context: "**Context:** {used} / {total} ({pct}%)"
|
||||||
|
context_used: "**Context:** ~{used} tokens"
|
||||||
tokens: "**トークン:** {tokens}"
|
tokens: "**トークン:** {tokens}"
|
||||||
agent_running: "**エージェント実行中:** {state}"
|
agent_running: "**エージェント実行中:** {state}"
|
||||||
state_yes: "はい ⚡"
|
state_yes: "はい ⚡"
|
||||||
|
|||||||
@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another
|
|||||||
title: "**제목:** {title}"
|
title: "**제목:** {title}"
|
||||||
created: "**생성됨:** {timestamp}"
|
created: "**생성됨:** {timestamp}"
|
||||||
last_activity: "**최종 활동:** {timestamp}"
|
last_activity: "**최종 활동:** {timestamp}"
|
||||||
|
model: "**Model:** `{model}`"
|
||||||
|
model_provider: "**Model:** `{model}` ({provider})"
|
||||||
|
context: "**Context:** {used} / {total} ({pct}%)"
|
||||||
|
context_used: "**Context:** ~{used} tokens"
|
||||||
tokens: "**토큰:** {tokens}"
|
tokens: "**토큰:** {tokens}"
|
||||||
agent_running: "**에이전트 실행 중:** {state}"
|
agent_running: "**에이전트 실행 중:** {state}"
|
||||||
state_yes: "예 ⚡"
|
state_yes: "예 ⚡"
|
||||||
|
|||||||
@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another
|
|||||||
title: "**Título:** {title}"
|
title: "**Título:** {title}"
|
||||||
created: "**Criada:** {timestamp}"
|
created: "**Criada:** {timestamp}"
|
||||||
last_activity: "**Última atividade:** {timestamp}"
|
last_activity: "**Última atividade:** {timestamp}"
|
||||||
|
model: "**Model:** `{model}`"
|
||||||
|
model_provider: "**Model:** `{model}` ({provider})"
|
||||||
|
context: "**Context:** {used} / {total} ({pct}%)"
|
||||||
|
context_used: "**Context:** ~{used} tokens"
|
||||||
tokens: "**Tokens de API cumulativos (reenviados a cada chamada):** {tokens}"
|
tokens: "**Tokens de API cumulativos (reenviados a cada chamada):** {tokens}"
|
||||||
agent_running: "**Agente em execução:** {state}"
|
agent_running: "**Agente em execução:** {state}"
|
||||||
state_yes: "Sim ⚡"
|
state_yes: "Sim ⚡"
|
||||||
|
|||||||
@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another
|
|||||||
title: "**Название:** {title}"
|
title: "**Название:** {title}"
|
||||||
created: "**Создано:** {timestamp}"
|
created: "**Создано:** {timestamp}"
|
||||||
last_activity: "**Последняя активность:** {timestamp}"
|
last_activity: "**Последняя активность:** {timestamp}"
|
||||||
|
model: "**Model:** `{model}`"
|
||||||
|
model_provider: "**Model:** `{model}` ({provider})"
|
||||||
|
context: "**Context:** {used} / {total} ({pct}%)"
|
||||||
|
context_used: "**Context:** ~{used} tokens"
|
||||||
tokens: "**Токены:** {tokens}"
|
tokens: "**Токены:** {tokens}"
|
||||||
agent_running: "**Агент активен:** {state}"
|
agent_running: "**Агент активен:** {state}"
|
||||||
state_yes: "Да ⚡"
|
state_yes: "Да ⚡"
|
||||||
|
|||||||
@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another
|
|||||||
title: "**Başlık:** {title}"
|
title: "**Başlık:** {title}"
|
||||||
created: "**Oluşturuldu:** {timestamp}"
|
created: "**Oluşturuldu:** {timestamp}"
|
||||||
last_activity: "**Son etkinlik:** {timestamp}"
|
last_activity: "**Son etkinlik:** {timestamp}"
|
||||||
|
model: "**Model:** `{model}`"
|
||||||
|
model_provider: "**Model:** `{model}` ({provider})"
|
||||||
|
context: "**Context:** {used} / {total} ({pct}%)"
|
||||||
|
context_used: "**Context:** ~{used} tokens"
|
||||||
tokens: "**Token:** {tokens}"
|
tokens: "**Token:** {tokens}"
|
||||||
agent_running: "**Aracı çalışıyor:** {state}"
|
agent_running: "**Aracı çalışıyor:** {state}"
|
||||||
state_yes: "Evet ⚡"
|
state_yes: "Evet ⚡"
|
||||||
|
|||||||
@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another
|
|||||||
title: "**Назва:** {title}"
|
title: "**Назва:** {title}"
|
||||||
created: "**Створено:** {timestamp}"
|
created: "**Створено:** {timestamp}"
|
||||||
last_activity: "**Остання активність:** {timestamp}"
|
last_activity: "**Остання активність:** {timestamp}"
|
||||||
|
model: "**Model:** `{model}`"
|
||||||
|
model_provider: "**Model:** `{model}` ({provider})"
|
||||||
|
context: "**Context:** {used} / {total} ({pct}%)"
|
||||||
|
context_used: "**Context:** ~{used} tokens"
|
||||||
tokens: "**Токени:** {tokens}"
|
tokens: "**Токени:** {tokens}"
|
||||||
agent_running: "**Агент активний:** {state}"
|
agent_running: "**Агент активний:** {state}"
|
||||||
state_yes: "Так ⚡"
|
state_yes: "Так ⚡"
|
||||||
|
|||||||
@ -269,6 +269,10 @@ Future messages in this room will use that transcript until `/reset` or another
|
|||||||
title: "**標題:** {title}"
|
title: "**標題:** {title}"
|
||||||
created: "**建立時間:** {timestamp}"
|
created: "**建立時間:** {timestamp}"
|
||||||
last_activity: "**最近活動:** {timestamp}"
|
last_activity: "**最近活動:** {timestamp}"
|
||||||
|
model: "**Model:** `{model}`"
|
||||||
|
model_provider: "**Model:** `{model}` ({provider})"
|
||||||
|
context: "**Context:** {used} / {total} ({pct}%)"
|
||||||
|
context_used: "**Context:** ~{used} tokens"
|
||||||
tokens: "**Token 數:** {tokens}"
|
tokens: "**Token 數:** {tokens}"
|
||||||
agent_running: "**代理執行中:** {state}"
|
agent_running: "**代理執行中:** {state}"
|
||||||
state_yes: "是 ⚡"
|
state_yes: "是 ⚡"
|
||||||
|
|||||||
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