Compare commits
228
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da6646a23b | ||
|
|
ca3428fe69 | ||
|
|
bfa2985553 | ||
|
|
8b1b9146c4 | ||
|
|
815f171f37 | ||
|
|
5e7a7f6a38 | ||
|
|
8f29ad23c2 | ||
|
|
b86043834f | ||
|
|
de8fed32fd | ||
|
|
696037587f | ||
|
|
bebf1b7e01 | ||
|
|
3d8c285054 | ||
|
|
1653a04f70 | ||
|
|
48db64c846 | ||
|
|
ce31ec09b9 | ||
|
|
006136c4ab | ||
|
|
25488de4ba | ||
|
|
aeebe1afa7 | ||
|
|
71d64880d9 | ||
|
|
be663d36a5 | ||
|
|
6381e70448 | ||
|
|
a4cfc8b740 | ||
|
|
060c4f64a8 | ||
|
|
91bf5ee6b7 | ||
|
|
3387b8df58 | ||
|
|
3b29e65c1b | ||
|
|
e2d69ce066 | ||
|
|
17edb1db2b | ||
|
|
d5fe467277 | ||
|
|
c7e46f9f3d | ||
|
|
0a079f7321 | ||
|
|
8eedb50bce | ||
|
|
80d782bc78 | ||
|
|
19419a47d7 | ||
|
|
a5d418bc5b | ||
|
|
791f4e939d | ||
|
|
7a15f0b1ac | ||
|
|
a3df95e76d | ||
|
|
cbf80ff71d | ||
|
|
02d26981d3 | ||
|
|
8523a9feaf | ||
|
|
e1338265c1 | ||
|
|
3adb74269a | ||
|
|
3b6686b596 | ||
|
|
82e45ab428 | ||
|
|
17264cc147 | ||
|
|
f6e6f00ff8 | ||
|
|
7003df708c | ||
|
|
ea510a7c02 | ||
|
|
3143f79b8f | ||
|
|
99f2a9503c | ||
|
|
5abf89ddd1 | ||
|
|
563ad23853 | ||
|
|
b7b378e3a4 | ||
|
|
493dd5b660 | ||
|
|
0739588f48 | ||
|
|
a6a78ff08a | ||
|
|
e529694919 | ||
|
|
a7e6a4fc0b | ||
|
|
e18c233c1e | ||
|
|
a1b8631176 | ||
|
|
88e7d7537c | ||
|
|
bff1b3261d | ||
|
|
bfdb528a76 | ||
|
|
085c33ed70 | ||
|
|
fa48c2501f | ||
|
|
b92db9213a | ||
|
|
945fd9c222 | ||
|
|
28781682ec | ||
|
|
928280ca2c | ||
|
|
85c583dc34 | ||
|
|
6079d7dd9d | ||
|
|
7f8b0dd1e0 | ||
|
|
c858484b45 | ||
|
|
5dcfb0b82e | ||
|
|
da3bd34c08 | ||
|
|
e74f291dc2 | ||
|
|
4b30db1f85 | ||
|
|
e98bec95ef | ||
|
|
fd256b0a70 | ||
|
|
bed626bdb2 | ||
|
|
02aaac8f73 | ||
|
|
705eaa054a | ||
|
|
046f0c01cb | ||
|
|
c058ac6677 | ||
|
|
4ce99508d6 | ||
|
|
6e5bddc9c3 | ||
|
|
7415e28073 | ||
|
|
6a854bc8ed | ||
|
|
c7e6a48bfb | ||
|
|
64ab17182a | ||
|
|
8acd825afc | ||
|
|
cc76ebcc16 | ||
|
|
eb68d66ff9 | ||
|
|
f9908af1a0 | ||
|
|
d67a438fec | ||
|
|
062eed654d | ||
|
|
6d3ed6b20d | ||
|
|
7333c035ce | ||
|
|
62905e0a6e | ||
|
|
40ad610968 | ||
|
|
af245abec9 | ||
|
|
a7d4ada79c | ||
|
|
c30550c552 | ||
|
|
d0c20708ce | ||
|
|
6640a9d3ab | ||
|
|
e5bbeb9f1e | ||
|
|
fc21a40b79 | ||
|
|
ff06fed123 | ||
|
|
13a1ad4866 | ||
|
|
5dd4fb05c6 | ||
|
|
b96bee7f5c | ||
|
|
4afbdf58b3 | ||
|
|
f08cc6bbeb | ||
|
|
6746404b0f | ||
|
|
98d39fc2c4 | ||
|
|
927e982b23 | ||
|
|
17e86dddc7 | ||
|
|
30ba7bcd5a | ||
|
|
6f2e616d9f | ||
|
|
bf196bb47b | ||
|
|
ca2c3d4ab4 | ||
|
|
6070941eb0 | ||
|
|
9a0ebf0175 | ||
|
|
b6f2ff5136 | ||
|
|
49de1adc49 | ||
|
|
708d2a0c33 | ||
|
|
747caa74f0 | ||
|
|
22297b3050 | ||
|
|
1ae0eed039 | ||
|
|
112cad59b4 | ||
|
|
71e864b600 | ||
|
|
7dd7703f64 | ||
|
|
8b6344dffd | ||
|
|
db9e74b1e0 | ||
|
|
fda39d4850 | ||
|
|
adb67ee48d | ||
|
|
a08ec216d4 | ||
|
|
d06c21f3d4 | ||
|
|
820d25c5bf | ||
|
|
96968c9932 | ||
|
|
939ab58b8d | ||
|
|
2252160dcf | ||
|
|
32f0fde35c | ||
|
|
1270f50e8b | ||
|
|
d208f2c2c0 | ||
|
|
fdf73f0adf | ||
|
|
3f013d289c | ||
|
|
d37ea68822 | ||
|
|
d760e6b7db | ||
|
|
09cdda64c9 | ||
|
|
2ce691d8ca | ||
|
|
dc66a98430 | ||
|
|
4dd9732a94 | ||
|
|
4b3839a8ee | ||
|
|
50a9d6333f | ||
|
|
8d465a5732 | ||
|
|
c8c8c53a0c | ||
|
|
bff052d61f | ||
|
|
61fb5a48b7 | ||
|
|
cb7f1d7e0e | ||
|
|
767736ff1e | ||
|
|
eaab34e57e | ||
|
|
4d14a1479a | ||
|
|
edc015886b | ||
|
|
9222f1c491 | ||
|
|
cc0bd10420 | ||
|
|
fae9166cf4 | ||
|
|
f790c61207 | ||
|
|
9ec0f7cbff | ||
|
|
94fbfb2019 | ||
|
|
d3d1772837 | ||
|
|
0961854b88 | ||
|
|
a02ea9d8ff | ||
|
|
5e4f2301f8 | ||
|
|
281f764e2a | ||
|
|
b3e7133da1 | ||
|
|
2d0aa1b7cb | ||
|
|
11d04d9d5e | ||
|
|
da6b745fff | ||
|
|
726a1a97a7 | ||
|
|
37d1c57f8a | ||
|
|
85f30e07a5 | ||
|
|
c5413c17ad | ||
|
|
7d652fc466 | ||
|
|
e31b74073b | ||
|
|
c730a9976d | ||
|
|
8d95e006b8 | ||
|
|
89d5ee4b10 | ||
|
|
fc9d18b03f | ||
|
|
07e0bb8aae | ||
|
|
81d4316b4a | ||
|
|
c9987f1e22 | ||
|
|
dda3894523 | ||
|
|
ddf83e95b0 | ||
|
|
5269012c51 | ||
|
|
5ec0667fb3 | ||
|
|
3aabae20eb | ||
|
|
2964f25534 | ||
|
|
b352e8ed17 | ||
|
|
301c698491 | ||
|
|
023730314b | ||
|
|
fcce49db3f | ||
|
|
42db075e10 | ||
|
|
74127e0c48 | ||
|
|
64a63d0d2b | ||
|
|
12307a66e0 | ||
|
|
5f334e86fd | ||
|
|
d1d0ed4016 | ||
|
|
ca8f2c7907 | ||
|
|
27c5fa5381 | ||
|
|
9ca5ea1375 | ||
|
|
fa92720d2c | ||
|
|
fd97a7cba4 | ||
|
|
6dcf5bcbc0 | ||
|
|
a66303eaef | ||
|
|
5e4473df96 | ||
|
|
215bf4b96c | ||
|
|
db884f4646 | ||
|
|
420f68e4e2 | ||
|
|
935970898f | ||
|
|
322cc94c98 | ||
|
|
cd381d6ba5 | ||
|
|
e00297782d | ||
|
|
d5d7b5c6dc | ||
|
|
9f3d393a4d | ||
|
|
6c624f197c | ||
|
|
7b61f86529 |
@@ -1,100 +0,0 @@
|
||||
name: Build Windows Installer
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Gate: workflow_dispatch is already restricted to users with write access,
|
||||
# but we want ADMIN-only. Explicitly check the triggering actor's repo
|
||||
# permission via the API and fail fast for anyone below admin.
|
||||
authorize:
|
||||
name: Authorize (admins only)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check actor is a repo admin
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
perm=$(gh api \
|
||||
"repos/${{ github.repository }}/collaborators/${ACTOR}/permission" \
|
||||
--jq '.permission')
|
||||
echo "Actor '${ACTOR}' has permission: ${perm}"
|
||||
if [ "${perm}" != "admin" ]; then
|
||||
echo "::error::'${ACTOR}' is not a repo admin (permission=${perm}). Refusing to build/sign."
|
||||
exit 1
|
||||
fi
|
||||
echo "Authorized: '${ACTOR}' is an admin."
|
||||
|
||||
build:
|
||||
name: Hermes-Setup.exe
|
||||
needs: authorize
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
# Required for OIDC auth to Azure (azure/login federated credentials).
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
|
||||
- name: Cache Rust targets
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
workspaces: apps/bootstrap-installer/src-tauri
|
||||
|
||||
- name: Build installer
|
||||
run: npm run tauri:build
|
||||
working-directory: apps/bootstrap-installer
|
||||
|
||||
- name: Azure login (OIDC)
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Sign Hermes-Setup.exe with Azure Artifact Signing
|
||||
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2
|
||||
with:
|
||||
endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }}
|
||||
signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile-name: ${{ vars.AZURE_SIGNING_CERTIFICATE_PROFILE }}
|
||||
# Sign both the raw exe and the bundled NSIS installer.
|
||||
files-folder: ${{ github.workspace }}\apps\bootstrap-installer\src-tauri\target\release
|
||||
files-folder-filter: exe
|
||||
files-folder-recurse: true
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Upload NSIS installer
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Hermes-Setup-installer
|
||||
path: apps/bootstrap-installer/src-tauri/target/release/bundle/nsis/*.exe
|
||||
|
||||
- name: Upload raw exe
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Hermes-Setup-exe
|
||||
path: apps/bootstrap-installer/src-tauri/target/release/Hermes-Setup.exe
|
||||
@@ -0,0 +1,342 @@
|
||||
name: Desktop Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
channel:
|
||||
description: Release channel to build
|
||||
required: true
|
||||
default: nightly
|
||||
type: choice
|
||||
options:
|
||||
- nightly
|
||||
- stable
|
||||
release_tag:
|
||||
description: "Required when channel=stable (example: v2026.5.5)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: desktop-release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
channel: ${{ steps.meta.outputs.channel }}
|
||||
release_name: ${{ steps.meta.outputs.release_name }}
|
||||
release_tag: ${{ steps.meta.outputs.release_tag }}
|
||||
version: ${{ steps.meta.outputs.version }}
|
||||
is_stable: ${{ steps.meta.outputs.is_stable }}
|
||||
steps:
|
||||
- id: meta
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_CHANNEL: ${{ github.event.inputs.channel }}
|
||||
INPUT_RELEASE_TAG: ${{ github.event.inputs.release_tag }}
|
||||
RELEASE_TAG_FROM_EVENT: ${{ github.event.release.tag_name }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
channel="nightly"
|
||||
release_tag="desktop-nightly"
|
||||
is_stable="false"
|
||||
|
||||
if [[ "$EVENT_NAME" == "release" ]]; then
|
||||
channel="stable"
|
||||
release_tag="$RELEASE_TAG_FROM_EVENT"
|
||||
is_stable="true"
|
||||
elif [[ "$EVENT_NAME" == "workflow_dispatch" && "$INPUT_CHANNEL" == "stable" ]]; then
|
||||
channel="stable"
|
||||
release_tag="$INPUT_RELEASE_TAG"
|
||||
is_stable="true"
|
||||
fi
|
||||
|
||||
if [[ "$channel" == "stable" ]]; then
|
||||
if [[ -z "$release_tag" ]]; then
|
||||
echo "Stable desktop releases require a release tag." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="${release_tag#v}"
|
||||
release_name="Hermes Desktop ${release_tag}"
|
||||
else
|
||||
stamp="$(date -u +%Y%m%d)"
|
||||
short_sha="${GITHUB_SHA::7}"
|
||||
version="0.0.0-nightly.${stamp}.${short_sha}"
|
||||
release_name="Hermes Desktop Nightly ${stamp}-${short_sha}"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "channel=$channel"
|
||||
echo "release_name=$release_name"
|
||||
echo "release_tag=$release_tag"
|
||||
echo "version=$version"
|
||||
echo "is_stable=$is_stable"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
needs: prepare
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: mac
|
||||
runner: macos-latest
|
||||
build_args: --mac dmg zip
|
||||
- platform: win
|
||||
runner: windows-latest
|
||||
build_args: --win nsis msi
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
DESKTOP_CHANNEL: ${{ needs.prepare.outputs.channel }}
|
||||
DESKTOP_VERSION: ${{ needs.prepare.outputs.version }}
|
||||
MAC_CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
MAC_CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
|
||||
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Enforce signing gates for stable releases
|
||||
if: needs.prepare.outputs.is_stable == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
missing=()
|
||||
|
||||
if [[ "${{ matrix.platform }}" == "mac" ]]; then
|
||||
[[ -z "${MAC_CSC_LINK:-}" ]] && missing+=("CSC_LINK")
|
||||
[[ -z "${MAC_CSC_KEY_PASSWORD:-}" ]] && missing+=("CSC_KEY_PASSWORD")
|
||||
[[ -z "${APPLE_API_KEY:-}" ]] && missing+=("APPLE_API_KEY")
|
||||
[[ -z "${APPLE_API_KEY_ID:-}" ]] && missing+=("APPLE_API_KEY_ID")
|
||||
[[ -z "${APPLE_API_ISSUER:-}" ]] && missing+=("APPLE_API_ISSUER")
|
||||
else
|
||||
[[ -z "${WIN_CSC_LINK:-}" ]] && missing+=("WIN_CSC_LINK")
|
||||
[[ -z "${WIN_CSC_KEY_PASSWORD:-}" ]] && missing+=("WIN_CSC_KEY_PASSWORD")
|
||||
fi
|
||||
|
||||
if (( ${#missing[@]} > 0 )); then
|
||||
echo "::error::Stable desktop release missing required secrets: ${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install workspace dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install TUI dependencies
|
||||
run: npm --prefix ui-tui ci
|
||||
|
||||
- name: Build bundled TUI payload
|
||||
run: npm --prefix ui-tui run build
|
||||
|
||||
- name: Build desktop renderer
|
||||
run: npm --prefix apps/desktop run build
|
||||
|
||||
- name: Map macOS signing credentials
|
||||
if: matrix.platform == 'mac'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
has_link=0
|
||||
has_pass=0
|
||||
[[ -n "${MAC_CSC_LINK:-}" ]] && has_link=1
|
||||
[[ -n "${MAC_CSC_KEY_PASSWORD:-}" ]] && has_pass=1
|
||||
|
||||
if [[ $has_link -eq 1 && $has_pass -eq 1 ]]; then
|
||||
echo "CSC_LINK=${MAC_CSC_LINK}" >> "$GITHUB_ENV"
|
||||
echo "CSC_KEY_PASSWORD=${MAC_CSC_KEY_PASSWORD}" >> "$GITHUB_ENV"
|
||||
elif [[ $has_link -eq 1 || $has_pass -eq 1 ]]; then
|
||||
echo "::error::macOS signing secrets are partially configured. Set both CSC_LINK and CSC_KEY_PASSWORD."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Map Windows signing credentials
|
||||
if: matrix.platform == 'win'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
has_link=0
|
||||
has_pass=0
|
||||
[[ -n "${WIN_CSC_LINK:-}" ]] && has_link=1
|
||||
[[ -n "${WIN_CSC_KEY_PASSWORD:-}" ]] && has_pass=1
|
||||
|
||||
if [[ $has_link -eq 1 && $has_pass -eq 1 ]]; then
|
||||
echo "CSC_LINK=${WIN_CSC_LINK}" >> "$GITHUB_ENV"
|
||||
echo "CSC_KEY_PASSWORD=${WIN_CSC_KEY_PASSWORD}" >> "$GITHUB_ENV"
|
||||
echo "CSC_FOR_PULL_REQUEST=true" >> "$GITHUB_ENV"
|
||||
elif [[ $has_link -eq 1 || $has_pass -eq 1 ]]; then
|
||||
echo "::error::Windows signing secrets are partially configured. Set both WIN_CSC_LINK and WIN_CSC_KEY_PASSWORD."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build desktop installers
|
||||
shell: bash
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=16384
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm --prefix apps/desktop run builder -- \
|
||||
${{ matrix.build_args }} \
|
||||
--publish never \
|
||||
--config.extraMetadata.version="${DESKTOP_VERSION}" \
|
||||
--config.extraMetadata.desktopChannel="${DESKTOP_CHANNEL}"
|
||||
|
||||
- name: Notarize and staple macOS DMG
|
||||
if: matrix.platform == 'mac' && needs.prepare.outputs.is_stable == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
dmg_path="$(ls apps/desktop/release/*.dmg | head -n 1)"
|
||||
node apps/desktop/scripts/notarize-artifact.cjs "$dmg_path"
|
||||
|
||||
- name: Validate macOS notarization and Gatekeeper trust
|
||||
if: matrix.platform == 'mac' && needs.prepare.outputs.is_stable == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
app_path="$(ls -d apps/desktop/release/mac*/Hermes.app | head -n 1)"
|
||||
dmg_path="$(ls apps/desktop/release/*.dmg | head -n 1)"
|
||||
xcrun stapler validate "$app_path"
|
||||
xcrun stapler validate "$dmg_path"
|
||||
spctl --assess --type execute --verbose=4 "$app_path"
|
||||
|
||||
- name: Generate desktop checksums
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node <<'EOF'
|
||||
const crypto = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const releaseDir = path.resolve('apps/desktop/release')
|
||||
const platform = process.env.PLATFORM
|
||||
const extensions = platform === 'mac' ? ['.dmg', '.zip'] : ['.exe', '.msi']
|
||||
const files = fs
|
||||
.readdirSync(releaseDir)
|
||||
.filter(name => extensions.some(ext => name.endsWith(ext)))
|
||||
.sort()
|
||||
|
||||
if (!files.length) {
|
||||
throw new Error(`No release artifacts were produced for ${platform}`)
|
||||
}
|
||||
|
||||
const lines = files.map(name => {
|
||||
const full = path.join(releaseDir, name)
|
||||
const hash = crypto.createHash('sha256').update(fs.readFileSync(full)).digest('hex')
|
||||
return `${hash} ${name}`
|
||||
})
|
||||
fs.writeFileSync(path.join(releaseDir, `SHA256SUMS-${platform}.txt`), `${lines.join('\n')}\n`)
|
||||
EOF
|
||||
env:
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
|
||||
- name: Upload packaged desktop artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: desktop-${{ matrix.platform }}
|
||||
path: |
|
||||
apps/desktop/release/*.dmg
|
||||
apps/desktop/release/*.zip
|
||||
apps/desktop/release/*.exe
|
||||
apps/desktop/release/*.msi
|
||||
apps/desktop/release/SHA256SUMS-${{ matrix.platform }}.txt
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
needs: [prepare, build]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CHANNEL: ${{ needs.prepare.outputs.channel }}
|
||||
RELEASE_NAME: ${{ needs.prepare.outputs.release_name }}
|
||||
RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
pattern: desktop-*
|
||||
merge-multiple: true
|
||||
path: dist/desktop
|
||||
|
||||
- name: Publish desktop assets to GitHub release
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s globstar nullglob
|
||||
|
||||
files=(
|
||||
dist/desktop/**/*.dmg
|
||||
dist/desktop/**/*.zip
|
||||
dist/desktop/**/*.exe
|
||||
dist/desktop/**/*.msi
|
||||
dist/desktop/**/SHA256SUMS-*.txt
|
||||
)
|
||||
|
||||
if (( ${#files[@]} == 0 )); then
|
||||
echo "No desktop artifacts were downloaded for publishing." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$CHANNEL" == "nightly" ]]; then
|
||||
git tag -f "$RELEASE_TAG" "$GITHUB_SHA"
|
||||
git push origin "refs/tags/$RELEASE_TAG" --force
|
||||
|
||||
notes="Automated nightly desktop build from main. This prerelease is replaced on each new run."
|
||||
|
||||
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
while IFS= read -r asset_name; do
|
||||
gh release delete-asset "$RELEASE_TAG" "$asset_name" --yes
|
||||
done < <(gh release view "$RELEASE_TAG" --json assets -q '.assets[].name')
|
||||
|
||||
gh release edit "$RELEASE_TAG" \
|
||||
--title "$RELEASE_NAME" \
|
||||
--prerelease \
|
||||
--notes "$notes"
|
||||
else
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--target "$GITHUB_SHA" \
|
||||
--title "$RELEASE_NAME" \
|
||||
--notes "$notes" \
|
||||
--prerelease
|
||||
fi
|
||||
else
|
||||
if ! gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
notes="Automated desktop artifacts attached by desktop-release workflow."
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--target "$GITHUB_SHA" \
|
||||
--title "$RELEASE_NAME" \
|
||||
--notes "$notes"
|
||||
fi
|
||||
fi
|
||||
|
||||
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber
|
||||
+1
-11
@@ -73,17 +73,7 @@ RUN set -eu; \
|
||||
tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz; \
|
||||
tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz; \
|
||||
tar -C / -Jxpf /tmp/s6-overlay-symlinks-noarch.tar.xz; \
|
||||
rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256; \
|
||||
# #34192: backward-compat shim for orchestration templates that still\
|
||||
# reference the legacy /usr/bin/tini entrypoint (e.g. Hostinger's\
|
||||
# 'Hermes WebUI' catalog). The image has moved to s6-overlay /init\
|
||||
# as PID 1 (see ENTRYPOINT below + the migration comment at the top\
|
||||
# of this file), but external wrappers pinned to /usr/bin/tini will\
|
||||
# crash with 'tini: No such file or directory' on startup. The shim\
|
||||
# symlinks /usr/bin/tini -> /init so legacy wrappers exec the right\
|
||||
# PID-1 reaper without behavior change for users on the current\
|
||||
# ENTRYPOINT. Safe to drop once the affected catalogs are updated.\
|
||||
ln -sf /init /usr/bin/tini
|
||||
rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256
|
||||
|
||||
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
|
||||
RUN useradd -u 10000 -m -d /opt/data hermes
|
||||
|
||||
@@ -36,9 +36,9 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
### Windows (native, PowerShell)
|
||||
### Windows (native, PowerShell) — Early Beta
|
||||
|
||||
> **Heads up:** Native Windows runs Hermes without WSL — CLI, gateway, TUI, and tools all work natively. If you'd rather use WSL2, the Linux/macOS one-liner above works there too. Found a bug? Please [file issues](https://github.com/NousResearch/hermes-agent/issues).
|
||||
> **Heads up:** Native Windows support is **early beta**. It installs and runs, but hasn't been road-tested as broadly as our Linux/macOS/WSL2 paths. Please [file issues](https://github.com/NousResearch/hermes-agent/issues) when you hit rough edges. For the most battle-tested Windows setup today, run the Linux/macOS one-liner above inside **WSL2**.
|
||||
|
||||
Run this in PowerShell:
|
||||
|
||||
@@ -46,13 +46,13 @@ Run this in PowerShell:
|
||||
iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)
|
||||
```
|
||||
|
||||
The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` — no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands.
|
||||
The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` — no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands.
|
||||
|
||||
If you already have Git installed, the installer detects it and uses that instead. Otherwise a ~45MB MinGit download is all you need — it won't touch or interfere with any system Git.
|
||||
If you already have Git installed, the installer detects it and uses that instead. Otherwise a ~45MB MinGit download is all you need — it won't touch or interfere with any system Git.
|
||||
|
||||
> **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies.
|
||||
>
|
||||
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively).
|
||||
> **Windows:** Native Windows is supported as an **early beta** — the PowerShell one-liner above installs everything, but expect rough edges and please file issues when you hit them. If you'd rather use WSL2 (our most battle-tested Windows path), the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively).
|
||||
|
||||
After installation:
|
||||
|
||||
@@ -104,17 +104,17 @@ You can still bring your own keys per-tool whenever you want — the gateway is
|
||||
|
||||
Hermes has two entry points: start the terminal UI with `hermes`, or run the gateway and talk to it from Telegram, Discord, Slack, WhatsApp, Signal, or Email. Once you're in a conversation, many slash commands are shared across both interfaces.
|
||||
|
||||
| Action | CLI | Messaging platforms |
|
||||
| ------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| Start chatting | `hermes` | Run `hermes gateway setup` + `hermes gateway start`, then send the bot a message |
|
||||
| Start fresh conversation | `/new` or `/reset` | `/new` or `/reset` |
|
||||
| Change model | `/model [provider:model]` | `/model [provider:model]` |
|
||||
| Set a personality | `/personality [name]` | `/personality [name]` |
|
||||
| Retry or undo the last turn | `/retry`, `/undo` | `/retry`, `/undo` |
|
||||
| Compress context / check usage | `/compress`, `/usage`, `/insights [--days N]` | `/compress`, `/usage`, `/insights [days]` |
|
||||
| Browse skills | `/skills` or `/<skill-name>` | `/<skill-name>` |
|
||||
| Interrupt current work | `Ctrl+C` or send a new message | `/stop` or send a new message |
|
||||
| Platform-specific status | `/platforms` | `/status`, `/sethome` |
|
||||
| Action | CLI | Messaging platforms |
|
||||
|---------|-----|---------------------|
|
||||
| Start chatting | `hermes` | Run `hermes gateway setup` + `hermes gateway start`, then send the bot a message |
|
||||
| Start fresh conversation | `/new` or `/reset` | `/new` or `/reset` |
|
||||
| Change model | `/model [provider:model]` | `/model [provider:model]` |
|
||||
| Set a personality | `/personality [name]` | `/personality [name]` |
|
||||
| Retry or undo the last turn | `/retry`, `/undo` | `/retry`, `/undo` |
|
||||
| Compress context / check usage | `/compress`, `/usage`, `/insights [--days N]` | `/compress`, `/usage`, `/insights [days]` |
|
||||
| Browse skills | `/skills` or `/<skill-name>` | `/<skill-name>` |
|
||||
| Interrupt current work | `Ctrl+C` or send a new message | `/stop` or send a new message |
|
||||
| Platform-specific status | `/platforms` | `/status`, `/sethome` |
|
||||
|
||||
For the full command lists, see the [CLI guide](https://hermes-agent.nousresearch.com/docs/user-guide/cli) and the [Messaging Gateway guide](https://hermes-agent.nousresearch.com/docs/user-guide/messaging).
|
||||
|
||||
@@ -124,23 +124,23 @@ For the full command lists, see the [CLI guide](https://hermes-agent.nousresearc
|
||||
|
||||
All documentation lives at **[hermes-agent.nousresearch.com/docs](https://hermes-agent.nousresearch.com/docs/)**:
|
||||
|
||||
| Section | What's Covered |
|
||||
| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
|
||||
| [Quickstart](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | Install → setup → first conversation in 2 minutes |
|
||||
| [CLI Usage](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | Commands, keybindings, personalities, sessions |
|
||||
| [Configuration](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | Config file, providers, models, all options |
|
||||
| [Messaging Gateway](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | Telegram, Discord, Slack, WhatsApp, Signal, Home Assistant |
|
||||
| [Security](https://hermes-agent.nousresearch.com/docs/user-guide/security) | Command approval, DM pairing, container isolation |
|
||||
| [Tools & Toolsets](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | 40+ tools, toolset system, terminal backends |
|
||||
| [Skills System](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills) | Procedural memory, Skills Hub, creating skills |
|
||||
| [Memory](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | Persistent memory, user profiles, best practices |
|
||||
| [MCP Integration](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | Connect any MCP server for extended capabilities |
|
||||
| [Cron Scheduling](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | Scheduled tasks with platform delivery |
|
||||
| [Context Files](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files) | Project context that shapes every conversation |
|
||||
| [Architecture](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | Project structure, agent loop, key classes |
|
||||
| [Contributing](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | Development setup, PR process, code style |
|
||||
| [CLI Reference](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | All commands and flags |
|
||||
| [Environment Variables](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | Complete env var reference |
|
||||
| Section | What's Covered |
|
||||
|---------|---------------|
|
||||
| [Quickstart](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | Install → setup → first conversation in 2 minutes |
|
||||
| [CLI Usage](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | Commands, keybindings, personalities, sessions |
|
||||
| [Configuration](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | Config file, providers, models, all options |
|
||||
| [Messaging Gateway](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | Telegram, Discord, Slack, WhatsApp, Signal, Home Assistant |
|
||||
| [Security](https://hermes-agent.nousresearch.com/docs/user-guide/security) | Command approval, DM pairing, container isolation |
|
||||
| [Tools & Toolsets](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | 40+ tools, toolset system, terminal backends |
|
||||
| [Skills System](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills) | Procedural memory, Skills Hub, creating skills |
|
||||
| [Memory](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | Persistent memory, user profiles, best practices |
|
||||
| [MCP Integration](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | Connect any MCP server for extended capabilities |
|
||||
| [Cron Scheduling](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | Scheduled tasks with platform delivery |
|
||||
| [Context Files](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files) | Project context that shapes every conversation |
|
||||
| [Architecture](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | Project structure, agent loop, key classes |
|
||||
| [Contributing](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | Development setup, PR process, code style |
|
||||
| [CLI Reference](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | All commands and flags |
|
||||
| [Environment Variables](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | Complete env var reference |
|
||||
|
||||
---
|
||||
|
||||
@@ -160,7 +160,6 @@ hermes claw migrate --overwrite # Overwrite existing conflicts
|
||||
```
|
||||
|
||||
What gets imported:
|
||||
|
||||
- **SOUL.md** — persona file
|
||||
- **Memories** — MEMORY.md and USER.md entries
|
||||
- **Skills** — user-created skills → `~/.hermes/skills/openclaw-imports/`
|
||||
@@ -214,3 +213,5 @@ scripts/run_tests.sh
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
Built by [Nous Research](https://nousresearch.com).
|
||||
|
||||
phragg was here
|
||||
|
||||
@@ -1783,25 +1783,11 @@ def _strip_orphaned_tool_blocks(result: List[Dict[str, Any]]) -> None:
|
||||
tool_result_ids.add(block.get("tool_use_id"))
|
||||
for m in result:
|
||||
if m["role"] == "assistant" and isinstance(m["content"], list):
|
||||
kept = [
|
||||
m["content"] = [
|
||||
b
|
||||
for b in m["content"]
|
||||
if b.get("type") != "tool_use" or b.get("id") in tool_result_ids
|
||||
]
|
||||
# If stripping an orphaned tool_use mutated a turn that also carries a
|
||||
# signed thinking block, that block's Anthropic signature was computed
|
||||
# against the ORIGINAL (un-stripped) turn content and is now invalid.
|
||||
# Anthropic rejects the replayed turn with HTTP 400 "thinking blocks in
|
||||
# the latest assistant message cannot be modified". Flag the turn so
|
||||
# _manage_thinking_signatures can demote the dead signature instead of
|
||||
# replaying it verbatim. See hermes-agent: extended-thinking + parallel
|
||||
# tool batch interrupted mid-flight → non-retryable 400 crash-loop.
|
||||
if len(kept) != len(m["content"]) and any(
|
||||
isinstance(b, dict) and b.get("type") in {"thinking", "redacted_thinking"}
|
||||
for b in m["content"]
|
||||
):
|
||||
m["_thinking_signature_invalidated"] = True
|
||||
m["content"] = kept
|
||||
if not m["content"]:
|
||||
m["content"] = [{"type": "text", "text": "(tool call removed)"}]
|
||||
|
||||
@@ -1846,10 +1832,6 @@ def _merge_consecutive_roles(result: List[Dict[str, Any]]) -> List[Dict[str, Any
|
||||
fixed[-1]["content"] = prev_content + curr_content
|
||||
else:
|
||||
# Consecutive assistant messages — merge text content.
|
||||
# Propagate the orphan-strip signature-invalidation flag onto the
|
||||
# surviving (prev) dict so _manage_thinking_signatures still sees it.
|
||||
if m.get("_thinking_signature_invalidated"):
|
||||
fixed[-1]["_thinking_signature_invalidated"] = True
|
||||
# Drop thinking blocks from the *second* message: their
|
||||
# signature was computed against a different turn boundary
|
||||
# and becomes invalid once merged.
|
||||
@@ -1938,26 +1920,11 @@ def _manage_thinking_signatures(
|
||||
else:
|
||||
# Latest assistant on direct Anthropic: keep signed, downgrade unsigned
|
||||
# to text so the reasoning isn't lost.
|
||||
#
|
||||
# Exception: if orphan-stripping (or another structural mutation) removed
|
||||
# a tool_use block from THIS turn, every thinking signature on it was
|
||||
# computed against the original turn content and is now dead. Anthropic
|
||||
# rejects the turn either way — replaying the signed block 400s with
|
||||
# "thinking blocks in the latest assistant message cannot be modified",
|
||||
# and a bare signed block with no following tool_use is also invalid.
|
||||
# Demote ALL thinking blocks on this turn to text so the turn replays
|
||||
# cleanly and the model can re-plan from the surviving tool results.
|
||||
signature_dead = bool(m.get("_thinking_signature_invalidated"))
|
||||
new_content = []
|
||||
for b in m["content"]:
|
||||
if not isinstance(b, dict) or b.get("type") not in _THINKING_TYPES:
|
||||
new_content.append(b)
|
||||
continue
|
||||
if signature_dead:
|
||||
thinking_text = b.get("thinking", "")
|
||||
if thinking_text:
|
||||
new_content.append({"type": "text", "text": thinking_text})
|
||||
continue
|
||||
if b.get("type") == "redacted_thinking":
|
||||
# Redacted blocks use 'data' for the signature payload —
|
||||
# drop the block when 'data' is missing (can't be validated).
|
||||
@@ -1977,9 +1944,6 @@ def _manage_thinking_signatures(
|
||||
if isinstance(b, dict) and b.get("type") in _THINKING_TYPES:
|
||||
b.pop("cache_control", None)
|
||||
|
||||
# Drop the internal bookkeeping flag — it must never reach the API payload.
|
||||
m.pop("_thinking_signature_invalidated", None)
|
||||
|
||||
|
||||
def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
|
||||
"""Keep only the most recent ``_MAX_KEEP_IMAGES`` computer-use screenshots.
|
||||
|
||||
@@ -1680,48 +1680,26 @@ def _read_main_provider() -> str:
|
||||
# per turn — no lock needed. Cleared by ``clear_runtime_main()``.
|
||||
_RUNTIME_MAIN_PROVIDER: str = ""
|
||||
_RUNTIME_MAIN_MODEL: str = ""
|
||||
_RUNTIME_MAIN_BASE_URL: str = ""
|
||||
_RUNTIME_MAIN_API_KEY: str = ""
|
||||
_RUNTIME_MAIN_API_MODE: str = ""
|
||||
|
||||
|
||||
def set_runtime_main(
|
||||
provider: str,
|
||||
model: str,
|
||||
*,
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
api_mode: str = "",
|
||||
) -> None:
|
||||
"""Record the live runtime provider/model/credentials for the current AIAgent.
|
||||
def set_runtime_main(provider: str, model: str) -> None:
|
||||
"""Record the live runtime provider/model for the current AIAgent.
|
||||
|
||||
Called by ``run_agent.AIAgent._sync_runtime_main_for_aux_routing`` (or
|
||||
equivalent setter) at the top of each turn so that
|
||||
``_read_main_provider`` / ``_read_main_model`` reflect CLI/gateway
|
||||
overrides instead of the stale config.yaml default.
|
||||
|
||||
For ``custom:`` providers, ``base_url`` and ``api_key`` must also be
|
||||
recorded so that ``_resolve_auto`` can construct a valid client in
|
||||
Step 1 instead of falling through to the aggregator chain.
|
||||
"""
|
||||
global _RUNTIME_MAIN_PROVIDER, _RUNTIME_MAIN_MODEL
|
||||
global _RUNTIME_MAIN_BASE_URL, _RUNTIME_MAIN_API_KEY, _RUNTIME_MAIN_API_MODE
|
||||
_RUNTIME_MAIN_PROVIDER = (provider or "").strip().lower()
|
||||
_RUNTIME_MAIN_MODEL = (model or "").strip()
|
||||
_RUNTIME_MAIN_BASE_URL = (base_url or "").strip()
|
||||
_RUNTIME_MAIN_API_KEY = api_key.strip() if isinstance(api_key, str) else ""
|
||||
_RUNTIME_MAIN_API_MODE = (api_mode or "").strip()
|
||||
|
||||
|
||||
def clear_runtime_main() -> None:
|
||||
"""Clear the runtime override (e.g. on session end)."""
|
||||
global _RUNTIME_MAIN_PROVIDER, _RUNTIME_MAIN_MODEL
|
||||
global _RUNTIME_MAIN_BASE_URL, _RUNTIME_MAIN_API_KEY, _RUNTIME_MAIN_API_MODE
|
||||
_RUNTIME_MAIN_PROVIDER = ""
|
||||
_RUNTIME_MAIN_MODEL = ""
|
||||
_RUNTIME_MAIN_BASE_URL = ""
|
||||
_RUNTIME_MAIN_API_KEY = ""
|
||||
_RUNTIME_MAIN_API_MODE = ""
|
||||
|
||||
|
||||
def _resolve_custom_runtime() -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
@@ -3002,18 +2980,6 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
|
||||
runtime_api_key = runtime.get("api_key", "")
|
||||
runtime_api_mode = str(runtime.get("api_mode") or "")
|
||||
|
||||
# Fall back to process-local globals when main_runtime dict was not
|
||||
# provided or was incomplete. ``set_runtime_main()`` now records
|
||||
# base_url/api_key/api_mode alongside provider/model, so custom:
|
||||
# providers get the full credential surface in Step 1 of the
|
||||
# auto-detect chain.
|
||||
if not runtime_base_url and _RUNTIME_MAIN_BASE_URL:
|
||||
runtime_base_url = _RUNTIME_MAIN_BASE_URL
|
||||
if not runtime_api_key and _RUNTIME_MAIN_API_KEY:
|
||||
runtime_api_key = _RUNTIME_MAIN_API_KEY
|
||||
if not runtime_api_mode and _RUNTIME_MAIN_API_MODE:
|
||||
runtime_api_mode = _RUNTIME_MAIN_API_MODE
|
||||
|
||||
# ── Warn once if OPENAI_BASE_URL is set but config.yaml uses a named
|
||||
# provider (not 'custom'). This catches the common "env poisoning"
|
||||
# scenario where a user switches providers via `hermes model` but the
|
||||
|
||||
@@ -1283,18 +1283,6 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
|
||||
agent._copy_reasoning_content_for_api(msg, api_msg)
|
||||
for internal_field in ("reasoning", "finish_reason", "_thinking_prefill"):
|
||||
api_msg.pop(internal_field, None)
|
||||
# Strict OpenAI-compatible gateways (Fireworks-backed OpenCode Go,
|
||||
# Mistral, Moonshot/Kimi) reject any message key outside the Chat
|
||||
# Completions schema. The main loop drops these via
|
||||
# ChatCompletionsTransport.convert_messages(), but the summary path
|
||||
# hand-builds messages and calls chat.completions.create() directly,
|
||||
# bypassing the transport — so mirror that sanitization here:
|
||||
# tool_name (SQLite FTS bookkeeping), the codex_* reasoning carriers,
|
||||
# and every Hermes-internal underscore-prefixed scaffolding key.
|
||||
for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items"):
|
||||
api_msg.pop(schema_foreign, None)
|
||||
for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]:
|
||||
api_msg.pop(internal_key, None)
|
||||
if _needs_sanitize:
|
||||
agent._sanitize_tool_calls_for_strict_api(api_msg)
|
||||
api_messages.append(api_msg)
|
||||
|
||||
+12
-67
@@ -40,46 +40,16 @@ SUMMARY_PREFIX = (
|
||||
"window — treat it as background reference, NOT as active instructions. "
|
||||
"Do NOT answer questions or fulfill requests mentioned in this summary; "
|
||||
"they were already addressed. "
|
||||
"Respond ONLY to the latest user message that appears AFTER this "
|
||||
"summary — that message is the single source of truth for what to do "
|
||||
"right now. "
|
||||
"If the latest user message is consistent with the '## Active Task' "
|
||||
"section, you may use the summary as background. If the latest user "
|
||||
"message contradicts, supersedes, changes topic from, or in any way "
|
||||
"diverges from '## Active Task' / '## In Progress' / '## Pending User "
|
||||
"Asks' / '## Remaining Work', the latest message WINS — discard those "
|
||||
"stale items entirely and do not 'wrap up the old task first'. "
|
||||
"Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
|
||||
"back', 'just verify', 'don't do that anymore', 'never mind', a new "
|
||||
"topic) must immediately end any in-flight work described in the "
|
||||
"summary; do not re-surface it in later turns. "
|
||||
"Your current task is identified in the '## Active Task' section of the "
|
||||
"summary — resume exactly from there. "
|
||||
"IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
|
||||
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
|
||||
"memory content due to this compaction note. "
|
||||
"The current session state (files, config, etc.) may reflect work "
|
||||
"described here — avoid repeating it:"
|
||||
)
|
||||
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
|
||||
|
||||
# Handoff prefixes that shipped in earlier releases. A summary persisted under
|
||||
# one of these can be inherited into a resumed lineage (#35344); when it is
|
||||
# re-normalized on re-compaction we must strip the OLD prefix too, otherwise the
|
||||
# stale directive it carried (e.g. "resume exactly from Active Task") survives
|
||||
# embedded in the body and keeps hijacking replies. Keep newest-first; entries
|
||||
# are matched literally. Add a frozen copy here whenever SUMMARY_PREFIX changes.
|
||||
_HISTORICAL_SUMMARY_PREFIXES = (
|
||||
# Pre-#35344: contained the self-contradicting "resume exactly" directive.
|
||||
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
|
||||
"into the summary below. This is a handoff from a previous context "
|
||||
"window — treat it as background reference, NOT as active instructions. "
|
||||
"Do NOT answer questions or fulfill requests mentioned in this summary; "
|
||||
"they were already addressed. "
|
||||
"Your current task is identified in the '## Active Task' section of the "
|
||||
"summary — resume exactly from there. "
|
||||
"Respond ONLY to the latest user message "
|
||||
"that appears AFTER this summary. The current session state (files, "
|
||||
"config, etc.) may reflect work described here — avoid repeating it:",
|
||||
"config, etc.) may reflect work described here — avoid repeating it:"
|
||||
)
|
||||
LEGACY_SUMMARY_PREFIX = "[CONTEXT SUMMARY]:"
|
||||
|
||||
# Minimum tokens for the summary output
|
||||
_MIN_SUMMARY_TOKENS = 2000
|
||||
@@ -1266,27 +1236,11 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
|
||||
|
||||
# Shared structured template (used by both paths).
|
||||
_template_sections = f"""## Active Task
|
||||
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
|
||||
input verbatim — the exact words they used. This includes:
|
||||
- Explicit task assignments ("refactor the auth module")
|
||||
- Questions awaiting an answer ("waarom staat X op Y?", "wat zijn de volgende stappen?")
|
||||
- Decisions awaiting input ("optie A of B?")
|
||||
- Ongoing discussions where the assistant owes the next substantive reply
|
||||
A conversation where the user just asked a question IS an active task — the
|
||||
task is "answer that question with full context". Do NOT write "None" merely
|
||||
because the user did not issue an imperative command; reserve "None" for the
|
||||
rare case where the last exchange was fully resolved and the user said
|
||||
something like "thanks, that's all".
|
||||
If multiple items are outstanding, list only the ones NOT yet completed.
|
||||
Continuation should pick up exactly here. Examples:
|
||||
[THE SINGLE MOST IMPORTANT FIELD. Copy the user's most recent request or
|
||||
task assignment verbatim — the exact words they used. If multiple tasks
|
||||
were requested and only some are done, list only the ones NOT yet completed.
|
||||
Continuation should pick up exactly here. Example:
|
||||
"User asked: 'Now refactor the auth module to use JWT instead of sessions'"
|
||||
"User asked: 'Waarom stond provider ineens op openrouter?' — needs investigation + answer"
|
||||
"User chose option A; awaiting implementation of step 2"
|
||||
If the user's most recent message was a reverse signal (stop, undo, roll
|
||||
back, never mind, just verify, change of topic) that supersedes earlier
|
||||
work, write the reverse signal verbatim and DO NOT carry forward the
|
||||
cancelled task. Example: "User asked: 'Stop the i18n refactor and just
|
||||
verify the current diff' — earlier i18n in-flight work is cancelled."
|
||||
If no outstanding task exists, write "None."]
|
||||
|
||||
## Goal
|
||||
@@ -1352,7 +1306,7 @@ PREVIOUS SUMMARY:
|
||||
NEW TURNS TO INCORPORATE:
|
||||
{content_to_summarize}
|
||||
|
||||
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new completed actions to the numbered list (continue numbering). Move items from "In Progress" to "Completed Actions" when done. Move answered questions to "Resolved Questions". Update "Active State" to reflect current state. Remove information only if it is clearly obsolete. CRITICAL: Update "## Active Task" to reflect the user's most recent unfulfilled input — this includes any question, decision request, or discussion turn that the assistant has not yet answered. Only write "None" if the last exchange was fully resolved.
|
||||
Update the summary using this exact structure. PRESERVE all existing information that is still relevant. ADD new completed actions to the numbered list (continue numbering). Move items from "In Progress" to "Completed Actions" when done. Move answered questions to "Resolved Questions". Update "Active State" to reflect current state. Remove information only if it is clearly obsolete. CRITICAL: Update "## Active Task" to reflect the user's most recent unfulfilled request — this is the most important field for task continuity.
|
||||
|
||||
{_template_sections}"""
|
||||
else:
|
||||
@@ -1516,16 +1470,9 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
|
||||
@staticmethod
|
||||
def _strip_summary_prefix(summary: str) -> str:
|
||||
"""Return summary body without the current, legacy, or any historical
|
||||
handoff prefix.
|
||||
|
||||
Historical prefixes must be stripped too: a handoff persisted under an
|
||||
older prefix can be inherited into a resumed lineage (#35344), and if we
|
||||
only re-prepend the current prefix without removing the old one, the
|
||||
stale directive it carried stays embedded in the body.
|
||||
"""
|
||||
"""Return summary body without the current or legacy handoff prefix."""
|
||||
text = (summary or "").strip()
|
||||
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX, *_HISTORICAL_SUMMARY_PREFIXES):
|
||||
for prefix in (SUMMARY_PREFIX, LEGACY_SUMMARY_PREFIX):
|
||||
if text.startswith(prefix):
|
||||
return text[len(prefix):].lstrip()
|
||||
return text
|
||||
@@ -1539,9 +1486,7 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
@staticmethod
|
||||
def _is_context_summary_content(content: Any) -> bool:
|
||||
text = _content_text_for_contains(content).lstrip()
|
||||
if text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX):
|
||||
return True
|
||||
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
|
||||
return text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX)
|
||||
|
||||
@classmethod
|
||||
def _find_latest_context_summary(
|
||||
|
||||
@@ -115,15 +115,6 @@ class ContextEngine(ABC):
|
||||
"""
|
||||
return False
|
||||
|
||||
def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
|
||||
"""Return True when preflight should trust recent real usage instead.
|
||||
|
||||
Built-in compression uses this to avoid re-compacting from known-noisy
|
||||
rough estimates after a compressed request has already fit. Third-party
|
||||
engines can ignore it safely.
|
||||
"""
|
||||
return False
|
||||
|
||||
# -- Optional: manual /compress preflight ------------------------------
|
||||
|
||||
def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:
|
||||
|
||||
@@ -575,18 +575,19 @@ def compress_context(
|
||||
force=True,
|
||||
)
|
||||
|
||||
# Keep the post-compression rough estimate for diagnostics, but do not
|
||||
# treat it as provider-reported prompt usage. Schema-heavy rough estimates
|
||||
# can remain above threshold even after the next real API request fits.
|
||||
# Update token estimate after compaction so pressure calculations
|
||||
# use the post-compression count, not the stale pre-compression one.
|
||||
# Use estimate_request_tokens_rough() so tool schemas are included —
|
||||
# with 50+ tools enabled, schemas alone can add 20-30K tokens, and
|
||||
# omitting them delays the next compression cycle far past the
|
||||
# configured threshold (issue #14695).
|
||||
_compressed_est = estimate_request_tokens_rough(
|
||||
compressed,
|
||||
system_prompt=new_system_prompt or "",
|
||||
tools=agent.tools or None,
|
||||
)
|
||||
agent.context_compressor.last_compression_rough_tokens = _compressed_est
|
||||
agent.context_compressor.last_prompt_tokens = -1
|
||||
agent.context_compressor.last_prompt_tokens = _compressed_est
|
||||
agent.context_compressor.last_completion_tokens = 0
|
||||
agent.context_compressor.awaiting_real_usage_after_compression = True
|
||||
|
||||
# Clear the file-read dedup cache. After compression the original
|
||||
# read content is summarised away — if the model re-reads the same
|
||||
@@ -598,7 +599,7 @@ def compress_context(
|
||||
pass
|
||||
|
||||
logger.info(
|
||||
"context compression done: session=%s messages=%d->%d rough_tokens=~%s awaiting_real_usage=true",
|
||||
"context compression done: session=%s messages=%d->%d tokens=~%s",
|
||||
agent.session_id or "none", _pre_msg_count, len(compressed),
|
||||
f"{_compressed_est:,}",
|
||||
)
|
||||
@@ -644,12 +645,6 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
# after a confirmed provider rejection, so the alternative is failure.
|
||||
target_bytes = 4 * 1024 * 1024
|
||||
changed_count = 0
|
||||
# Track parts that are over the target but could NOT be shrunk under it.
|
||||
# If any survive, retrying is pointless — the same oversized payload will
|
||||
# be re-sent and rejected again, wasting the single retry budget. We only
|
||||
# report success (caller retries) when every over-threshold image was
|
||||
# actually brought under the target.
|
||||
unshrinkable_oversized = 0
|
||||
|
||||
def _shrink_data_url(url: str) -> Optional[str]:
|
||||
"""Return a smaller data URL, or None if shrink can't help."""
|
||||
@@ -716,34 +711,17 @@ def try_shrink_image_parts_in_messages(api_messages: list) -> bool:
|
||||
if resized:
|
||||
image_value["url"] = resized
|
||||
changed_count += 1
|
||||
elif isinstance(url, str) and url.startswith("data:") \
|
||||
and len(url) > target_bytes:
|
||||
unshrinkable_oversized += 1
|
||||
elif isinstance(image_value, str):
|
||||
resized = _shrink_data_url(image_value)
|
||||
if resized:
|
||||
part["image_url"] = resized
|
||||
changed_count += 1
|
||||
elif image_value.startswith("data:") \
|
||||
and len(image_value) > target_bytes:
|
||||
unshrinkable_oversized += 1
|
||||
|
||||
if changed_count:
|
||||
logger.info(
|
||||
"image-shrink recovery: re-encoded %d image part(s) to fit under %.0f MB",
|
||||
changed_count, target_bytes / (1024 * 1024),
|
||||
)
|
||||
if unshrinkable_oversized:
|
||||
# At least one oversized image could not be shrunk under the target.
|
||||
# Retrying would re-send it and fail identically, so signal "no
|
||||
# progress" even if other parts shrank — the caller will surface the
|
||||
# original error rather than burning its single retry on a no-op.
|
||||
logger.warning(
|
||||
"image-shrink recovery: %d oversized image part(s) could not be "
|
||||
"shrunk under %.0f MB — not retrying (would re-send rejected payload)",
|
||||
unshrinkable_oversized, target_bytes / (1024 * 1024),
|
||||
)
|
||||
return False
|
||||
return changed_count > 0
|
||||
|
||||
|
||||
|
||||
+24
-164
@@ -392,9 +392,6 @@ def run_conversation(
|
||||
set_runtime_main(
|
||||
getattr(agent, "provider", "") or "",
|
||||
getattr(agent, "model", "") or "",
|
||||
base_url=getattr(agent, "base_url", "") or "",
|
||||
api_key=getattr(agent, "api_key", "") or "",
|
||||
api_mode=getattr(agent, "api_mode", "") or "",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -603,50 +600,18 @@ def run_conversation(
|
||||
system_prompt=active_system_prompt or "",
|
||||
tools=agent.tools or None,
|
||||
)
|
||||
_compressor = agent.context_compressor
|
||||
_defer_preflight = getattr(
|
||||
_compressor,
|
||||
"should_defer_preflight_to_real_usage",
|
||||
lambda _tokens: False,
|
||||
)
|
||||
_preflight_deferred = _defer_preflight(_preflight_tokens)
|
||||
|
||||
if not _preflight_deferred:
|
||||
# Keep the CLI/ACP context display in sync with what preflight
|
||||
# actually measured. The status bar reads
|
||||
# ``compressor.last_prompt_tokens``, which otherwise only updates
|
||||
# from a *successful* API response. When the conversation has grown
|
||||
# since the last successful call — or when compression then fails
|
||||
# (e.g. the auxiliary summary model times out) and no fresh usage
|
||||
# arrives — the bar stays stuck at the old, smaller value while
|
||||
# preflight reports a much larger number, looking out of sync.
|
||||
# Seed it with the fresh estimate (only ever revising upward; a real
|
||||
# ``update_from_response`` will correct it after the next API call).
|
||||
# Skipped when deferring — a deferred estimate is known to over-count
|
||||
# vs the last real provider prompt, so trusting it for the display
|
||||
# would re-introduce the very desync we're avoiding.
|
||||
if _preflight_tokens > (_compressor.last_prompt_tokens or 0):
|
||||
_compressor.last_prompt_tokens = _preflight_tokens
|
||||
|
||||
if _preflight_deferred:
|
||||
logger.info(
|
||||
"Skipping preflight compression: rough estimate ~%s >= %s, "
|
||||
"but last real provider prompt was %s after compression",
|
||||
f"{_preflight_tokens:,}",
|
||||
f"{_compressor.threshold_tokens:,}",
|
||||
f"{_compressor.last_real_prompt_tokens:,}",
|
||||
)
|
||||
elif _compressor.should_compress(_preflight_tokens):
|
||||
if agent.context_compressor.should_compress(_preflight_tokens):
|
||||
logger.info(
|
||||
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
|
||||
f"{_preflight_tokens:,}",
|
||||
f"{_compressor.threshold_tokens:,}",
|
||||
f"{agent.context_compressor.threshold_tokens:,}",
|
||||
agent.model,
|
||||
f"{_compressor.context_length:,}",
|
||||
f"{agent.context_compressor.context_length:,}",
|
||||
)
|
||||
agent._emit_status(
|
||||
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
|
||||
f">= {_compressor.threshold_tokens:,} threshold. "
|
||||
f">= {agent.context_compressor.threshold_tokens:,} threshold. "
|
||||
"This may take a moment."
|
||||
)
|
||||
# May need multiple passes for very large sessions with small
|
||||
@@ -681,8 +646,8 @@ def run_conversation(
|
||||
system_prompt=active_system_prompt or "",
|
||||
tools=agent.tools or None,
|
||||
)
|
||||
if not _compressor.should_compress(_preflight_tokens):
|
||||
break # Under threshold or anti-thrash guard stopped it
|
||||
if _preflight_tokens < agent.context_compressor.threshold_tokens:
|
||||
break # Under threshold
|
||||
|
||||
# Plugin hook: pre_llm_call
|
||||
# Fired once per turn before the tool-calling loop. Plugins can
|
||||
@@ -1492,8 +1457,7 @@ def run_conversation(
|
||||
|
||||
if retry_count >= max_retries:
|
||||
# Try fallback before giving up
|
||||
if agent._has_pending_fallback():
|
||||
agent._buffer_status(f"⚠️ Max retries ({max_retries}) for invalid responses — trying fallback...")
|
||||
agent._buffer_status(f"⚠️ Max retries ({max_retries}) for invalid responses — trying fallback...")
|
||||
if agent._try_activate_fallback():
|
||||
retry_count = 0
|
||||
compression_attempts = 0
|
||||
@@ -1739,52 +1703,20 @@ def run_conversation(
|
||||
if agent.api_mode in {"chat_completions", "bedrock_converse", "anthropic_messages"}:
|
||||
assistant_message = _trunc_msg
|
||||
if assistant_message is not None and _trunc_has_tool_calls:
|
||||
_is_stub_stall = (
|
||||
getattr(response, "id", "") == PARTIAL_STREAM_STUB_ID
|
||||
)
|
||||
if truncated_tool_call_retries < 3:
|
||||
if truncated_tool_call_retries < 1:
|
||||
truncated_tool_call_retries += 1
|
||||
if _is_stub_stall:
|
||||
# The stream broke mid tool-call (network /
|
||||
# peer-closed connection), not a real output
|
||||
# cap — say so instead of "max output tokens".
|
||||
agent._buffer_vprint(
|
||||
f"⚠️ Stream interrupted mid tool-call — "
|
||||
f"retrying ({truncated_tool_call_retries}/3)..."
|
||||
)
|
||||
else:
|
||||
agent._buffer_vprint(
|
||||
f"⚠️ Truncated tool call detected — "
|
||||
f"retrying API call "
|
||||
f"({truncated_tool_call_retries}/3)..."
|
||||
)
|
||||
# Boost max_tokens on each retry so the model has
|
||||
# more room to complete the tool-call JSON. A
|
||||
# network stall doesn't need a bigger budget, but
|
||||
# a genuine output-cap truncation does, and the
|
||||
# boost is harmless for the stall case.
|
||||
_tc_boost_base = agent.max_tokens if agent.max_tokens else 4096
|
||||
_tc_boost = _tc_boost_base * (truncated_tool_call_retries + 1)
|
||||
_tc_requested_cap = agent._requested_output_cap_from_api_kwargs(api_kwargs)
|
||||
if _tc_requested_cap is not None:
|
||||
_tc_boost = max(_tc_boost, _tc_requested_cap)
|
||||
_tc_boost_cap = max(32768, _tc_requested_cap or 0)
|
||||
agent._ephemeral_max_output_tokens = min(_tc_boost, _tc_boost_cap)
|
||||
agent._buffer_vprint(
|
||||
f"⚠️ Truncated tool call detected — retrying API call..."
|
||||
)
|
||||
# Don't append the broken response to messages;
|
||||
# just re-run the same API call from the current
|
||||
# message state, giving the model another chance.
|
||||
continue
|
||||
agent._flush_status_buffer()
|
||||
if _is_stub_stall:
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}⚠️ Stream kept dropping mid tool-call after 3 retries — the action was not executed.",
|
||||
force=True,
|
||||
)
|
||||
else:
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}⚠️ Truncated tool call response detected again — refusing to execute incomplete tool arguments.",
|
||||
force=True,
|
||||
)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}⚠️ Truncated tool call response detected again — refusing to execute incomplete tool arguments.",
|
||||
force=True,
|
||||
)
|
||||
agent._cleanup_task_resources(effective_task_id)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
return {
|
||||
@@ -1793,12 +1725,7 @@ def run_conversation(
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
"partial": True,
|
||||
"error": (
|
||||
"Stream repeatedly dropped mid tool-call (network); "
|
||||
"the tool was not executed"
|
||||
if _is_stub_stall
|
||||
else "Response truncated due to output length limit"
|
||||
),
|
||||
"error": "Response truncated due to output length limit",
|
||||
}
|
||||
|
||||
# If we have prior messages, roll back to last complete state
|
||||
@@ -3132,17 +3059,12 @@ def run_conversation(
|
||||
) and not is_context_length_error
|
||||
|
||||
if is_client_error:
|
||||
# Try fallback before aborting — a different provider may
|
||||
# not have the same issue (rate limit, auth, etc.). Only
|
||||
# announce the attempt when a fallback chain actually
|
||||
# exists; otherwise "trying fallback..." is a lie and the
|
||||
# session looks like it's recovering when it's about to
|
||||
# abort silently (#35314, #17446).
|
||||
if agent._has_pending_fallback():
|
||||
if classified.reason == FailoverReason.content_policy_blocked:
|
||||
agent._buffer_status("⚠️ Provider safety filter blocked this request — trying fallback...")
|
||||
else:
|
||||
agent._buffer_status(f"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...")
|
||||
# Try fallback before aborting — a different provider
|
||||
# may not have the same issue (rate limit, auth, etc.)
|
||||
if classified.reason == FailoverReason.content_policy_blocked:
|
||||
agent._buffer_status("⚠️ Provider safety filter blocked this request — trying fallback...")
|
||||
else:
|
||||
agent._buffer_status(f"⚠️ Non-retryable error (HTTP {status_code}) — trying fallback...")
|
||||
if agent._try_activate_fallback():
|
||||
retry_count = 0
|
||||
compression_attempts = 0
|
||||
@@ -3285,8 +3207,7 @@ def run_conversation(
|
||||
retry_count = 0
|
||||
continue
|
||||
# Try fallback before giving up entirely
|
||||
if agent._has_pending_fallback():
|
||||
agent._buffer_status(f"⚠️ Max retries ({max_retries}) exhausted — trying fallback...")
|
||||
agent._buffer_status(f"⚠️ Max retries ({max_retries}) exhausted — trying fallback...")
|
||||
if agent._try_activate_fallback():
|
||||
retry_count = 0
|
||||
compression_attempts = 0
|
||||
@@ -3449,16 +3370,9 @@ def run_conversation(
|
||||
# Progressively boost the output token budget on each retry.
|
||||
# Retry 1 → 2× base, retry 2 → 3× base, capped at 32 768.
|
||||
# Applies to all providers via _ephemeral_max_output_tokens.
|
||||
# If the original request already used a larger provider/model
|
||||
# default budget, keep that floor so continuation retries do
|
||||
# not accidentally downshift to a much smaller cap.
|
||||
_boost_base = agent.max_tokens if agent.max_tokens else 4096
|
||||
_boost = _boost_base * (length_continue_retries + 1)
|
||||
_requested_cap = agent._requested_output_cap_from_api_kwargs(api_kwargs)
|
||||
if _requested_cap is not None:
|
||||
_boost = max(_boost, _requested_cap)
|
||||
_boost_cap = max(32768, _requested_cap or 0)
|
||||
agent._ephemeral_max_output_tokens = min(_boost, _boost_cap)
|
||||
agent._ephemeral_max_output_tokens = min(_boost, 32768)
|
||||
continue
|
||||
|
||||
# Guard: if all retries exhausted without a successful response
|
||||
@@ -3948,11 +3862,6 @@ def run_conversation(
|
||||
# inflate completion_tokens with reasoning,
|
||||
# causing premature compression. (#12026)
|
||||
_real_tokens = _compressor.last_prompt_tokens
|
||||
elif _compressor.last_prompt_tokens == -1:
|
||||
# Compression just ran and no API-reported prompt count
|
||||
# has arrived yet. Avoid treating a schema-heavy rough
|
||||
# post-compression estimate as real context pressure.
|
||||
_real_tokens = 0
|
||||
else:
|
||||
# Include tool schemas — with 50+ tools enabled
|
||||
# these add 20-30K tokens the messages-only
|
||||
@@ -4534,55 +4443,6 @@ def run_conversation(
|
||||
except Exception as _ver_err:
|
||||
logger.debug("file-mutation verifier footer failed: %s", _ver_err)
|
||||
|
||||
# Turn-completion explainer.
|
||||
# When a turn ends abnormally after substantive work — empty content
|
||||
# after retries, a partial/truncated stream, a still-pending tool
|
||||
# result, or an iteration/budget limit — the user otherwise gets a
|
||||
# blank or fragmentary response box with no consolidated reason why
|
||||
# the agent stopped (#34452). Surface a single user-visible
|
||||
# explanation derived from ``_turn_exit_reason``, mirroring the
|
||||
# file-mutation verifier footer pattern above.
|
||||
#
|
||||
# Gate carefully so healthy turns stay quiet:
|
||||
# - ``text_response(...)`` exits never produce an explanation
|
||||
# (handled inside the formatter), so a terse ``Done.`` is silent.
|
||||
# - We only ACT when there is no genuinely usable reply this turn:
|
||||
# an empty response, the "(empty)" terminal sentinel, or a
|
||||
# suspiciously short partial fragment with no terminating
|
||||
# punctuation (e.g. "The"). A real short answer keeps its text.
|
||||
if not interrupted:
|
||||
try:
|
||||
if agent._turn_completion_explainer_enabled():
|
||||
_stripped = (final_response or "").strip()
|
||||
_is_empty_terminal = _stripped == "" or _stripped == "(empty)"
|
||||
# A short fragment that is not a normal text_response exit
|
||||
# and lacks sentence-ending punctuation is treated as a
|
||||
# truncated partial (the "The" case from #34452).
|
||||
_is_partial_fragment = (
|
||||
not _is_empty_terminal
|
||||
and not str(_turn_exit_reason).startswith("text_response")
|
||||
and len(_stripped) <= 24
|
||||
and _stripped[-1:] not in {".", "!", "?", "。", "!", "?", "`", ")"}
|
||||
)
|
||||
if _is_empty_terminal or _is_partial_fragment:
|
||||
_explanation = agent._format_turn_completion_explanation(
|
||||
_turn_exit_reason
|
||||
)
|
||||
if _explanation:
|
||||
if _is_empty_terminal:
|
||||
# Replace the bare "(empty)"/blank sentinel with
|
||||
# the actionable explanation.
|
||||
final_response = _explanation
|
||||
else:
|
||||
# Keep the partial fragment, append the reason so
|
||||
# the user sees both what arrived and why it
|
||||
# stopped.
|
||||
final_response = (
|
||||
_stripped + "\n\n" + _explanation
|
||||
)
|
||||
except Exception as _exp_err:
|
||||
logger.debug("turn-completion explainer failed: %s", _exp_err)
|
||||
|
||||
_response_transformed = False
|
||||
|
||||
# Plugin hook: transform_llm_output
|
||||
|
||||
+5
-48
@@ -183,18 +183,6 @@ def get_archive_after_days() -> int:
|
||||
return DEFAULT_ARCHIVE_AFTER_DAYS
|
||||
|
||||
|
||||
def get_prune_builtins() -> bool:
|
||||
"""Whether the curator may prune (archive) bundled built-in skills too.
|
||||
|
||||
ON by default. When on, built-ins become curation candidates and are
|
||||
archived after the same inactivity period as agent-created skills, with a
|
||||
suppression list keeping them archived across `hermes update` re-seeds.
|
||||
Hub-installed skills are never pruned regardless of this flag.
|
||||
"""
|
||||
cfg = _load_config()
|
||||
return bool(cfg.get("prune_builtins", True))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Idle / interval check
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -266,17 +254,9 @@ def should_run_now(now: Optional[datetime] = None) -> bool:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int]:
|
||||
"""Walk every curator-managed skill and move active/stale/archived based on
|
||||
"""Walk every agent-created skill and move active/stale/archived based on
|
||||
the latest real activity timestamp. Pinned skills are never touched.
|
||||
|
||||
Built-ins (eligible only when ``curator.prune_builtins`` is on) are seeded
|
||||
with a baseline record the first time they're seen so their inactivity
|
||||
clock starts NOW rather than at epoch — a long-unused built-in is therefore
|
||||
archived only after a fresh ``archive_after_days`` of non-use, not on the
|
||||
first pass after the flag flips on.
|
||||
|
||||
Returns a counter dict describing what changed.
|
||||
"""
|
||||
Returns a counter dict describing what changed."""
|
||||
from tools import skill_usage as _u
|
||||
|
||||
if now is None:
|
||||
@@ -284,7 +264,7 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
|
||||
stale_cutoff = now - timedelta(days=get_stale_after_days())
|
||||
archive_cutoff = now - timedelta(days=get_archive_after_days())
|
||||
|
||||
counts = {"marked_stale": 0, "archived": 0, "reactivated": 0, "checked": 0, "seeded": 0}
|
||||
counts = {"marked_stale": 0, "archived": 0, "reactivated": 0, "checked": 0}
|
||||
|
||||
for row in _u.agent_created_report():
|
||||
counts["checked"] += 1
|
||||
@@ -292,13 +272,6 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int
|
||||
if row.get("pinned"):
|
||||
continue
|
||||
|
||||
# First sight of a curation-eligible skill with no persisted record
|
||||
# (e.g. a newly-eligible built-in): anchor its clock to now and defer.
|
||||
if not row.get("_persisted", True):
|
||||
_u.seed_record_if_missing(name)
|
||||
counts["seeded"] += 1
|
||||
continue
|
||||
|
||||
last_activity = _parse_iso(row.get("last_activity_at"))
|
||||
# If never active, treat created_at as the anchor so new skills don't
|
||||
# immediately archive themselves.
|
||||
@@ -1511,30 +1484,14 @@ def run_curator_review(
|
||||
"error": None,
|
||||
}
|
||||
else:
|
||||
# When pruning built-ins is enabled, the candidate list now
|
||||
# includes bundled skills. Override the default "don't touch
|
||||
# bundled" rule for them — but only archiving is permitted, and
|
||||
# hub-installed skills remain strictly off-limits.
|
||||
builtins_note = ""
|
||||
if get_prune_builtins():
|
||||
builtins_note = (
|
||||
"\n\nPRUNE-BUILTINS MODE IS ON: bundled built-in skills "
|
||||
"ARE included in the candidate list below and MAY be "
|
||||
"archived for staleness/irrelevance, overriding hard "
|
||||
"rule #1 for bundled skills ONLY. Hub-installed skills "
|
||||
"remain strictly off-limits. Treat a stale built-in the "
|
||||
"same as a stale agent-created skill: archive it (never "
|
||||
"delete). It will be restored on `hermes update` only if "
|
||||
"the user explicitly restores it."
|
||||
)
|
||||
if dry_run:
|
||||
prompt = (
|
||||
f"{CURATOR_DRY_RUN_BANNER}\n\n"
|
||||
f"{CURATOR_REVIEW_PROMPT}{builtins_note}\n\n"
|
||||
f"{CURATOR_REVIEW_PROMPT}\n\n"
|
||||
f"{candidate_list}"
|
||||
)
|
||||
else:
|
||||
prompt = f"{CURATOR_REVIEW_PROMPT}{builtins_note}\n\n{candidate_list}"
|
||||
prompt = f"{CURATOR_REVIEW_PROMPT}\n\n{candidate_list}"
|
||||
llm_meta = _run_llm_review(prompt)
|
||||
final_summary = (
|
||||
f"{prefix}{auto_summary}; llm: {llm_meta.get('summary', 'no change')}"
|
||||
|
||||
@@ -21,8 +21,6 @@ It DOES include:
|
||||
pointer — otherwise the curator would immediately re-fire on the next
|
||||
tick)
|
||||
- ``.bundled_manifest`` (so protection markers stay consistent)
|
||||
- ``.curator_suppressed`` (so rollback restores the set of pruned built-ins
|
||||
the re-seeder must leave archived)
|
||||
|
||||
Alongside the skills tarball, each snapshot also captures a copy of
|
||||
``~/.hermes/cron/jobs.json`` as ``cron-jobs.json`` when it exists. Cron
|
||||
|
||||
+13
-6
@@ -247,13 +247,18 @@ def _cmd_restart() -> int:
|
||||
|
||||
|
||||
def _cmd_which(server_id: str) -> int:
|
||||
from agent.lsp.install import INSTALL_RECIPES, _existing_binary
|
||||
from agent.lsp.install import INSTALL_RECIPES, hermes_lsp_bin_dir
|
||||
import shutil as _shutil
|
||||
|
||||
recipe = INSTALL_RECIPES.get(server_id)
|
||||
bin_name = (recipe or {}).get("bin", server_id)
|
||||
resolved = _existing_binary(bin_name)
|
||||
if resolved:
|
||||
sys.stdout.write(resolved + "\n")
|
||||
staged = hermes_lsp_bin_dir() / bin_name
|
||||
if staged.exists():
|
||||
sys.stdout.write(str(staged) + "\n")
|
||||
return 0
|
||||
on_path = _shutil.which(bin_name)
|
||||
if on_path:
|
||||
sys.stdout.write(on_path + "\n")
|
||||
return 0
|
||||
sys.stderr.write(f"{server_id}: not installed\n")
|
||||
return 1
|
||||
@@ -287,9 +292,11 @@ def _backend_warnings() -> list:
|
||||
suggestion across common platforms.
|
||||
"""
|
||||
import shutil as _shutil
|
||||
from agent.lsp.install import _existing_binary
|
||||
from agent.lsp.install import hermes_lsp_bin_dir
|
||||
notes: list = []
|
||||
bash_installed = _existing_binary("bash-language-server") is not None
|
||||
bash_installed = _shutil.which("bash-language-server") is not None or (
|
||||
(hermes_lsp_bin_dir() / "bash-language-server").exists()
|
||||
)
|
||||
if bash_installed and _shutil.which("shellcheck") is None:
|
||||
notes.append(
|
||||
"bash-language-server is installed but shellcheck is missing — "
|
||||
|
||||
+3
-16
@@ -44,7 +44,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
|
||||
from urllib.parse import quote, unquote
|
||||
@@ -245,27 +244,15 @@ class LSPClient:
|
||||
await self._cleanup_process()
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _win_wrap_cmd(cmd: List[str]) -> List[str]:
|
||||
"""On Windows, wrap .cmd/.bat shims so CreateProcess can run them."""
|
||||
exe = cmd[0]
|
||||
if exe.lower().endswith((".cmd", ".bat")):
|
||||
return ["cmd.exe", "/c", *cmd]
|
||||
return cmd
|
||||
|
||||
async def _spawn(self) -> None:
|
||||
env = dict(os.environ)
|
||||
if self._env:
|
||||
env.update(self._env)
|
||||
|
||||
cmd = self._command
|
||||
if sys.platform == "win32":
|
||||
cmd = self._win_wrap_cmd(cmd)
|
||||
|
||||
try:
|
||||
self._proc = await asyncio.create_subprocess_exec(
|
||||
cmd[0],
|
||||
*cmd[1:],
|
||||
self._command[0],
|
||||
*self._command[1:],
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
@@ -274,7 +261,7 @@ class LSPClient:
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise LSPProtocolError(
|
||||
f"LSP server binary not found: {cmd[0]} ({e})"
|
||||
f"LSP server binary not found: {self._command[0]} ({e})"
|
||||
) from e
|
||||
|
||||
# Drain stderr at debug level — if we don't, the pipe buffer
|
||||
|
||||
+23
-47
@@ -108,11 +108,6 @@ INSTALL_RECIPES: Dict[str, Dict[str, Any]] = {
|
||||
_install_locks: Dict[str, threading.Lock] = {}
|
||||
_install_results: Dict[str, Optional[str]] = {}
|
||||
_install_lock_meta = threading.Lock()
|
||||
_WINDOWS_WRAPPER_SUFFIXES = (".cmd", ".exe", ".bat")
|
||||
|
||||
|
||||
def _is_windows() -> bool:
|
||||
return os.name == "nt"
|
||||
|
||||
|
||||
def hermes_lsp_bin_dir() -> Path:
|
||||
@@ -125,33 +120,14 @@ def hermes_lsp_bin_dir() -> Path:
|
||||
return p
|
||||
|
||||
|
||||
def _native_binary_candidates(base: Path) -> list[Path]:
|
||||
"""Return platform-native executable candidates for a staged binary."""
|
||||
candidates = [base]
|
||||
if _is_windows():
|
||||
existing = {str(base).lower()}
|
||||
for suffix in _WINDOWS_WRAPPER_SUFFIXES:
|
||||
candidate = Path(str(base) + suffix)
|
||||
key = str(candidate).lower()
|
||||
if key not in existing:
|
||||
candidates.append(candidate)
|
||||
existing.add(key)
|
||||
return candidates
|
||||
|
||||
|
||||
def _existing_binary(name: str) -> Optional[str]:
|
||||
"""Probe the staging dir + PATH for a binary named ``name``."""
|
||||
for staged in _native_binary_candidates(hermes_lsp_bin_dir() / name):
|
||||
if staged.exists() and os.access(staged, os.X_OK):
|
||||
return str(staged)
|
||||
staged = hermes_lsp_bin_dir() / name
|
||||
if staged.exists() and os.access(staged, os.X_OK):
|
||||
return str(staged)
|
||||
on_path = shutil.which(name)
|
||||
if on_path:
|
||||
return on_path
|
||||
if _is_windows():
|
||||
for suffix in _WINDOWS_WRAPPER_SUFFIXES:
|
||||
on_path = shutil.which(f"{name}{suffix}")
|
||||
if on_path:
|
||||
return on_path
|
||||
return None
|
||||
|
||||
|
||||
@@ -274,7 +250,12 @@ def _install_npm(
|
||||
|
||||
# Find the bin
|
||||
nm_bin = staging / "node_modules" / ".bin" / bin_name
|
||||
for c in _native_binary_candidates(nm_bin):
|
||||
if os.name == "nt":
|
||||
# On Windows npm sometimes drops `.cmd` shims
|
||||
candidates = [nm_bin, nm_bin.with_suffix(".cmd")]
|
||||
else:
|
||||
candidates = [nm_bin]
|
||||
for c in candidates:
|
||||
if c.exists():
|
||||
# Symlink into our `lsp/bin/` for stable PATH access.
|
||||
link = hermes_lsp_bin_dir() / c.name
|
||||
@@ -320,7 +301,7 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
|
||||
logger.warning("[install] go install errored for %s: %s", pkg, e)
|
||||
return None
|
||||
bin_path = staging / bin_name
|
||||
if _is_windows():
|
||||
if os.name == "nt":
|
||||
bin_path = bin_path.with_suffix(".exe")
|
||||
if bin_path.exists():
|
||||
return str(bin_path)
|
||||
@@ -356,24 +337,19 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
|
||||
except (subprocess.TimeoutExpired, OSError) as e:
|
||||
logger.warning("[install] pip install errored for %s: %s", pkg, e)
|
||||
return None
|
||||
# Look for the console script. POSIX wheels generally write to bin/,
|
||||
# while native Windows installs use Scripts/.
|
||||
script_dirs = [pip_target / "bin"]
|
||||
if _is_windows():
|
||||
script_dirs.append(pip_target / "Scripts")
|
||||
for script_dir in script_dirs:
|
||||
for bin_path in _native_binary_candidates(script_dir / bin_name):
|
||||
if bin_path.exists():
|
||||
link = hermes_lsp_bin_dir() / bin_path.name
|
||||
if not link.exists():
|
||||
try:
|
||||
link.symlink_to(bin_path)
|
||||
except (OSError, NotImplementedError):
|
||||
try:
|
||||
shutil.copy2(bin_path, link)
|
||||
except OSError:
|
||||
return str(bin_path)
|
||||
return str(link if link.exists() else bin_path)
|
||||
# Look for the script
|
||||
bin_path = pip_target / "bin" / bin_name
|
||||
if bin_path.exists():
|
||||
link = hermes_lsp_bin_dir() / bin_name
|
||||
if not link.exists():
|
||||
try:
|
||||
link.symlink_to(bin_path)
|
||||
except (OSError, NotImplementedError):
|
||||
try:
|
||||
shutil.copy2(bin_path, link)
|
||||
except OSError:
|
||||
return str(bin_path)
|
||||
return str(link if link.exists() else bin_path)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -491,7 +491,6 @@ class MemoryManager:
|
||||
*,
|
||||
parent_session_id: str = "",
|
||||
reset: bool = False,
|
||||
rewound: bool = False,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Notify all providers that the agent's session_id has rotated.
|
||||
@@ -504,21 +503,9 @@ class MemoryManager:
|
||||
per-session state so subsequent writes land in the correct
|
||||
session's record. See ``MemoryProvider.on_session_switch`` for
|
||||
the full contract.
|
||||
|
||||
``rewound=True`` signals that session_id is unchanged but the
|
||||
transcript was truncated; providers caching per-turn document
|
||||
state should invalidate.
|
||||
"""
|
||||
if not new_session_id:
|
||||
return
|
||||
# Only forward ``rewound`` when it's actually set. Passing it
|
||||
# unconditionally would inject ``rewound=False`` into every
|
||||
# provider's **kwargs for the common /resume, /branch, /new, and
|
||||
# compression paths, polluting providers that capture extra kwargs
|
||||
# (and breaking exact-dict assertions). The /undo path sets
|
||||
# rewound=True explicitly; everyone else stays clean.
|
||||
if rewound:
|
||||
kwargs["rewound"] = True
|
||||
for provider in self._providers:
|
||||
try:
|
||||
provider.on_session_switch(
|
||||
|
||||
@@ -178,7 +178,6 @@ class MemoryProvider(ABC):
|
||||
*,
|
||||
parent_session_id: str = "",
|
||||
reset: bool = False,
|
||||
rewound: bool = False,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Called when the agent switches session_id mid-process.
|
||||
@@ -208,10 +207,6 @@ class MemoryProvider(ABC):
|
||||
(``_session_turns``, ``_turn_counter``, etc.) when this is
|
||||
set. ``False`` for ``/resume`` / ``/branch`` / compression
|
||||
where the logical conversation continues under the new id.
|
||||
rewound:
|
||||
``True`` if session_id is unchanged but the transcript was
|
||||
truncated; providers caching per-turn document state should
|
||||
invalidate.
|
||||
|
||||
Default is no-op for backward compatibility.
|
||||
"""
|
||||
|
||||
@@ -200,12 +200,8 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"qwen3-coder-plus": 1000000, # 1M context
|
||||
"qwen3-coder": 262144, # 256K context
|
||||
"qwen": 131072,
|
||||
# MiniMax — M3 is 1M context (max output 512K); M2.x series is 204,800.
|
||||
# Keys use substring matching (longest-first), so "minimax-m3" wins over
|
||||
# the generic "minimax" catch-all for the M3 slug on every surface
|
||||
# (native MiniMax-M3, OpenRouter/Nous minimax/minimax-m3).
|
||||
# https://platform.minimax.io/docs/api-reference/text-chat-openai
|
||||
"minimax-m3": 1000000,
|
||||
# MiniMax — official docs: 204,800 context for all models
|
||||
# https://platform.minimax.io/docs/api-reference/text-anthropic-api
|
||||
"minimax": 204800,
|
||||
# GLM
|
||||
"glm": 202752,
|
||||
|
||||
@@ -150,6 +150,10 @@ _JWT_RE = re.compile(
|
||||
r"(?:\.[A-Za-z0-9_=-]{4,}){0,2}" # Optional payload and/or signature
|
||||
)
|
||||
|
||||
# Discord user/role mentions: <@123456789012345678> or <@!123456789012345678>
|
||||
# Snowflake IDs are 17-20 digit integers that resolve to specific Discord accounts.
|
||||
_DISCORD_MENTION_RE = re.compile(r"<@!?(\d{17,20})>")
|
||||
|
||||
# E.164 phone numbers: +<country><number>, 7-15 digits
|
||||
# Negative lookahead prevents matching hex strings or identifiers
|
||||
_SIGNAL_PHONE_RE = re.compile(r"(\+[1-9]\d{6,14})(?![A-Za-z0-9])")
|
||||
@@ -415,6 +419,10 @@ def redact_sensitive_text(text: str, *, force: bool = False, code_file: bool = F
|
||||
if "&" in text and "=" in text:
|
||||
text = _redact_form_body(text)
|
||||
|
||||
# Discord user/role mentions (<@snowflake_id>)
|
||||
if "<@" in text:
|
||||
text = _DISCORD_MENTION_RE.sub(lambda m: f"<@{'!' if '!' in m.group(0) else ''}***>", text)
|
||||
|
||||
# E.164 phone numbers (Signal, WhatsApp)
|
||||
if "+" in text:
|
||||
def _redact_phone(m):
|
||||
|
||||
+51
-65
@@ -180,9 +180,28 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Block evaluation (BEFORE checkpoint preflight) ───────────
|
||||
# We must know whether the tool will execute before touching
|
||||
# checkpoint state (dedup slot, real snapshots).
|
||||
# Checkpoint for file-mutating tools
|
||||
if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
|
||||
try:
|
||||
file_path = function_args.get("path", "")
|
||||
if file_path:
|
||||
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path)
|
||||
agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Checkpoint before destructive terminal commands
|
||||
if function_name == "terminal" and agent._checkpoint_mgr.enabled:
|
||||
try:
|
||||
cmd = function_args.get("command", "")
|
||||
if _is_destructive_command(cmd):
|
||||
cwd = function_args.get("workdir") or os.getenv("TERMINAL_CWD", os.getcwd())
|
||||
agent._checkpoint_mgr.ensure_checkpoint(
|
||||
cwd, f"before terminal: {cmd[:60]}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
block_result = None
|
||||
blocked_by_guardrail = False
|
||||
if _ts_scope_block is not None:
|
||||
@@ -205,30 +224,6 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
block_result = agent._guardrail_block_result(guardrail_decision)
|
||||
blocked_by_guardrail = True
|
||||
|
||||
# ── Checkpoint preflight (only for tools that will execute) ──
|
||||
if block_result is None:
|
||||
# Checkpoint for file-mutating tools
|
||||
if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
|
||||
try:
|
||||
file_path = function_args.get("path", "")
|
||||
if file_path:
|
||||
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path)
|
||||
agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Checkpoint before destructive terminal commands
|
||||
if function_name == "terminal" and agent._checkpoint_mgr.enabled:
|
||||
try:
|
||||
cmd = function_args.get("command", "")
|
||||
if _is_destructive_command(cmd):
|
||||
cwd = function_args.get("workdir") or os.getenv("TERMINAL_CWD", os.getcwd())
|
||||
agent._checkpoint_mgr.ensure_checkpoint(
|
||||
cwd, f"before terminal: {cmd[:60]}"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
parsed_calls.append((tool_call, function_name, function_args, block_result, blocked_by_guardrail))
|
||||
|
||||
# ── Logging / callbacks ──────────────────────────────────────────
|
||||
@@ -306,38 +301,33 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
# submit site below (GHSA-qg5c-hvr5-hjgr, #13617).
|
||||
start = time.time()
|
||||
try:
|
||||
try:
|
||||
result = agent._invoke_tool(
|
||||
function_name,
|
||||
function_args,
|
||||
effective_task_id,
|
||||
tool_call.id,
|
||||
messages=messages,
|
||||
pre_tool_block_checked=True,
|
||||
)
|
||||
except Exception as tool_error:
|
||||
result = f"Error executing tool '{function_name}': {tool_error}"
|
||||
logger.error("_invoke_tool raised for %s: %s", function_name, tool_error, exc_info=True)
|
||||
duration = time.time() - start
|
||||
is_error, _ = _detect_tool_failure(function_name, result)
|
||||
if is_error:
|
||||
logger.info("tool %s failed (%.2fs): %s", function_name, duration, result[:200])
|
||||
else:
|
||||
logger.info("tool %s completed (%.2fs, %d chars)", function_name, duration, len(result))
|
||||
results[index] = (function_name, function_args, result, duration, is_error, False)
|
||||
finally:
|
||||
# Tear down worker-tid tracking. Clear any interrupt bit we may
|
||||
# have set so the next task scheduled onto this recycled tid
|
||||
# starts with a clean slate. This MUST be in a finally block
|
||||
# because BaseException subclasses (CancelledError, KeyboardInterrupt)
|
||||
# bypass ``except Exception`` and would otherwise leak the tid
|
||||
# into _interrupted_threads, poisoning the recycled thread.
|
||||
with agent._tool_worker_threads_lock:
|
||||
agent._tool_worker_threads.discard(_worker_tid)
|
||||
try:
|
||||
_ra()._set_interrupt(False, _worker_tid)
|
||||
except Exception:
|
||||
pass
|
||||
result = agent._invoke_tool(
|
||||
function_name,
|
||||
function_args,
|
||||
effective_task_id,
|
||||
tool_call.id,
|
||||
messages=messages,
|
||||
pre_tool_block_checked=True,
|
||||
)
|
||||
except Exception as tool_error:
|
||||
result = f"Error executing tool '{function_name}': {tool_error}"
|
||||
logger.error("_invoke_tool raised for %s: %s", function_name, tool_error, exc_info=True)
|
||||
duration = time.time() - start
|
||||
is_error, _ = _detect_tool_failure(function_name, result)
|
||||
if is_error:
|
||||
logger.info("tool %s failed (%.2fs): %s", function_name, duration, result[:200])
|
||||
else:
|
||||
logger.info("tool %s completed (%.2fs, %d chars)", function_name, duration, len(result))
|
||||
results[index] = (function_name, function_args, result, duration, is_error, False)
|
||||
# Tear down worker-tid tracking. Clear any interrupt bit we may
|
||||
# have set so the next task scheduled onto this recycled tid
|
||||
# starts with a clean slate.
|
||||
with agent._tool_worker_threads_lock:
|
||||
agent._tool_worker_threads.discard(_worker_tid)
|
||||
try:
|
||||
_ra()._set_interrupt(False, _worker_tid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Start spinner for CLI mode (skip when TUI handles tool progress)
|
||||
spinner = None
|
||||
@@ -763,14 +753,10 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
elif function_name == "delegate_task":
|
||||
tasks_arg = function_args.get("tasks")
|
||||
if tasks_arg and isinstance(tasks_arg, list):
|
||||
spinner_label = f"🔀 delegating {len(tasks_arg)} tasks · (/agents to monitor)"
|
||||
spinner_label = f"🔀 delegating {len(tasks_arg)} tasks"
|
||||
else:
|
||||
goal_preview = (function_args.get("goal") or "")[:30]
|
||||
spinner_label = (
|
||||
f"🔀 {goal_preview} · (/agents to monitor)"
|
||||
if goal_preview
|
||||
else "🔀 delegating · (/agents to monitor)"
|
||||
)
|
||||
spinner_label = f"🔀 {goal_preview}" if goal_preview else "🔀 delegating"
|
||||
spinner = None
|
||||
if agent._should_emit_quiet_tool_messages() and agent._should_start_quiet_spinner():
|
||||
face = random.choice(KawaiiSpinner.get_waiting_faces())
|
||||
|
||||
@@ -2,15 +2,8 @@
|
||||
//!
|
||||
//! Mirrors `hermes_constants.get_hermes_home()` from the Python CLI:
|
||||
//! Windows: %LOCALAPPDATA%\hermes
|
||||
//! macOS: ~/.hermes
|
||||
//! Linux: ~/.hermes (override via $HERMES_HOME)
|
||||
//!
|
||||
//! NOTE (macOS): Python's get_hermes_home(), scripts/install.sh, and the
|
||||
//! Electron desktop's resolveHermesHome() ALL use ~/.hermes on macOS — there
|
||||
//! is no ~/Library/Application Support branch anywhere else. An earlier
|
||||
//! version of this file used Application Support, which drifted from every
|
||||
//! other component: the installer wrote the install to one dir and the
|
||||
//! desktop looked for it in another, so first launch never found the backend.
|
||||
//! macOS: ~/Library/Application Support/hermes
|
||||
//! Linux: ~/.hermes (XDG override via $HERMES_HOME)
|
||||
//!
|
||||
//! IMPORTANT: this must match exactly. Drift here means install.ps1
|
||||
//! writes to one place and the installer reads from another, breaking
|
||||
@@ -35,8 +28,15 @@ pub fn hermes_home() -> PathBuf {
|
||||
}
|
||||
}
|
||||
|
||||
// macOS + Linux + fallback: ~/.hermes (matches Python get_hermes_home(),
|
||||
// install.sh, and the Electron desktop's resolveHermesHome()).
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// ~/Library/Application Support/hermes
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join("Library/Application Support/hermes");
|
||||
}
|
||||
}
|
||||
|
||||
// Linux + fallback: ~/.hermes
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(".hermes");
|
||||
}
|
||||
|
||||
+240
-93
@@ -1,104 +1,278 @@
|
||||
# Hermes Desktop ☤
|
||||
# Hermes Desktop
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NousResearch/hermes-agent/releases"><img src="https://img.shields.io/badge/Download-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux-FFD700?style=for-the-badge" alt="Download"></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
|
||||
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
|
||||
</p>
|
||||
Native Electron shell for Hermes. It packages the desktop renderer, a bundled Hermes source payload, and installer targets for macOS and Windows. Note: this doc needs updating.
|
||||
|
||||
**The native desktop app for [Hermes Agent](../../README.md) — the self-improving AI agent from [Nous Research](https://nousresearch.com).** Same agent, same skills, same memory as the CLI and gateway, in a polished native window — chat with streaming tool output, side-by-side previews, a file browser, voice, and settings, no terminal required. Available for **macOS, Windows, and Linux**.
|
||||
## Setup
|
||||
|
||||
<table>
|
||||
<tr><td><b>Chat with the full agent</b></td><td>Streaming responses, live tool activity, structured tool summaries, and the same conversation history as every other Hermes surface.</td></tr>
|
||||
<tr><td><b>Side-by-side previews</b></td><td>Render web pages, files, and tool outputs in a right-hand pane while you keep chatting.</td></tr>
|
||||
<tr><td><b>File browser</b></td><td>Explore and preview the working directory without leaving the app.</td></tr>
|
||||
<tr><td><b>Voice</b></td><td>Talk to Hermes and hear it back.</td></tr>
|
||||
<tr><td><b>Settings & onboarding</b></td><td>Manage providers, models, tools, and credentials from a real UI. First-run setup gets you to your first message in seconds.</td></tr>
|
||||
<tr><td><b>Stays current</b></td><td>Built-in updates pull the latest agent and rebuild the app in place.</td></tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
### Install with Hermes (recommended)
|
||||
|
||||
Add `--include-desktop` to the [one-line installer](../../README.md#quick-install) and it sets up the agent and builds the desktop app in one go:
|
||||
Install workspace dependencies from the repo root so `apps/desktop`, `web`, and `apps/shared` stay linked:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --include-desktop
|
||||
npm install
|
||||
```
|
||||
|
||||
Already have the Hermes CLI? Just run:
|
||||
For Python, you have two options:
|
||||
|
||||
**Option A — let the desktop provision it for you (recommended for first-time setup):** just run `npm run dev`. On first launch the desktop creates a venv at `HERMES_HOME/hermes-agent/venv` and runs `pip install -e .` against the resolved Hermes source automatically. Requires Python 3.11+ on `PATH`.
|
||||
|
||||
**Option B — share an existing CLI install:** if you already ran `scripts/install.ps1` / `scripts/install.sh`, that's the same layout the desktop uses. The desktop reuses your existing venv and editable install — no extra steps. See [Runtime Bootstrap](#runtime-bootstrap) below for details.
|
||||
|
||||
If you're hacking on Hermes from a clone outside `HERMES_HOME/hermes-agent`, point the desktop at it explicitly:
|
||||
|
||||
```bash
|
||||
hermes desktop
|
||||
HERMES_DESKTOP_HERMES_ROOT=/path/to/your/clone npm run dev
|
||||
```
|
||||
|
||||
It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. On first launch Hermes walks you through picking a provider and model; nothing else to configure.
|
||||
### Runtime prerequisites
|
||||
|
||||
### Prebuilt installers
|
||||
Hermes Desktop needs:
|
||||
|
||||
When a release ships desktop installers they're attached to its [releases page](https://github.com/NousResearch/hermes-agent/releases) — `.dmg` (macOS), `.exe` / `.msi` (Windows), `.AppImage` / `.deb` / `.rpm` (Linux). These are published manually, so the install-with-Hermes path above is the most reliable way to get the latest.
|
||||
- **Python 3.11+** — for the agent runtime, dashboard backend, and tool execution. (required)
|
||||
- **Git for Windows** (Windows only) — provides Git Bash, which Hermes' terminal tool calls directly. Linux and macOS already ship a system bash. (required)
|
||||
- **ripgrep** — used by Hermes' `search_files` tool for fast `.gitignore`-aware file/content search. Recommended on all platforms; Hermes falls back to `grep`/`find` if missing (works but slower and noisier).
|
||||
|
||||
---
|
||||
The packaged Windows installer (`Hermes-*.exe`) detects all three at install time. Required items missing are auto-installed via `winget install -e --id Python.Python.3.11 --scope user` and `winget install -e --id Git.Git`. The recommended ripgrep is offered as `winget install -e --id BurntSushi.ripgrep.MSVC --scope user`. If `winget` isn't available the installer shows manual download URLs and lets you continue. The MSI installer (`Hermes-*.msi`) doesn't run the prereq page — enterprise deploys are expected to handle prereqs out-of-band.
|
||||
|
||||
## Updating
|
||||
For dev (`npm run dev`) the Python and Git Bash checks happen at first launch via the Electron bootstrapper, which throws a clear error if either prereq is missing. Manual install commands you can run yourself:
|
||||
|
||||
The app checks for updates in the background and offers a one-click update when one is ready. You can also update any time from the CLI:
|
||||
|
||||
```bash
|
||||
hermes update
|
||||
```powershell
|
||||
winget install -e --id Python.Python.3.11 --scope user
|
||||
winget install -e --id Git.Git
|
||||
winget install -e --id BurntSushi.ripgrep.MSVC --scope user
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
The installer handles everything for you (Python 3.11+, a portable Git, ripgrep). The only thing worth knowing:
|
||||
|
||||
- **Windows** — the installer bundles its own Git and Python; no admin rights or system changes required.
|
||||
- **macOS / Linux** — uses your system Python 3.11+ (installed automatically if missing).
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
Want to hack on the app itself? Install workspace deps from the repo root once, then run the dev server from this directory:
|
||||
|
||||
```bash
|
||||
npm install # from repo root — links apps/desktop, web, apps/shared
|
||||
cd apps/desktop
|
||||
npm run dev # Vite renderer + Electron, which boots the Python backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Point the app at a specific source checkout, or sandbox it away from your real config:
|
||||
`npm run dev` starts Vite on `127.0.0.1:5174`, launches Electron, and lets Electron boot the Hermes backend (`hermes dashboard --no-open --tui`) on an open port in `9120-9199`. This path is for UI iteration and may still show Electron/dev identities in OS prompts.
|
||||
|
||||
Useful overrides:
|
||||
|
||||
```bash
|
||||
HERMES_DESKTOP_HERMES_ROOT=/path/to/clone npm run dev
|
||||
HERMES_HOME=/tmp/throwaway npm run dev
|
||||
npm run dev:fake-boot # exercise the startup overlay with deterministic delays
|
||||
HERMES_DESKTOP_HERMES_ROOT=/path/to/hermes-agent npm run dev
|
||||
HERMES_DESKTOP_PYTHON=/path/to/python npm run dev
|
||||
HERMES_DESKTOP_CWD=/path/to/project npm run dev
|
||||
HERMES_DESKTOP_IGNORE_EXISTING=1 npm run dev
|
||||
HERMES_HOME=/tmp/throwaway-hermes-home npm run dev
|
||||
HERMES_DESKTOP_BOOT_FAKE=1 npm run dev
|
||||
HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=900 npm run dev
|
||||
```
|
||||
|
||||
### Building installers
|
||||
`HERMES_DESKTOP_IGNORE_EXISTING=1` skips any `hermes` CLI already on `PATH`, which is useful when testing the factory-image bootstrap path.
|
||||
|
||||
`HERMES_HOME` overrides the install root (default: `%LOCALAPPDATA%\hermes` on Windows, `~/.hermes` elsewhere) — handy for sandboxed dev runs that shouldn't touch your real config.
|
||||
|
||||
`HERMES_DESKTOP_BOOT_FAKE=1` adds deterministic per-phase delays to desktop startup so you can validate the startup overlay and progress bar. For convenience, `npm run dev:fake-boot` enables fake mode with defaults.
|
||||
|
||||
On a fresh Hermes profile, Desktop shows a first-run setup overlay after boot. The overlay saves the minimum required provider credential (for example `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, or `OPENAI_API_KEY`) to the active Hermes `.env`, reloads the backend env, and then lets the user continue without opening Settings manually.
|
||||
|
||||
## Dashboard Dev
|
||||
|
||||
Run the Python dashboard backend with embedded chat enabled:
|
||||
|
||||
```bash
|
||||
npm run dist:mac # DMG + zip
|
||||
npm run dist:win # NSIS + MSI
|
||||
npm run dist:linux # AppImage + deb + rpm
|
||||
npm run pack # unpacked app under release/ (no installer)
|
||||
hermes dashboard --tui --no-open
|
||||
```
|
||||
|
||||
Installers are built and uploaded to GitHub Releases manually. macOS/Windows signing & notarization happen automatically when the relevant credentials are present in the environment (`CSC_LINK` / `CSC_KEY_PASSWORD` / `APPLE_*` for macOS, `WIN_CSC_*` for Windows).
|
||||
For dashboard HMR, start Vite in another terminal:
|
||||
|
||||
### How it works
|
||||
```bash
|
||||
cd web
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The packaged app ships only the Electron shell. On first launch it installs the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. The renderer (React, in `src/`) talks to a `hermes dashboard --tui` backend over the standard gateway APIs and reuses the embedded TUI rather than reimplementing chat. The install, backend-resolution, and self-update logic all live in `electron/main.cjs`.
|
||||
Open the Vite URL. The dev server proxies `/api`, `/api/pty`, and plugin assets to `http://127.0.0.1:9119` and fetches the live dashboard HTML so the ephemeral session token matches the running backend.
|
||||
|
||||
### Verification
|
||||
## Build
|
||||
|
||||
Run before opening a PR (lint may surface pre-existing warnings but must exit cleanly):
|
||||
```bash
|
||||
npm run build
|
||||
npm run pack # unpacked app at release/mac-<arch>/Hermes.app
|
||||
npm run dist:mac # macOS DMG + zip
|
||||
npm run dist:mac:dmg # DMG only
|
||||
npm run dist:mac:zip # zip only
|
||||
npm run dist:win # NSIS + MSI
|
||||
```
|
||||
|
||||
Before packaging, the desktop app no longer bundles a copy of the Hermes Agent Python source. Instead, the packaged Electron app will fetch and install Hermes Agent at first launch via `scripts/install.ps1`'s stage protocol (Windows) — see the bootstrap flow documented in `electron/main.cjs`. macOS and Linux packaged builds are temporarily non-functional until `install.sh` gains the same stage protocol; dev workflows on all three platforms continue to work since they resolve a sibling source checkout.
|
||||
|
||||
## Automated Releases
|
||||
|
||||
Desktop installers are published by [`.github/workflows/desktop-release.yml`](../../.github/workflows/desktop-release.yml) with two channels:
|
||||
|
||||
- **Stable:** runs on published GitHub releases and uploads signed artifacts to that release tag.
|
||||
- **Nightly:** runs on `main` pushes and updates the rolling `desktop-nightly` prerelease.
|
||||
|
||||
The workflow injects a channel-aware desktop version at build time:
|
||||
|
||||
- stable: derived from the release tag (for example `v2026.5.5` -> `2026.5.5`)
|
||||
- nightly: `0.0.0-nightly.YYYYMMDD.<sha>`
|
||||
|
||||
Artifact names include channel, platform, and architecture:
|
||||
|
||||
```text
|
||||
Hermes-<version>-<channel>-<platform>-<arch>.<ext>
|
||||
```
|
||||
|
||||
Each run also publishes `SHA256SUMS-<platform>.txt` so installers can be verified.
|
||||
|
||||
### Stable release gates
|
||||
|
||||
Stable builds fail fast if signing credentials are missing:
|
||||
|
||||
- macOS signing + notarization: `CSC_LINK`, `CSC_KEY_PASSWORD`, `APPLE_API_KEY`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`
|
||||
- Windows signing: `WIN_CSC_LINK`, `WIN_CSC_KEY_PASSWORD`
|
||||
|
||||
Stable macOS builds also validate stapling and Gatekeeper assessment in CI before upload.
|
||||
|
||||
## Icons
|
||||
|
||||
Desktop icons live in `assets/`:
|
||||
|
||||
- `assets/icon.icns`
|
||||
- `assets/icon.ico`
|
||||
- `assets/icon.png`
|
||||
|
||||
The builder config points at `assets/icon`. Replace these files directly if the app icon changes.
|
||||
|
||||
## Testing Install Paths
|
||||
|
||||
Use the package-local test scripts from this directory:
|
||||
|
||||
```bash
|
||||
npm run test:desktop:all
|
||||
npm run test:desktop:existing
|
||||
npm run test:desktop:fresh
|
||||
npm run test:desktop:dmg
|
||||
npm run test:desktop:platforms
|
||||
```
|
||||
|
||||
`test:desktop:existing` builds the packaged app and opens it normally. It should use an existing `hermes` CLI if one is on `PATH`, preserving the user’s real `~/.hermes` config.
|
||||
|
||||
`test:desktop:fresh` builds the packaged app and launches it in a throwaway fresh-install sandbox. It sets `HERMES_DESKTOP_IGNORE_EXISTING=1`, points Electron `userData` at a temp dir, points `HERMES_HOME` at a temp dir, and launches through the factory-image bootstrap path without touching your real desktop runtime or `~/.hermes`.
|
||||
|
||||
`test:desktop:dmg` builds and opens the DMG.
|
||||
|
||||
`test:desktop:platforms` runs platform bootstrap-path assertions, including:
|
||||
- existing-CLI vs factory-image runtime path selection semantics
|
||||
- WSL2 protection against Windows `.exe/.cmd/.bat/.ps1` overrides
|
||||
- platform-specific runtime import checks (`winpty` vs `ptyprocess`)
|
||||
|
||||
For fast reruns without rebuilding:
|
||||
|
||||
```bash
|
||||
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:fresh
|
||||
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:existing
|
||||
HERMES_DESKTOP_SKIP_BUILD=1 npm run test:desktop:dmg
|
||||
```
|
||||
|
||||
## Installing Locally
|
||||
|
||||
```bash
|
||||
npm run dist:mac:dmg
|
||||
open release/Hermes-0.0.0-arm64.dmg
|
||||
```
|
||||
|
||||
Drag `Hermes` to Applications. If testing repeated installs, replace the existing app.
|
||||
|
||||
## Runtime Bootstrap
|
||||
|
||||
Hermes Desktop shares its install layout with the CLI installers (`scripts/install.ps1`, `scripts/install.sh`) so a desktop-only user and a CLI-only user end up with the same files in the same places.
|
||||
|
||||
### Where things live
|
||||
|
||||
```text
|
||||
HERMES_HOME/ # %LOCALAPPDATA%\hermes (Windows)
|
||||
# ~/.hermes (macOS / Linux)
|
||||
├── hermes-agent/ # ACTIVE_HERMES_ROOT — git checkout
|
||||
│ ├── .git/ # canonical install is always a git checkout
|
||||
│ ├── hermes_cli/, agent/, ... # Python source
|
||||
│ ├── pyproject.toml # source of truth for deps
|
||||
│ ├── venv/ # virtualenv (Scripts\python.exe on Windows,
|
||||
│ │ # bin/python elsewhere)
|
||||
│ └── .hermes-bootstrap-complete # marker: first-launch install.ps1 succeeded
|
||||
├── git/ # PortableGit (Windows; installed by install.ps1)
|
||||
├── config.yaml # user config
|
||||
├── .env # API keys
|
||||
└── logs/
|
||||
├── desktop.log # Electron-side boot log
|
||||
├── agent.log
|
||||
├── errors.log
|
||||
└── gateway.log
|
||||
```
|
||||
|
||||
The packaged installer ships only the Electron app — Hermes Agent itself is fetched and installed at first launch by running `scripts/install.ps1` (Windows) against the git ref baked into the .exe at build time (see `apps/desktop/scripts/write-build-stamp.cjs`).
|
||||
|
||||
### Resolution order
|
||||
|
||||
The desktop resolves a Hermes backend in this order:
|
||||
|
||||
1. `HERMES_DESKTOP_HERMES_ROOT` — explicit dev override.
|
||||
2. Repo source root — only when running `npm run dev` from a checkout. Takes precedence over `HERMES_HOME/hermes-agent` so devs always run their local edits.
|
||||
3. `HERMES_HOME/hermes-agent` if the `.hermes-bootstrap-complete` marker is present. The marker attests that install.ps1 succeeded and the user finished initial configuration; we trust the install and skip the bootstrap flow on every launch after the first.
|
||||
4. Existing `hermes` CLI on PATH (skipped when `HERMES_DESKTOP_IGNORE_EXISTING=1`).
|
||||
5. Pip-installed `hermes_cli` module via system Python.
|
||||
6. None of the above → bootstrap-needed sentinel. The desktop's first-launch wizard runs `scripts/install.ps1` stages, then writes the marker on success.
|
||||
|
||||
### First-launch flow on a packaged install
|
||||
|
||||
1. `resolveHermesBackend()` returns `kind: 'bootstrap-needed'`.
|
||||
2. The renderer shows the install overlay; main fetches `scripts/install.ps1` from GitHub at the pinned commit (from `install-stamp.json`).
|
||||
3. Main drives `install.ps1 -Manifest` to get the stage list, then iterates `install.ps1 -Stage <name> -NonInteractive -Json` with live progress events to the renderer.
|
||||
4. On all stages succeeding, main writes `.hermes-bootstrap-complete` with `{ schemaVersion, pinnedCommit, pinnedBranch, completedAt, desktopVersion }`.
|
||||
5. Renderer hands off to the existing onboarding overlay (API key / model / persona).
|
||||
6. Subsequent launches see the marker and skip everything in steps 1-5.
|
||||
|
||||
### Updates
|
||||
|
||||
Once bootstrapped, the install is a real git checkout. Updates flow through the in-app update path (`applyUpdates()` → `git fetch && git pull --ff-only` against the configured branch) or `hermes update` from the CLI. Both check `pyproject.toml` drift and re-run `pip install -e .` only when needed.
|
||||
|
||||
A user who installed via `scripts/install.ps1` directly (so `HERMES_HOME/hermes-agent/.git` exists but no `.hermes-bootstrap-complete` marker) is detected via resolver step 4 (their `hermes` CLI on PATH) and the desktop reuses their install without re-running the bootstrap.
|
||||
|
||||
## Debugging
|
||||
|
||||
Desktop boot logs are written to:
|
||||
|
||||
```text
|
||||
HERMES_HOME/logs/desktop.log # %LOCALAPPDATA%\hermes\logs\desktop.log on Windows
|
||||
# ~/.hermes/logs/desktop.log on macOS / Linux
|
||||
```
|
||||
|
||||
If the UI reports `Desktop boot failed`, check that log first. It includes the backend command output and recent Python traceback context.
|
||||
|
||||
To force a fresh first-launch bootstrap (rare — useful for development / dogfooding the install flow):
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete"
|
||||
|
||||
# Windows (PowerShell)
|
||||
Remove-Item "$env:LOCALAPPDATA\hermes\hermes-agent\.hermes-bootstrap-complete"
|
||||
```
|
||||
|
||||
For a full reset of just the Python venv (rare — usually only needed if the venv is broken):
|
||||
|
||||
```bash
|
||||
# macOS / Linux
|
||||
rm -rf "$HOME/.hermes/hermes-agent/venv"
|
||||
|
||||
# Windows (PowerShell)
|
||||
Remove-Item -Recurse -Force "$env:LOCALAPPDATA\hermes\hermes-agent\venv"
|
||||
```
|
||||
|
||||
To reset stale macOS microphone permission prompts:
|
||||
|
||||
```bash
|
||||
tccutil reset Microphone com.github.Electron
|
||||
tccutil reset Microphone com.nousresearch.hermes
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
Run before handing off installer changes:
|
||||
|
||||
```bash
|
||||
npm run fix
|
||||
@@ -107,31 +281,4 @@ npm run lint
|
||||
npm run test:desktop:all
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Boot logs land in `HERMES_HOME/logs/desktop.log` (includes backend output and recent Python tracebacks) — check it first if the app reports a boot failure.
|
||||
|
||||
```bash
|
||||
# Force a clean first-launch setup
|
||||
rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete" # macOS/Linux
|
||||
# Rebuild a broken Python venv
|
||||
rm -rf "$HOME/.hermes/hermes-agent/venv" # macOS/Linux
|
||||
# Reset a stuck macOS microphone prompt
|
||||
tccutil reset Microphone com.nousresearch.hermes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
- 💬 [Discord](https://discord.gg/NousResearch)
|
||||
- 📖 [Documentation](https://hermes-agent.nousresearch.com/docs/)
|
||||
- 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](../../LICENSE).
|
||||
|
||||
Built by [Nous Research](https://nousresearch.com).
|
||||
Current lint may report existing warnings, but it should exit with no errors.
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
* NOT implemented yet (deferred to Phase 1E / 1F):
|
||||
* - User-facing retry / cancel from the renderer (event channels exist;
|
||||
* no UI consumes them yet)
|
||||
* - macOS / Linux install.sh equivalent
|
||||
*/
|
||||
|
||||
const fs = require('node:fs')
|
||||
@@ -53,17 +54,9 @@ const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
|
||||
// install.ps1 source resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function installScriptName() {
|
||||
return process.platform === 'win32' ? 'install.ps1' : 'install.sh'
|
||||
}
|
||||
|
||||
function installScriptKind() {
|
||||
return process.platform === 'win32' ? 'powershell' : 'posix'
|
||||
}
|
||||
|
||||
function resolveLocalInstallScript(sourceRepoRoot) {
|
||||
if (!sourceRepoRoot) return null
|
||||
const candidate = path.join(sourceRepoRoot, 'scripts', installScriptName())
|
||||
const candidate = path.join(sourceRepoRoot, 'scripts', 'install.ps1')
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.R_OK)
|
||||
return candidate
|
||||
@@ -77,15 +70,14 @@ function bootstrapCacheDir(hermesHome) {
|
||||
}
|
||||
|
||||
function cachedScriptPath(hermesHome, commit) {
|
||||
return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.${process.platform === 'win32' ? 'ps1' : 'sh'}`)
|
||||
return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.ps1`)
|
||||
}
|
||||
|
||||
function downloadInstallScript(commit, destPath) {
|
||||
// Fetch from GitHub raw at the pinned commit. The raw URL with a SHA
|
||||
// is immutable (unlike a branch ref), so we don't need integrity
|
||||
// verification beyond "did the file we wrote pass a syntax probe."
|
||||
const scriptName = installScriptName()
|
||||
const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${commit}/scripts/${scriptName}`
|
||||
const url = `https://raw.githubusercontent.com/NousResearch/hermes-agent/${commit}/scripts/install.ps1`
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
const tmpPath = destPath + '.tmp'
|
||||
@@ -100,9 +92,7 @@ function downloadInstallScript(commit, destPath) {
|
||||
https
|
||||
.get(res.headers.location, res2 => {
|
||||
if (res2.statusCode !== 200) {
|
||||
reject(
|
||||
new Error(`Failed to download ${scriptName}: HTTP ${res2.statusCode} from redirect ${res.headers.location}`)
|
||||
)
|
||||
reject(new Error(`Failed to download install.ps1: HTTP ${res2.statusCode} from redirect ${res.headers.location}`))
|
||||
return
|
||||
}
|
||||
const out2 = fs.createWriteStream(tmpPath)
|
||||
@@ -122,7 +112,7 @@ function downloadInstallScript(commit, destPath) {
|
||||
try {
|
||||
fs.unlinkSync(tmpPath)
|
||||
} catch {}
|
||||
reject(new Error(`Failed to download ${scriptName}: HTTP ${res.statusCode} from ${url}`))
|
||||
reject(new Error(`Failed to download install.ps1: HTTP ${res.statusCode} from ${url}`))
|
||||
return
|
||||
}
|
||||
res.pipe(out)
|
||||
@@ -148,19 +138,19 @@ function downloadInstallScript(commit, destPath) {
|
||||
}
|
||||
|
||||
async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit }) {
|
||||
// 1. Dev shortcut: prefer a local checkout's installer so we can iterate
|
||||
// 1. Dev shortcut: prefer a local checkout's install.ps1 so we can iterate
|
||||
// without pushing. SOURCE_REPO_ROOT comes from main.cjs (path.resolve
|
||||
// of APP_ROOT/../..).
|
||||
const localScript = resolveLocalInstallScript(sourceRepoRoot)
|
||||
if (localScript) {
|
||||
emit({ type: 'log', line: `[bootstrap] using local ${installScriptName()} at ${localScript}` })
|
||||
return { path: localScript, source: 'local', kind: installScriptKind() }
|
||||
emit({ type: 'log', line: `[bootstrap] using local install.ps1 at ${localScript}` })
|
||||
return { path: localScript, source: 'local' }
|
||||
}
|
||||
|
||||
// 2. Packaged path: download from GitHub at the pinned commit (1B's stamp).
|
||||
if (!installStamp || !installStamp.commit || !STAMP_COMMIT_RE.test(installStamp.commit)) {
|
||||
throw new Error(
|
||||
`Cannot resolve ${installScriptName()}: no SOURCE_REPO_ROOT and no install stamp. ` +
|
||||
'Cannot resolve install.ps1: no SOURCE_REPO_ROOT and no install stamp. ' +
|
||||
'This packaged build was produced without a valid build-time stamp.'
|
||||
)
|
||||
}
|
||||
@@ -168,16 +158,16 @@ async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome,
|
||||
const cached = cachedScriptPath(hermesHome, installStamp.commit)
|
||||
try {
|
||||
await fsp.access(cached, fs.constants.R_OK)
|
||||
emit({ type: 'log', line: `[bootstrap] using cached ${installScriptName()} for ${installStamp.commit.slice(0, 12)}` })
|
||||
return { path: cached, source: 'cache', commit: installStamp.commit, kind: installScriptKind() }
|
||||
emit({ type: 'log', line: `[bootstrap] using cached install.ps1 for ${installStamp.commit.slice(0, 12)}` })
|
||||
return { path: cached, source: 'cache', commit: installStamp.commit }
|
||||
} catch {
|
||||
// not cached; download
|
||||
}
|
||||
|
||||
emit({ type: 'log', line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub` })
|
||||
emit({ type: 'log', line: `[bootstrap] fetching install.ps1 for ${installStamp.commit.slice(0, 12)} from GitHub` })
|
||||
await downloadInstallScript(installStamp.commit, cached)
|
||||
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
|
||||
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
|
||||
return { path: cached, source: 'download', commit: installStamp.commit }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -260,75 +250,6 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
|
||||
})
|
||||
}
|
||||
|
||||
function spawnBash(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('bash', [scriptPath, ...args], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
|
||||
}
|
||||
})
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let killed = false
|
||||
|
||||
const onAbort = () => {
|
||||
killed = true
|
||||
try {
|
||||
child.kill('SIGTERM')
|
||||
} catch {}
|
||||
}
|
||||
if (abortSignal) {
|
||||
if (abortSignal.aborted) {
|
||||
onAbort()
|
||||
} else {
|
||||
abortSignal.addEventListener('abort', onAbort, { once: true })
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
|
||||
let stdoutBuf = ''
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk
|
||||
stdoutBuf += chunk
|
||||
let nl
|
||||
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
|
||||
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '')
|
||||
stdoutBuf = stdoutBuf.slice(nl + 1)
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line })
|
||||
}
|
||||
})
|
||||
|
||||
let stderrBuf = ''
|
||||
child.stderr.on('data', chunk => {
|
||||
stderr += chunk
|
||||
stderrBuf += chunk
|
||||
let nl
|
||||
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
|
||||
const line = stderrBuf.slice(0, nl).replace(/\r$/, '')
|
||||
stderrBuf = stderrBuf.slice(nl + 1)
|
||||
if (line) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${line}` })
|
||||
}
|
||||
})
|
||||
|
||||
child.on('error', err => {
|
||||
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
child.on('close', (code, signal) => {
|
||||
if (abortSignal) abortSignal.removeEventListener('abort', onAbort)
|
||||
if (stdoutBuf) emit && emit({ type: 'log', stage: stageName, line: stdoutBuf })
|
||||
if (stderrBuf) emit && emit({ type: 'log', stage: stageName, line: `stderr: ${stderrBuf}` })
|
||||
resolve({ stdout, stderr, code, signal, killed })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest + stage dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -347,29 +268,15 @@ function buildPinArgs(installStamp) {
|
||||
return args
|
||||
}
|
||||
|
||||
function buildPosixPinArgs({ installStamp, activeRoot, hermesHome }) {
|
||||
const args = ['--dir', activeRoot, '--hermes-home', hermesHome]
|
||||
if (installStamp && installStamp.branch) {
|
||||
args.push('--branch', installStamp.branch)
|
||||
}
|
||||
if (installStamp && installStamp.commit) {
|
||||
args.push('--commit', installStamp.commit)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, activeRoot, installStamp }) {
|
||||
const isPosix = installerKind === 'posix'
|
||||
const args = isPosix
|
||||
? ['--manifest', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })]
|
||||
: ['-Manifest', ...buildPinArgs(installStamp)]
|
||||
const result = await (isPosix ? spawnBash : spawnPowerShell)(scriptPath, args, {
|
||||
async function fetchManifest({ scriptPath, emit, hermesHome, installStamp }) {
|
||||
const pinArgs = buildPinArgs(installStamp)
|
||||
const result = await spawnPowerShell(scriptPath, ['-Manifest', ...pinArgs], {
|
||||
emit,
|
||||
stageName: '__manifest__',
|
||||
hermesHome
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
throw new Error(`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} failed: exit ${result.code}\n${result.stderr || result.stdout}`)
|
||||
throw new Error(`install.ps1 -Manifest failed: exit ${result.code}\n${result.stderr || result.stdout}`)
|
||||
}
|
||||
// The manifest is the LAST JSON line on stdout (install.ps1 may print
|
||||
// banner / info lines first depending on Console.OutputEncoding effects).
|
||||
@@ -383,7 +290,7 @@ async function fetchManifest({ scriptPath, installerKind, emit, hermesHome, acti
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
throw new Error(`${isPosix ? 'install.sh --manifest' : 'install.ps1 -Manifest'} produced no parseable JSON payload\n${result.stdout}`)
|
||||
throw new Error(`install.ps1 -Manifest produced no parseable JSON payload\n${result.stdout}`)
|
||||
}
|
||||
|
||||
// Parse the JSON result frame from a stage run. The protocol guarantees
|
||||
@@ -402,17 +309,14 @@ function parseStageResult(stdout) {
|
||||
return null
|
||||
}
|
||||
|
||||
async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, activeRoot, abortSignal, installStamp }) {
|
||||
async function runStage({ scriptPath, stage, emit, hermesHome, abortSignal, installStamp }) {
|
||||
const startedAt = Date.now()
|
||||
emit({ type: 'stage', name: stage.name, state: 'running' })
|
||||
|
||||
const isPosix = installerKind === 'posix'
|
||||
const args = isPosix
|
||||
? ['--stage', stage.name, '--non-interactive', '--json', ...buildPosixPinArgs({ installStamp, activeRoot, hermesHome })]
|
||||
: ['-Stage', stage.name, '-NonInteractive', '-Json', ...buildPinArgs(installStamp)]
|
||||
const result = await (isPosix ? spawnBash : spawnPowerShell)(
|
||||
const pinArgs = buildPinArgs(installStamp)
|
||||
const result = await spawnPowerShell(
|
||||
scriptPath,
|
||||
args,
|
||||
['-Stage', stage.name, '-NonInteractive', '-Json', ...pinArgs],
|
||||
{ emit, stageName: stage.name, abortSignal, hermesHome }
|
||||
)
|
||||
|
||||
@@ -432,7 +336,7 @@ async function runStage({ scriptPath, installerKind, stage, emit, hermesHome, ac
|
||||
name: stage.name,
|
||||
state: 'failed',
|
||||
durationMs,
|
||||
error: `${isPosix ? 'install.sh --stage' : 'install.ps1 -Stage'} ${stage.name} produced no JSON result frame (exit=${result.code})`,
|
||||
error: `install.ps1 -Stage ${stage.name} produced no JSON result frame (exit=${result.code})`,
|
||||
json: null
|
||||
}
|
||||
emit(ev)
|
||||
@@ -508,19 +412,11 @@ async function runBootstrap(opts) {
|
||||
})
|
||||
|
||||
try {
|
||||
// 1. Resolve the platform installer.
|
||||
// 1. Resolve install.ps1
|
||||
const scriptInfo = await resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit })
|
||||
const installerKind = scriptInfo.kind || 'powershell'
|
||||
|
||||
// 2. Fetch manifest
|
||||
const manifest = await fetchManifest({
|
||||
scriptPath: scriptInfo.path,
|
||||
installerKind,
|
||||
emit,
|
||||
hermesHome,
|
||||
activeRoot,
|
||||
installStamp
|
||||
})
|
||||
const manifest = await fetchManifest({ scriptPath: scriptInfo.path, emit, hermesHome, installStamp })
|
||||
emit({
|
||||
type: 'manifest',
|
||||
stages: manifest.stages,
|
||||
@@ -536,16 +432,7 @@ async function runBootstrap(opts) {
|
||||
emit({ type: 'failed', error: 'bootstrap cancelled by user' })
|
||||
return { ok: false, cancelled: true }
|
||||
}
|
||||
const ev = await runStage({
|
||||
scriptPath: scriptInfo.path,
|
||||
installerKind,
|
||||
stage,
|
||||
emit,
|
||||
hermesHome,
|
||||
activeRoot,
|
||||
abortSignal,
|
||||
installStamp
|
||||
})
|
||||
const ev = await runStage({ scriptPath: scriptInfo.path, stage, emit, hermesHome, abortSignal, installStamp })
|
||||
if (ev.state === 'failed') {
|
||||
emit({ type: 'failed', stage: stage.name, error: ev.error || 'stage failed' })
|
||||
return { ok: false, failedStage: stage.name, error: ev.error }
|
||||
|
||||
+49
-342
@@ -188,10 +188,10 @@ const BOOTSTRAP_MARKER_SCHEMA_VERSION = 1
|
||||
|
||||
const DESKTOP_CONNECTION_CONFIG_PATH = path.join(app.getPath('userData'), 'connection.json')
|
||||
const DESKTOP_UPDATE_CONFIG_PATH = path.join(app.getPath('userData'), 'updates.json')
|
||||
// Branch we track for self-update. The GUI work has merged to main, so this
|
||||
// tracks main. User can also override at runtime via
|
||||
// hermesDesktop.updates.setBranch().
|
||||
const DEFAULT_UPDATE_BRANCH = 'main'
|
||||
// Branch we track for self-update. Flip to 'main' once the GUI work merges —
|
||||
// single field edit, no rebuild required. User can also override at runtime
|
||||
// via hermesDesktop.updates.setBranch().
|
||||
const DEFAULT_UPDATE_BRANCH = 'bb/gui'
|
||||
// desktop.log lives under HERMES_HOME/logs/ so it sits next to agent.log,
|
||||
// errors.log, gateway.log produced by hermes_logging.setup_logging — one log
|
||||
// directory per user, regardless of which UI surface produced the line.
|
||||
@@ -1024,33 +1024,9 @@ function emitUpdateProgress(payload) {
|
||||
}
|
||||
}
|
||||
|
||||
// Self-heal the tracked update branch: if origin no longer publishes it (e.g.
|
||||
// bb/gui was merged into main and deleted), fall back to main and persist so
|
||||
// every later check/apply follows main — no manual flip, even for already-
|
||||
// installed clients. Read-only ls-remote probe; only flips on a definitive
|
||||
// "ref absent" (exit 2), never on a transient network error, so a flaky
|
||||
// connection can't strand a user on the wrong branch.
|
||||
async function resolveHealedBranch(updateRoot, branch) {
|
||||
if (!branch || branch === 'main') {
|
||||
return branch || 'main'
|
||||
}
|
||||
|
||||
const probe = await runGit(['ls-remote', '--exit-code', '--heads', 'origin', branch], { cwd: updateRoot })
|
||||
if (probe.code !== 2) {
|
||||
return branch
|
||||
}
|
||||
|
||||
rememberLog(`[updates] origin/${branch} is gone (merged?); falling back to main`)
|
||||
const config = readDesktopUpdateConfig()
|
||||
if (config.branch !== 'main') {
|
||||
writeDesktopUpdateConfig({ ...config, branch: 'main' })
|
||||
}
|
||||
return 'main'
|
||||
}
|
||||
|
||||
async function checkUpdates() {
|
||||
const updateRoot = resolveUpdateRoot()
|
||||
let { branch } = readDesktopUpdateConfig()
|
||||
const { branch } = readDesktopUpdateConfig()
|
||||
const gitDir = path.join(updateRoot, '.git')
|
||||
if (!directoryExists(gitDir)) {
|
||||
return {
|
||||
@@ -1062,7 +1038,6 @@ async function checkUpdates() {
|
||||
}
|
||||
}
|
||||
|
||||
branch = await resolveHealedBranch(updateRoot, branch)
|
||||
const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot })
|
||||
if (fetched.code !== 0) {
|
||||
return {
|
||||
@@ -1152,15 +1127,6 @@ async function applyUpdates(opts = {}) {
|
||||
|
||||
try {
|
||||
const updater = resolveUpdaterBinary()
|
||||
if (!updater && !IS_WINDOWS) {
|
||||
// macOS/Linux drag-install: no staged Tauri hermes-setup. Unlike Windows
|
||||
// (where a venv-shim file lock forces the quit→hand-off→rebuild dance),
|
||||
// there's no mandatory file locking here, so the desktop can drive the
|
||||
// whole update itself: `hermes update` (backend) + `hermes desktop
|
||||
// --build-only` (OS-aware GUI rebuild), then swap the running .app bundle
|
||||
// with the freshly built one and relaunch.
|
||||
return await applyUpdatesPosixInApp(opts)
|
||||
}
|
||||
if (!updater) {
|
||||
// No staged updater binary — this is a CLI-installed user (they ran
|
||||
// `hermes desktop`, never the Tauri installer that self-copies
|
||||
@@ -1175,10 +1141,9 @@ async function applyUpdates(opts = {}) {
|
||||
let command = 'hermes update'
|
||||
try {
|
||||
const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot })
|
||||
const current = (head.stdout || '').trim()
|
||||
if (head.code === 0 && current && current !== 'HEAD') {
|
||||
const branch = await resolveHealedBranch(updateRoot, current)
|
||||
if (branch !== 'main') command = `hermes update --branch ${branch}`
|
||||
const branch = (head.stdout || '').trim()
|
||||
if (head.code === 0 && branch && branch !== 'HEAD' && branch !== 'main') {
|
||||
command = `hermes update --branch ${branch}`
|
||||
}
|
||||
} catch {
|
||||
// Best-effort: fall back to bare `hermes update` if branch detection fails.
|
||||
@@ -1213,177 +1178,6 @@ async function applyUpdates(opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the hermes CLI to drive an in-app update: prefer the venv shim in
|
||||
// the install we're updating, fall back to `hermes` on PATH.
|
||||
function resolveHermesCliBinary(updateRoot) {
|
||||
const venvHermes = path.join(updateRoot, 'venv', 'bin', 'hermes')
|
||||
if (fileExists(venvHermes)) return venvHermes
|
||||
return findOnPath('hermes') || null
|
||||
}
|
||||
|
||||
// Spawn a command and stream each output line to the update progress channel.
|
||||
function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
|
||||
return new Promise(resolve => {
|
||||
let child
|
||||
try {
|
||||
child = spawn(command, args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...(env || {}) },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
} catch (err) {
|
||||
resolve({ code: 1, error: err.message })
|
||||
return
|
||||
}
|
||||
const emitLines = chunk => {
|
||||
for (const line of chunk.toString().split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed) emitUpdateProgress({ stage, message: trimmed, percent: null })
|
||||
}
|
||||
}
|
||||
child.stdout.on('data', emitLines)
|
||||
child.stderr.on('data', emitLines)
|
||||
child.once('error', err => resolve({ code: 1, error: err.message }))
|
||||
child.once('exit', code => resolve({ code }))
|
||||
})
|
||||
}
|
||||
|
||||
// The running app's .app bundle (packaged macOS): execPath is
|
||||
// <App>.app/Contents/MacOS/<exe>; climb three levels to the bundle root.
|
||||
function runningAppBundle() {
|
||||
if (!IS_MAC) return null
|
||||
let dir = path.dirname(app.getPath('exe')) // .../Contents/MacOS
|
||||
for (let i = 0; i < 2; i++) dir = path.dirname(dir) // -> .../X.app
|
||||
return dir.endsWith('.app') ? dir : null
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
// macOS/Linux in-app update: backend (`hermes update`) + OS-aware GUI rebuild
|
||||
// (`hermes desktop --build-only`), then atomically swap the running .app bundle
|
||||
// with the freshly built one and relaunch. Degrades to "backend updated,
|
||||
// restart to load the new GUI" if the swap can't be performed.
|
||||
async function applyUpdatesPosixInApp(opts = {}) {
|
||||
const updateRoot = resolveUpdateRoot()
|
||||
const hermes = resolveHermesCliBinary(updateRoot)
|
||||
if (!hermes) {
|
||||
emitUpdateProgress({ stage: 'manual', message: 'hermes update', percent: null })
|
||||
return { ok: true, manual: true, command: 'hermes update', hermesRoot: updateRoot }
|
||||
}
|
||||
|
||||
// Put the Hermes-managed Node and the venv on PATH so `hermes desktop`'s
|
||||
// npm build can find them on a machine with no system Node.
|
||||
const extraPath = [path.join(HERMES_HOME, 'node', 'bin'), path.join(updateRoot, 'venv', 'bin')]
|
||||
.filter(Boolean)
|
||||
.join(path.delimiter)
|
||||
const env = {
|
||||
HERMES_HOME,
|
||||
PATH: [extraPath, process.env.PATH].filter(Boolean).join(path.delimiter)
|
||||
}
|
||||
|
||||
// Branch-pin so a non-main checkout doesn't get switched to main (and self-heal
|
||||
// to main when the pinned branch no longer exists on origin).
|
||||
let branchArgs = []
|
||||
try {
|
||||
const head = await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: updateRoot })
|
||||
const current = (head.stdout || '').trim()
|
||||
if (head.code === 0 && current && current !== 'HEAD') {
|
||||
branchArgs = ['--branch', await resolveHealedBranch(updateRoot, current)]
|
||||
}
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
|
||||
emitUpdateProgress({ stage: 'update', message: 'Updating Hermes (git + dependencies)…', percent: 10 })
|
||||
const updated = await runStreamedUpdate(hermes, ['update', '--yes', ...branchArgs], {
|
||||
cwd: updateRoot,
|
||||
env,
|
||||
stage: 'update'
|
||||
})
|
||||
if (updated.code !== 0) {
|
||||
emitUpdateProgress({ stage: 'error', message: 'hermes update failed.', error: updated.error || 'update-failed' })
|
||||
return { ok: false, error: 'hermes update failed' }
|
||||
}
|
||||
|
||||
emitUpdateProgress({ stage: 'rebuild', message: 'Rebuilding the desktop app…', percent: 60 })
|
||||
const rebuilt = await runStreamedUpdate(hermes, ['desktop', '--build-only'], {
|
||||
cwd: updateRoot,
|
||||
env,
|
||||
stage: 'rebuild'
|
||||
})
|
||||
if (rebuilt.code !== 0) {
|
||||
emitUpdateProgress({
|
||||
stage: 'error',
|
||||
message: 'Backend updated, but the desktop rebuild failed. Restart Hermes to retry.',
|
||||
error: rebuilt.error || 'rebuild-failed'
|
||||
})
|
||||
return { ok: false, backendUpdated: true, error: 'desktop rebuild failed' }
|
||||
}
|
||||
|
||||
const rebuiltApp = [
|
||||
path.join(updateRoot, 'apps', 'desktop', 'release', 'mac-arm64', 'Hermes.app'),
|
||||
path.join(updateRoot, 'apps', 'desktop', 'release', 'mac', 'Hermes.app')
|
||||
].find(directoryExists)
|
||||
const targetApp = runningAppBundle()
|
||||
|
||||
// No bundle to swap (dev run, Linux AppImage, or unresolved paths): the
|
||||
// backend is updated; the next launch picks up the rebuilt GUI.
|
||||
if (!rebuiltApp || !targetApp) {
|
||||
emitUpdateProgress({
|
||||
stage: 'done',
|
||||
message: 'Backend updated. Restart Hermes to load the new version.',
|
||||
percent: 100
|
||||
})
|
||||
return { ok: true, backendUpdated: true, rebuiltApp: rebuiltApp || null }
|
||||
}
|
||||
|
||||
emitUpdateProgress({ stage: 'restart', message: 'Installing the updated app and restarting…', percent: 95 })
|
||||
|
||||
// Detached swapper: wait for THIS process to exit (so the bundle is free),
|
||||
// ditto the rebuilt app over the running one, clear quarantine, relaunch.
|
||||
const swapScript = `#!/bin/bash
|
||||
set -u
|
||||
APP_PID=${process.pid}
|
||||
SRC=${shellQuote(rebuiltApp)}
|
||||
DST=${shellQuote(targetApp)}
|
||||
for _ in $(seq 1 240); do
|
||||
kill -0 "$APP_PID" 2>/dev/null || break
|
||||
sleep 0.5
|
||||
done
|
||||
if [ "$SRC" != "$DST" ]; then
|
||||
if /usr/bin/ditto "$SRC" "$DST.hermes-update-new"; then
|
||||
rm -rf "$DST.hermes-update-old" 2>/dev/null || true
|
||||
mv "$DST" "$DST.hermes-update-old" 2>/dev/null || rm -rf "$DST"
|
||||
mv "$DST.hermes-update-new" "$DST"
|
||||
rm -rf "$DST.hermes-update-old" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
/usr/bin/xattr -dr com.apple.quarantine "$DST" 2>/dev/null || true
|
||||
/usr/bin/open "$DST"
|
||||
`
|
||||
const scriptPath = path.join(app.getPath('temp'), `hermes-desktop-update-${Date.now()}.sh`)
|
||||
try {
|
||||
fs.writeFileSync(scriptPath, swapScript, { mode: 0o755 })
|
||||
} catch (err) {
|
||||
emitUpdateProgress({
|
||||
stage: 'done',
|
||||
message: 'Backend + app updated. Restart Hermes to load the new version.',
|
||||
percent: 100
|
||||
})
|
||||
rememberLog(`[updates] could not write swap script: ${err.message}; rebuilt app at ${rebuiltApp}`)
|
||||
return { ok: true, backendUpdated: true, rebuiltApp }
|
||||
}
|
||||
|
||||
const child = spawn('/bin/bash', [scriptPath], { detached: true, stdio: 'ignore' })
|
||||
child.unref()
|
||||
rememberLog(`[updates] launched mac swap+relaunch: ${scriptPath} (${rebuiltApp} -> ${targetApp})`)
|
||||
|
||||
setTimeout(() => app.quit(), 600)
|
||||
return { ok: true, handedOff: true, rebuiltApp, targetApp }
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||
@@ -1418,11 +1212,8 @@ function isBootstrapComplete() {
|
||||
// We DELIBERATELY do NOT verify that the checkout is currently at the
|
||||
// pinned commit -- users update via the in-app update path or `hermes
|
||||
// update`, which moves HEAD legitimately. The marker just attests "we
|
||||
// ran the bootstrap successfully at least once." We DO additionally require
|
||||
// a runnable venv: an interrupted or split-home install can leave the marker
|
||||
// + checkout without a venv, and trusting that spawns a dead backend
|
||||
// ("gateway offline") instead of re-running bootstrap to repair it.
|
||||
return isHermesSourceRoot(ACTIVE_HERMES_ROOT) && fileExists(getVenvPython(VENV_ROOT))
|
||||
// ran the bootstrap successfully at least once."
|
||||
return isHermesSourceRoot(ACTIVE_HERMES_ROOT)
|
||||
}
|
||||
|
||||
function writeBootstrapMarker(payload) {
|
||||
@@ -1653,15 +1444,43 @@ async function ensureRuntime(backend) {
|
||||
}
|
||||
|
||||
// backend.kind === 'bootstrap-needed' means resolveHermesBackend couldn't
|
||||
// find anything to spawn. Hand off to the bootstrap runner which drives the
|
||||
// platform installer, writes the bootstrap-complete marker on success, then
|
||||
// we re-resolve to get the now-installed backend.
|
||||
// find anything to spawn. Hand off to the bootstrap runner which drives
|
||||
// install.ps1's stage protocol, writes the bootstrap-complete marker on
|
||||
// success, then we re-resolve to get the now-installed backend.
|
||||
//
|
||||
// Phase 1D status: bootstrap runs but events go to desktop.log only
|
||||
// (renderer window isn't created until later in startBackend). Phase 1E
|
||||
// will rewire startup to spawn the window first and route bootstrap events
|
||||
// to a renderer-side install overlay.
|
||||
if (backend.kind === 'bootstrap-needed') {
|
||||
if (process.platform !== 'win32') {
|
||||
// macOS/Linux: install.sh doesn't yet support the stage protocol that
|
||||
// install.ps1 does, so we can't drive a first-launch bootstrap. Emit
|
||||
// a platform-unsupported event so the renderer's install overlay can
|
||||
// render a 'run install.sh manually' guide instead of a generic
|
||||
// 'desktop boot failed' toast. Mark the bootstrap state as inactive
|
||||
// with an explanatory error so the overlay's failure branch picks
|
||||
// it up immediately. THEN throw -- so the existing 'desktop boot
|
||||
// failed' path still trips and prevents the rest of startHermes
|
||||
// from running against a missing install.
|
||||
const guidanceUrl = 'https://github.com/NousResearch/hermes-agent#install'
|
||||
const installShUrl = 'https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh'
|
||||
try {
|
||||
broadcastBootstrapEvent({
|
||||
type: 'unsupported-platform',
|
||||
platform: process.platform,
|
||||
activeRoot: backend.activeRoot,
|
||||
installCommand: `bash <(curl -fsSL ${installShUrl})`,
|
||||
docsUrl: guidanceUrl
|
||||
})
|
||||
} catch {}
|
||||
throw new Error(
|
||||
`Hermes Agent is not installed at ${backend.activeRoot}. On macOS/Linux ` +
|
||||
'first-launch install is not yet automated -- run scripts/install.sh ' +
|
||||
'from the Hermes repo manually, then relaunch this app.'
|
||||
)
|
||||
}
|
||||
|
||||
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
|
||||
|
||||
// Eagerly flip the bootstrap UI state to 'active' so the renderer
|
||||
@@ -1823,29 +1642,10 @@ function fetchJson(url, token, options = {}) {
|
||||
reject(new Error(`${res.statusCode}: ${text || res.statusMessage}`))
|
||||
return
|
||||
}
|
||||
if (!text) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
// A 2xx response whose body is HTML means the request fell through
|
||||
// to the SPA index.html (e.g. an unregistered /api path). JSON.parse
|
||||
// would throw an opaque `Unexpected token '<'` here, so surface a
|
||||
// clear diagnostic with the offending URL instead.
|
||||
const looksHtml = /^\s*<(?:!doctype|html)/i.test(text)
|
||||
const contentType = String(res.headers['content-type'] || '')
|
||||
if (looksHtml || contentType.includes('text/html')) {
|
||||
reject(
|
||||
new Error(
|
||||
`Expected JSON from ${url} but got HTML (status ${res.statusCode}). ` +
|
||||
'The endpoint is likely missing on the Hermes backend.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(text))
|
||||
} catch {
|
||||
reject(new Error(`Invalid JSON from ${url} (status ${res.statusCode}): ${text.slice(0, 200)}`))
|
||||
resolve(text ? JSON.parse(text) : null)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2640,54 +2440,15 @@ function installContextMenu(window) {
|
||||
})
|
||||
}
|
||||
|
||||
// Microphone capture for the voice composer. The renderer drives mic access
|
||||
// through getUserMedia, which Chromium gates behind these two session hooks.
|
||||
//
|
||||
// The naive `details.mediaTypes.includes('audio')` check works on macOS but
|
||||
// breaks on Windows: Chromium frequently fires the mic permission request with
|
||||
// an empty/undefined `mediaTypes`, so the strict check denies it and
|
||||
// getUserMedia throws NotAllowedError ("Microphone permission was denied").
|
||||
// We therefore treat an audio-capture request as allowed whenever it's the
|
||||
// 'media'/'audioCapture' permission AND mediaTypes either includes 'audio' OR
|
||||
// is empty/absent (the Windows case). Video is still denied.
|
||||
function isAudioCapturePermission(permission, details) {
|
||||
if (permission === 'audioCapture') {
|
||||
return true
|
||||
}
|
||||
if (permission !== 'media') {
|
||||
return false
|
||||
}
|
||||
const mediaTypes = details?.mediaTypes
|
||||
if (!Array.isArray(mediaTypes) || mediaTypes.length === 0) {
|
||||
// Windows: mediaTypes is often empty for a mic request. Don't deny on
|
||||
// missing metadata. (A video request would carry mediaTypes:['video'].)
|
||||
return true
|
||||
}
|
||||
return mediaTypes.includes('audio') && !mediaTypes.includes('video')
|
||||
}
|
||||
|
||||
function installMediaPermissions() {
|
||||
// Async request handler: the prompt-style path (most platforms).
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback, details) => {
|
||||
callback(isAudioCapturePermission(permission, details))
|
||||
})
|
||||
if (permission === 'media' && details?.mediaTypes?.includes('audio')) {
|
||||
callback(true)
|
||||
|
||||
// Synchronous check handler: Chromium consults this for getUserMedia on
|
||||
// Windows in addition to (or instead of) the request handler. Without it,
|
||||
// the check defaults to false and the mic is denied before the request
|
||||
// handler ever runs.
|
||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission, _origin, details) => {
|
||||
if (permission === 'media' || permission === 'audioCapture') {
|
||||
// details.mediaType is a single string here (not the mediaTypes array).
|
||||
const mediaType = details?.mediaType
|
||||
if (mediaType === 'video') {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return
|
||||
}
|
||||
|
||||
return false
|
||||
callback(false)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3177,23 +2938,6 @@ ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:bootstrap:repair', async () => {
|
||||
// Forceful repair: drop the bootstrap-complete marker so the next
|
||||
// startHermes() re-runs the full installer (refreshing a broken/partial
|
||||
// venv), and clear any latched failure + live connection. The renderer
|
||||
// reloads afterwards to re-drive the boot flow from scratch.
|
||||
rememberLog('[bootstrap] repair requested by renderer; clearing marker + latched failure')
|
||||
try {
|
||||
if (fileExists(BOOTSTRAP_COMPLETE_MARKER)) {
|
||||
fs.rmSync(BOOTSTRAP_COMPLETE_MARKER, { force: true })
|
||||
}
|
||||
} catch (error) {
|
||||
rememberLog(`[bootstrap] failed to remove marker during repair: ${error.message}`)
|
||||
}
|
||||
bootstrapFailure = null
|
||||
resetHermesConnection()
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
|
||||
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
|
||||
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
|
||||
@@ -3349,21 +3093,6 @@ ipcMain.handle('hermes:openExternal', (_event, url) => {
|
||||
|
||||
ipcMain.handle('hermes:fetchLinkTitle', (_event, url) => fetchLinkTitle(url))
|
||||
|
||||
ipcMain.handle('hermes:logs:reveal', async () => {
|
||||
try {
|
||||
await fs.promises.mkdir(path.dirname(DESKTOP_LOG_PATH), { recursive: true })
|
||||
if (!fileExists(DESKTOP_LOG_PATH)) {
|
||||
await fs.promises.appendFile(DESKTOP_LOG_PATH, '')
|
||||
}
|
||||
shell.showItemInFolder(DESKTOP_LOG_PATH)
|
||||
return { ok: true, path: DESKTOP_LOG_PATH }
|
||||
} catch (error) {
|
||||
return { ok: false, path: DESKTOP_LOG_PATH, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:logs:recent', async () => ({ path: DESKTOP_LOG_PATH, lines: hermesLog.slice(-200) }))
|
||||
|
||||
// Always-hidden noise (covers non-git projects too — gitignore would catch
|
||||
// these anyway when present, but we want the same hygiene without one).
|
||||
const FS_READDIR_HIDDEN = new Set([
|
||||
@@ -3617,30 +3346,8 @@ ipcMain.handle('hermes:updates:branch:set', async (_event, name) => {
|
||||
return { branch }
|
||||
})
|
||||
|
||||
// Resolve the canonical Hermes version (the one `release.py` bumps in
|
||||
// hermes_cli/__init__.py + pyproject.toml) so the desktop About panel shows the
|
||||
// real Hermes version instead of the Electron app's own package.json version,
|
||||
// which historically drifted (stuck at 0.0.2). Falls back to app.getVersion()
|
||||
// when the source tree can't be read (e.g. a packaged build without the repo).
|
||||
function resolveHermesVersion() {
|
||||
try {
|
||||
const root = resolveUpdateRoot()
|
||||
const initPath = path.join(root, 'hermes_cli', '__init__.py')
|
||||
if (fileExists(initPath)) {
|
||||
const raw = fs.readFileSync(initPath, 'utf8')
|
||||
const match = raw.match(/__version__\s*=\s*["']([^"']+)["']/)
|
||||
if (match) {
|
||||
return match[1]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the Electron app version below.
|
||||
}
|
||||
return app.getVersion()
|
||||
}
|
||||
|
||||
ipcMain.handle('hermes:version', async () => ({
|
||||
appVersion: resolveHermesVersion(),
|
||||
appVersion: app.getVersion(),
|
||||
electronVersion: process.versions.electron,
|
||||
nodeVersion: process.versions.node,
|
||||
platform: process.platform,
|
||||
|
||||
@@ -31,8 +31,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
|
||||
revealLogs: () => ipcRenderer.invoke('hermes:logs:reveal'),
|
||||
getRecentLogs: () => ipcRenderer.invoke('hermes:logs:recent'),
|
||||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
|
||||
terminal: {
|
||||
@@ -90,7 +88,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
// reload mid-bootstrap.
|
||||
getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'),
|
||||
resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'),
|
||||
repairBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:repair'),
|
||||
onBootstrapEvent: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:bootstrap:event', listener)
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hermes",
|
||||
"version": "0.15.1",
|
||||
"version": "0.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hermes",
|
||||
"version": "0.15.1",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "^0.12.28",
|
||||
"@assistant-ui/react-streamdown": "^0.1.11",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "hermes",
|
||||
"productName": "Hermes",
|
||||
"private": true,
|
||||
"version": "0.15.1",
|
||||
"version": "0.0.2",
|
||||
"description": "Native desktop shell for Hermes Agent.",
|
||||
"author": "Nous Research",
|
||||
"type": "module",
|
||||
@@ -25,7 +25,6 @@
|
||||
"dist:win": "npm run build && npm run builder -- --win",
|
||||
"dist:win:msi": "npm run build && npm run builder -- --win msi",
|
||||
"dist:win:nsis": "npm run build && npm run builder -- --win nsis",
|
||||
"dist:linux": "npm run build && npm run builder -- --linux AppImage deb rpm",
|
||||
"test:desktop": "node scripts/test-desktop.mjs",
|
||||
"test:desktop:all": "node scripts/test-desktop.mjs all",
|
||||
"test:desktop:dmg": "node scripts/test-desktop.mjs dmg",
|
||||
@@ -141,7 +140,6 @@
|
||||
"package.json"
|
||||
],
|
||||
"beforeBuild": "scripts/before-build.cjs",
|
||||
"afterPack": "scripts/after-pack.cjs",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "build/install-stamp.json",
|
||||
@@ -150,10 +148,6 @@
|
||||
{
|
||||
"from": "build/native-deps",
|
||||
"to": "native-deps"
|
||||
},
|
||||
{
|
||||
"from": "assets/icon.ico",
|
||||
"to": "icon.ico"
|
||||
}
|
||||
],
|
||||
"asar": true,
|
||||
@@ -210,16 +204,6 @@
|
||||
],
|
||||
"signAndEditExecutable": false
|
||||
},
|
||||
"linux": {
|
||||
"category": "Development",
|
||||
"maintainer": "Nous Research <support@nousresearch.com>",
|
||||
"synopsis": "Native desktop shell for Hermes Agent.",
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb",
|
||||
"rpm"
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* after-pack.cjs — electron-builder afterPack hook.
|
||||
*
|
||||
* Stamps the Hermes icon + identity onto the packed Windows Hermes.exe via
|
||||
* rcedit (delegated to set-exe-identity.cjs). This runs for EVERY packed build
|
||||
* — first install, `hermes desktop`, the installer's --update rebuild, and a
|
||||
* dev's manual `npm run pack` — so the branded exe can never silently revert
|
||||
* to the stock "Electron" icon/name (the bug when the stamp lived only in
|
||||
* install.ps1, which the update path doesn't use).
|
||||
*
|
||||
* Windows-only: rcedit edits PE resources, irrelevant on macOS/Linux where the
|
||||
* app identity comes from the bundle Info.plist / desktop entry. Best-effort:
|
||||
* a stamp failure must never fail an otherwise-good build (worst case is the
|
||||
* stock icon, not a broken app), so we log and resolve rather than throw.
|
||||
*
|
||||
* electron-builder passes a context with:
|
||||
* - electronPlatformName: 'win32' | 'darwin' | 'linux'
|
||||
* - appOutDir: the unpacked app directory for this target
|
||||
* - packager.appInfo.productFilename: the exe basename (e.g. 'Hermes')
|
||||
*/
|
||||
|
||||
const path = require('node:path')
|
||||
|
||||
const { stampExeIdentity } = require('./set-exe-identity.cjs')
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
if (context.electronPlatformName !== 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
const productName = context.packager?.appInfo?.productFilename || 'Hermes'
|
||||
const exe = path.join(context.appOutDir, `${productName}.exe`)
|
||||
const desktopRoot = path.resolve(__dirname, '..')
|
||||
|
||||
try {
|
||||
await stampExeIdentity(exe, desktopRoot)
|
||||
} catch (err) {
|
||||
// Never fail the build over a cosmetic stamp.
|
||||
console.warn(`[after-pack] exe identity stamp failed (${err.message}); Hermes.exe keeps the stock Electron icon`)
|
||||
}
|
||||
}
|
||||
@@ -16,79 +16,72 @@
|
||||
// skips rcedit, so the unpacked Hermes.exe keeps the stock Electron icon and
|
||||
// "Electron" taskbar name. This script restores the icon + identity by calling
|
||||
// rcedit DIRECTLY. rcedit is a pure PE resource editor: no signing, no certs,
|
||||
// no winCodeSign, no symlinks.
|
||||
// no winCodeSign, no symlinks. Invoked from install.ps1's Install-Desktop
|
||||
// after `npm run pack`.
|
||||
//
|
||||
// HOW IT RUNS
|
||||
// -----------
|
||||
// Primarily as an electron-builder `afterPack` hook (scripts/after-pack.cjs),
|
||||
// so EVERY packed build — first install, `hermes desktop`, the installer's
|
||||
// --update rebuild, or a dev's manual `npm run pack` — gets a branded exe from
|
||||
// one place. Previously this stamp lived only in install.ps1, so the update
|
||||
// path (which rebuilds via `hermes desktop --build-only`, never install.ps1)
|
||||
// shipped a stock "Electron" exe. Keeping it in afterPack closes that gap.
|
||||
//
|
||||
// Also runnable standalone for ad-hoc re-stamping:
|
||||
// USAGE
|
||||
// node scripts/set-exe-identity.cjs <path-to-Hermes.exe>
|
||||
//
|
||||
// Exits 0 on success, non-zero on failure when run as a CLI. As a hook,
|
||||
// stampExeIdentity() resolves on success and rejects on failure; the caller
|
||||
// (after-pack.cjs) swallows the rejection so a stamp failure never fails an
|
||||
// otherwise-good build (worst case: stock icon, not a broken app).
|
||||
// Exits 0 on success, non-zero on failure. install.ps1 treats failure as
|
||||
// non-fatal (worst case: stock icon, not a broken app).
|
||||
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
|
||||
// Stamp the Hermes icon + identity onto `exe`. Resolves on success, throws on
|
||||
// failure. `desktopRoot` defaults to this script's package root so the icon and
|
||||
// the rcedit dependency resolve regardless of cwd.
|
||||
async function stampExeIdentity(exe, desktopRoot = path.resolve(__dirname, '..')) {
|
||||
if (!exe || !fs.existsSync(exe)) {
|
||||
throw new Error(`target exe not found: ${exe}`)
|
||||
async function main() {
|
||||
const exe = process.argv[2]
|
||||
if (!exe) {
|
||||
console.error('[set-exe-identity] usage: set-exe-identity.cjs <path-to-exe>')
|
||||
process.exit(2)
|
||||
}
|
||||
if (!fs.existsSync(exe)) {
|
||||
console.error(`[set-exe-identity] target exe not found: ${exe}`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
// Icon lives at apps/desktop/assets/icon.ico
|
||||
// Icon lives beside this script's package root: apps/desktop/assets/icon.ico
|
||||
const desktopRoot = path.resolve(__dirname, '..')
|
||||
const icon = path.join(desktopRoot, 'assets', 'icon.ico')
|
||||
if (!fs.existsSync(icon)) {
|
||||
throw new Error(`icon not found: ${icon}`)
|
||||
console.error(`[set-exe-identity] icon not found: ${icon}`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
// rcedit is a direct devDependency of apps/desktop, so it resolves whether
|
||||
// we're run from the desktop dir or the repo root (workspace hoist).
|
||||
// rcedit@5 exports a NAMED `rcedit` function (CommonJS: { rcedit }), not a
|
||||
// default export.
|
||||
const mod = require('rcedit')
|
||||
const rcedit = typeof mod === 'function' ? mod : mod.rcedit
|
||||
if (typeof rcedit !== 'function') {
|
||||
throw new Error(`unexpected rcedit export shape: ${typeof mod} keys=${Object.keys(mod)}`)
|
||||
let rcedit
|
||||
try {
|
||||
const mod = require('rcedit')
|
||||
rcedit = typeof mod === 'function' ? mod : mod.rcedit
|
||||
if (typeof rcedit !== 'function') {
|
||||
throw new Error(`unexpected rcedit export shape: ${typeof mod} keys=${Object.keys(mod)}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[set-exe-identity] could not load rcedit module: ${err.message}`)
|
||||
process.exit(3)
|
||||
}
|
||||
|
||||
console.log(`[set-exe-identity] stamping ${exe}`)
|
||||
console.log(`[set-exe-identity] icon: ${icon}`)
|
||||
|
||||
await rcedit(exe, {
|
||||
icon,
|
||||
'version-string': {
|
||||
ProductName: 'Hermes',
|
||||
FileDescription: 'Hermes',
|
||||
CompanyName: 'Nous Research',
|
||||
LegalCopyright: 'Copyright (c) 2026 Nous Research'
|
||||
}
|
||||
})
|
||||
try {
|
||||
await rcedit(exe, {
|
||||
icon,
|
||||
'version-string': {
|
||||
ProductName: 'Hermes',
|
||||
FileDescription: 'Hermes',
|
||||
CompanyName: 'Nous Research',
|
||||
LegalCopyright: 'Copyright (c) 2026 Nous Research'
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(`[set-exe-identity] rcedit failed: ${err.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('[set-exe-identity] done — Hermes icon + identity stamped')
|
||||
}
|
||||
|
||||
module.exports = { stampExeIdentity }
|
||||
|
||||
// CLI entry point: `node scripts/set-exe-identity.cjs <exe>`.
|
||||
if (require.main === module) {
|
||||
const exe = process.argv[2]
|
||||
if (!exe) {
|
||||
console.error('[set-exe-identity] usage: set-exe-identity.cjs <path-to-exe>')
|
||||
process.exit(2)
|
||||
}
|
||||
stampExeIdentity(exe).catch(err => {
|
||||
console.error(`[set-exe-identity] ${err.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
main()
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Full-bleed affordance shown while files are dragged over the chat area. Always
|
||||
* `pointer-events-none` so the drop lands on the real element underneath and the
|
||||
* drop-zone handler claims it — the overlay is purely visual. Mirrors the
|
||||
* composer surface so the two read as one family.
|
||||
*/
|
||||
export function ChatDropOverlay({ active }: { active: boolean }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center p-4 transition-opacity duration-150 ease-out',
|
||||
active ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
data-slot="chat-drop-overlay"
|
||||
>
|
||||
<div className="absolute inset-2 rounded-2xl border-2 border-dashed border-[color-mix(in_srgb,var(--dt-composer-ring)_55%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_55%,transparent)] backdrop-blur-[2px] [-webkit-backdrop-filter:blur(2px)]" />
|
||||
<div className="relative flex items-center gap-2 rounded-full border border-[color-mix(in_srgb,var(--dt-composer-ring)_45%,transparent)] bg-[color-mix(in_srgb,var(--dt-card)_92%,transparent)] px-4 py-2 text-[0.8125rem] font-medium text-foreground shadow-composer">
|
||||
<Codicon className="text-(--ui-accent)" name="cloud-upload" size="1rem" />
|
||||
Drop files to attach
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -331,21 +331,6 @@ export function ChatBar({
|
||||
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
|
||||
// Push the new text into the contentEditable editor directly. Setting the
|
||||
// assistant-ui composer state alone is not enough: the draft→editor sync
|
||||
// effect only re-renders the editor when it is NOT focused
|
||||
// (document.activeElement !== editor), and the dictation/insert paths
|
||||
// typically run while the editor has (or immediately regains) focus — so
|
||||
// the store would hold the text but the visible editor would stay empty
|
||||
// and there'd be nothing to send. Mirror appendExternalText here.
|
||||
const editor = editorRef.current
|
||||
|
||||
if (editor) {
|
||||
renderComposerContents(editor, nextDraft)
|
||||
placeCaretEnd(editor)
|
||||
}
|
||||
|
||||
requestMainFocus()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { type DragEvent as ReactDragEvent, useCallback, useRef, useState } from 'react'
|
||||
|
||||
import { dragHasAttachments } from '@/app/chat/composer/inline-refs'
|
||||
|
||||
import { type DroppedFile, extractDroppedFiles, HERMES_PATHS_MIME } from './use-composer-actions'
|
||||
|
||||
const hasFiles = (event: ReactDragEvent) => dragHasAttachments(event.dataTransfer, HERMES_PATHS_MIME)
|
||||
|
||||
interface FileDropZoneOptions {
|
||||
/** When false the zone ignores drags entirely. */
|
||||
enabled?: boolean
|
||||
onDropFiles: (files: DroppedFile[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* "Drop files anywhere in this region" affordance. An enter/leave depth counter
|
||||
* keeps nested children from flickering the active state; `onDropCapture` clears
|
||||
* it even when a nested target (the composer) handles the drop and stops
|
||||
* propagation before our bubble-phase `onDrop` would fire.
|
||||
*
|
||||
* Spread `dropHandlers` onto the container; render an overlay off `dragActive`.
|
||||
*/
|
||||
export function useFileDropZone({ enabled = true, onDropFiles }: FileDropZoneOptions) {
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const depth = useRef(0)
|
||||
|
||||
const reset = useCallback(() => {
|
||||
depth.current = 0
|
||||
setDragActive(false)
|
||||
}, [])
|
||||
|
||||
const onDragEnter = useCallback(
|
||||
(event: ReactDragEvent) => {
|
||||
if (!enabled || !hasFiles(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
depth.current += 1
|
||||
setDragActive(true)
|
||||
},
|
||||
[enabled]
|
||||
)
|
||||
|
||||
const onDragOver = useCallback(
|
||||
(event: ReactDragEvent) => {
|
||||
if (!enabled || !hasFiles(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = 'copy'
|
||||
},
|
||||
[enabled]
|
||||
)
|
||||
|
||||
const onDragLeave = useCallback(() => {
|
||||
if (enabled && --depth.current <= 0) {
|
||||
reset()
|
||||
}
|
||||
}, [enabled, reset])
|
||||
|
||||
const onDrop = useCallback(
|
||||
(event: ReactDragEvent) => {
|
||||
if (!enabled || !hasFiles(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
reset()
|
||||
|
||||
const files = extractDroppedFiles(event.dataTransfer)
|
||||
|
||||
if (files.length) {
|
||||
onDropFiles(files)
|
||||
}
|
||||
},
|
||||
[enabled, onDropFiles, reset]
|
||||
)
|
||||
|
||||
return { dragActive, dropHandlers: { onDragEnter, onDragLeave, onDragOver, onDrop, onDropCapture: reset } }
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type * as React from 'react'
|
||||
import { Suspense, useCallback, useMemo, useRef } from 'react'
|
||||
import { Suspense, useMemo, useRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { Thread } from '@/components/assistant-ui/thread'
|
||||
@@ -43,13 +43,9 @@ import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
import { routeSessionId } from '../routes'
|
||||
import { titlebarHeaderBaseClass, titlebarHeaderShadowClass } from '../shell/titlebar'
|
||||
|
||||
import { ChatDropOverlay } from './chat-drop-overlay'
|
||||
import { ChatBar, ChatBarFallback } from './composer'
|
||||
import { requestComposerInsert } from './composer/focus'
|
||||
import { droppedFileInlineRef } from './composer/inline-refs'
|
||||
import type { ChatBarState } from './composer/types'
|
||||
import type { DroppedFile } from './hooks/use-composer-actions'
|
||||
import { useFileDropZone } from './hooks/use-file-drop-zone'
|
||||
import { SessionActionsMenu } from './sidebar/session-actions-menu'
|
||||
import { lastVisibleMessageIsUser, threadLoadingState } from './thread-loading'
|
||||
|
||||
@@ -271,24 +267,6 @@ export function ChatView({
|
||||
onReload
|
||||
})
|
||||
|
||||
// Drop files anywhere in the conversation area, not just on the composer
|
||||
// input — appending the same inline `@file:` ref chips the composer drop
|
||||
// produces (vs. attachment cards) so both surfaces behave identically.
|
||||
const onDropFiles = useCallback(
|
||||
(candidates: DroppedFile[]) => {
|
||||
const refs = candidates
|
||||
.map(candidate => droppedFileInlineRef(candidate, currentCwd))
|
||||
.filter((ref): ref is string => Boolean(ref))
|
||||
|
||||
if (refs.length) {
|
||||
requestComposerInsert(refs.join(' '), { mode: 'inline', target: 'main' })
|
||||
}
|
||||
},
|
||||
[currentCwd]
|
||||
)
|
||||
|
||||
const { dragActive, dropHandlers } = useFileDropZone({ enabled: showChatBar, onDropFiles })
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -307,10 +285,7 @@ export function ChatView({
|
||||
|
||||
<NotificationStack />
|
||||
|
||||
<div
|
||||
className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]"
|
||||
{...dropHandlers}
|
||||
>
|
||||
<div className="relative min-h-0 max-w-full flex-1 overflow-hidden bg-(--ui-chat-surface-background) contain-[layout_paint]">
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread
|
||||
clampToComposer={showChatBar}
|
||||
@@ -351,7 +326,6 @@ export function ChatView({
|
||||
</Suspense>
|
||||
)}
|
||||
</AssistantRuntimeProvider>
|
||||
<ChatDropOverlay active={dragActive} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef } from 'react'
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
|
||||
import { BootFailureOverlay } from '@/components/boot-failure-overlay'
|
||||
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
|
||||
import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
|
||||
import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
@@ -485,7 +484,6 @@ export function DesktopController() {
|
||||
/>
|
||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||
<UpdatesOverlay />
|
||||
<BootFailureOverlay />
|
||||
|
||||
{settingsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
setSessionStartedAt,
|
||||
setTurnStartedAt
|
||||
} from '@/store/session'
|
||||
import { reportBackendContract } from '@/store/updates'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
|
||||
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
|
||||
@@ -205,8 +204,6 @@ function applyRuntimeInfo(
|
||||
|
||||
const sessionState: Partial<Pick<ClientSessionState, 'branch' | 'cwd'>> = {}
|
||||
|
||||
reportBackendContract(info.desktop_contract)
|
||||
|
||||
if (info.credential_warning) {
|
||||
requestDesktopOnboarding(info.credential_warning)
|
||||
}
|
||||
|
||||
@@ -308,7 +308,7 @@ export interface ModeOption {
|
||||
export const MODE_OPTIONS: ModeOption[] = [
|
||||
{ id: 'light', label: 'Light', description: 'Bright desktop surfaces', icon: Sun },
|
||||
{ id: 'dark', label: 'Dark', description: 'Low-glare workspace', icon: Moon },
|
||||
{ id: 'system', label: 'System', description: 'Follow OS appearance', icon: Monitor }
|
||||
{ id: 'system', label: 'System', description: 'Follow macOS appearance', icon: Monitor }
|
||||
]
|
||||
|
||||
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools', string> = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { AlertCircle, Check, FileText, Globe, Loader2, Monitor } from '@/lib/icons'
|
||||
import { AlertCircle, Check, Globe, Loader2, Monitor } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
@@ -289,19 +289,6 @@ export function GatewaySettings() {
|
||||
Save and reconnect
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 divide-y divide-border/40">
|
||||
<ListRow
|
||||
action={
|
||||
<Button onClick={() => void window.hermesDesktop?.revealLogs()} variant="outline">
|
||||
<FileText className="size-4" />
|
||||
Open logs
|
||||
</Button>
|
||||
}
|
||||
description="Reveal desktop.log in your file manager — useful when the gateway fails to start."
|
||||
title="Diagnostics"
|
||||
/>
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getSkills = vi.fn()
|
||||
const getToolsets = vi.fn()
|
||||
const toggleSkill = vi.fn()
|
||||
const toggleToolset = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getSkills: () => getSkills(),
|
||||
getToolsets: () => getToolsets(),
|
||||
toggleSkill: (name: string, enabled: boolean) => toggleSkill(name, enabled),
|
||||
toggleToolset: (name: string, enabled: boolean) => toggleToolset(name, enabled)
|
||||
}))
|
||||
|
||||
// Notifications hit nanostores/timers we don't care about here.
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
notify: vi.fn(),
|
||||
notifyError: vi.fn()
|
||||
}))
|
||||
|
||||
function toolset(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: 'web',
|
||||
label: 'Web Search',
|
||||
description: 'web_search, web_extract',
|
||||
enabled: true,
|
||||
available: true,
|
||||
configured: true,
|
||||
tools: ['web_search', 'web_extract'],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getSkills.mockResolvedValue([])
|
||||
getToolsets.mockResolvedValue([toolset()])
|
||||
toggleToolset.mockResolvedValue({ ok: true, name: 'web', enabled: false })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('ToolsSettings toolset toggle', () => {
|
||||
it('renders a switch for each toolset and toggles it off', async () => {
|
||||
const { ToolsSettings } = await import('./tools-settings')
|
||||
render(<ToolsSettings query="" />)
|
||||
|
||||
const sw = await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
|
||||
expect(sw.getAttribute('aria-checked')).toBe('true')
|
||||
|
||||
fireEvent.click(sw)
|
||||
|
||||
await waitFor(() => expect(toggleToolset).toHaveBeenCalledWith('web', false))
|
||||
})
|
||||
|
||||
it('keeps the configured pill alongside the switch', async () => {
|
||||
const { ToolsSettings } = await import('./tools-settings')
|
||||
render(<ToolsSettings query="" />)
|
||||
|
||||
await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
|
||||
expect(screen.getByText('Configured')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -1,22 +1,19 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
|
||||
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
|
||||
import { Brain, Wrench } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
|
||||
|
||||
import { asText, includesQuery, prettyName, toolNames } from './helpers'
|
||||
import { ListRow, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import { ToolsetConfigPanel } from './toolset-config-panel'
|
||||
import type { SearchProps } from './types'
|
||||
|
||||
export function ToolsSettings({ query }: SearchProps) {
|
||||
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
|
||||
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
|
||||
const [savingSkill, setSavingSkill] = useState<string | null>(null)
|
||||
const [savingToolset, setSavingToolset] = useState<string | null>(null)
|
||||
const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -34,12 +31,6 @@ export function ToolsSettings({ query }: SearchProps) {
|
||||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
const refreshToolsets = useCallback(() => {
|
||||
getToolsets()
|
||||
.then(setToolsets)
|
||||
.catch(err => notifyError(err, 'Toolsets failed to refresh'))
|
||||
}, [])
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
if (!skills) {
|
||||
return []
|
||||
@@ -106,24 +97,6 @@ export function ToolsSettings({ query }: SearchProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleToolset(toolset: ToolsetInfo, enabled: boolean) {
|
||||
setSavingToolset(toolset.name)
|
||||
|
||||
try {
|
||||
await toggleToolset(toolset.name, enabled)
|
||||
setToolsets(c => c?.map(t => (t.name === toolset.name ? { ...t, enabled, available: enabled } : t)) ?? c)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
|
||||
message: `${asText(toolset.label || toolset.name)} applies to new sessions.`
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`)
|
||||
} finally {
|
||||
setSavingToolset(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!skills || !toolsets) {
|
||||
return <LoadingState label="Loading skills and toolsets..." />
|
||||
}
|
||||
@@ -167,54 +140,35 @@ export function ToolsSettings({ query }: SearchProps) {
|
||||
{filteredToolsets.map(toolset => {
|
||||
const tools = toolNames(toolset)
|
||||
const label = asText(toolset.label || toolset.name)
|
||||
const expanded = expandedToolset === toolset.name
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
aria-label={`Configure ${label}`}
|
||||
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
onClick={() => setExpandedToolset(c => (c === toolset.name ? null : toolset.name))}
|
||||
type="button"
|
||||
>
|
||||
<Pill tone={toolset.configured ? 'primary' : 'muted'}>
|
||||
{toolset.configured ? 'Configured' : 'Needs keys'}
|
||||
</Pill>
|
||||
</button>
|
||||
<Switch
|
||||
aria-label={`Toggle ${label} toolset`}
|
||||
checked={toolset.enabled}
|
||||
disabled={savingToolset === toolset.name}
|
||||
onCheckedChange={c => void handleToggleToolset(toolset, c)}
|
||||
/>
|
||||
<Pill tone={toolset.enabled ? 'primary' : 'muted'}>{toolset.enabled ? 'Enabled' : 'Disabled'}</Pill>
|
||||
<Pill tone={toolset.configured ? 'primary' : 'muted'}>
|
||||
{toolset.configured ? 'Configured' : 'Needs keys'}
|
||||
</Pill>
|
||||
</div>
|
||||
}
|
||||
below={
|
||||
<>
|
||||
{tools.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{tools.slice(0, 10).map(t => (
|
||||
<span
|
||||
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.64rem] text-muted-foreground"
|
||||
key={t}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
{tools.length > 10 && (
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[0.64rem] text-muted-foreground">
|
||||
+{tools.length - 10} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{expanded && (
|
||||
<ToolsetConfigPanel onConfiguredChange={refreshToolsets} toolset={toolset.name} />
|
||||
)}
|
||||
</>
|
||||
tools.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{tools.slice(0, 10).map(t => (
|
||||
<span
|
||||
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.64rem] text-muted-foreground"
|
||||
key={t}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
{tools.length > 10 && (
|
||||
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[0.64rem] text-muted-foreground">
|
||||
+{tools.length - 10} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
description={asText(toolset.description)}
|
||||
key={asText(toolset.name) || label}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ToolsetConfig } from '@/types/hermes'
|
||||
|
||||
const getToolsetConfig = vi.fn()
|
||||
const selectToolsetProvider = vi.fn()
|
||||
const setEnvVar = vi.fn()
|
||||
const deleteEnvVar = vi.fn()
|
||||
const revealEnvVar = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getToolsetConfig: (name: string) => getToolsetConfig(name),
|
||||
selectToolsetProvider: (name: string, provider: string) => selectToolsetProvider(name, provider),
|
||||
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
|
||||
deleteEnvVar: (key: string) => deleteEnvVar(key),
|
||||
revealEnvVar: (key: string) => revealEnvVar(key)
|
||||
}))
|
||||
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
notify: vi.fn(),
|
||||
notifyError: vi.fn()
|
||||
}))
|
||||
|
||||
function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
|
||||
return {
|
||||
name: 'tts',
|
||||
has_category: true,
|
||||
providers: [
|
||||
{
|
||||
name: 'Microsoft Edge TTS',
|
||||
badge: 'free',
|
||||
tag: 'No API key needed',
|
||||
env_vars: [],
|
||||
post_setup: null,
|
||||
requires_nous_auth: false
|
||||
},
|
||||
{
|
||||
name: 'ElevenLabs',
|
||||
badge: 'paid',
|
||||
tag: 'Most natural voices',
|
||||
env_vars: [
|
||||
{ key: 'ELEVENLABS_API_KEY', prompt: 'ElevenLabs API key', url: 'https://x', default: null, is_set: false }
|
||||
],
|
||||
post_setup: null,
|
||||
requires_nous_auth: false
|
||||
}
|
||||
],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getToolsetConfig.mockResolvedValue(config())
|
||||
selectToolsetProvider.mockResolvedValue({ ok: true, name: 'tts', provider: 'ElevenLabs' })
|
||||
setEnvVar.mockResolvedValue({ ok: true })
|
||||
deleteEnvVar.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('ToolsetConfigPanel', () => {
|
||||
it('lists providers from the config endpoint', async () => {
|
||||
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
|
||||
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
|
||||
|
||||
expect(await screen.findByText('Microsoft Edge TTS')).toBeTruthy()
|
||||
expect(screen.getByText('ElevenLabs')).toBeTruthy()
|
||||
expect(getToolsetConfig).toHaveBeenCalledWith('tts')
|
||||
})
|
||||
|
||||
it('selects a provider when clicked', async () => {
|
||||
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
|
||||
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
|
||||
|
||||
const elevenlabs = await screen.findByRole('button', { name: /ElevenLabs/ })
|
||||
fireEvent.click(elevenlabs)
|
||||
|
||||
await waitFor(() => expect(selectToolsetProvider).toHaveBeenCalledWith('tts', 'ElevenLabs'))
|
||||
})
|
||||
|
||||
it('saves an API key for a provider env var', async () => {
|
||||
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
|
||||
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
|
||||
|
||||
// Select the keyed provider so its env vars render.
|
||||
const elevenlabs = await screen.findByRole('button', { name: /ElevenLabs/ })
|
||||
fireEvent.click(elevenlabs)
|
||||
|
||||
// Click "Set" to reveal the input for the unset key.
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Set' }))
|
||||
|
||||
const input = await screen.findByPlaceholderText('ElevenLabs API key')
|
||||
fireEvent.change(input, { target: { value: 'sk-test-123' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('ELEVENLABS_API_KEY', 'sk-test-123'))
|
||||
})
|
||||
})
|
||||
@@ -1,322 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes'
|
||||
import { Check, ExternalLink, Eye, EyeOff, Loader2, Save, Trash2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes'
|
||||
|
||||
import { Pill } from './primitives'
|
||||
|
||||
interface ToolsetConfigPanelProps {
|
||||
toolset: string
|
||||
/** Called after a key is saved/cleared or a provider chosen, so the parent
|
||||
* can refresh the "Configured / Needs keys" pill. */
|
||||
onConfiguredChange?: () => void
|
||||
}
|
||||
|
||||
function providerConfigured(provider: ToolProvider, envState: Record<string, boolean>): boolean {
|
||||
if (provider.env_vars.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return provider.env_vars.every(ev => envState[ev.key])
|
||||
}
|
||||
|
||||
interface EnvVarFieldProps {
|
||||
envVar: ToolEnvVar
|
||||
isSet: boolean
|
||||
onSaved: (key: string) => void
|
||||
onCleared: (key: string) => void
|
||||
}
|
||||
|
||||
function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [value, setValue] = useState('')
|
||||
const [revealed, setRevealed] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function handleSave() {
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
|
||||
try {
|
||||
await setEnvVar(envVar.key, value)
|
||||
setEditing(false)
|
||||
setValue('')
|
||||
onSaved(envVar.key)
|
||||
notify({ kind: 'success', title: 'Credential saved', message: `${envVar.key} updated.` })
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to save ${envVar.key}`)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClear() {
|
||||
if (!window.confirm(`Remove ${envVar.key} from .env?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
|
||||
try {
|
||||
await deleteEnvVar(envVar.key)
|
||||
setRevealed(null)
|
||||
onCleared(envVar.key)
|
||||
notify({ kind: 'success', title: 'Credential removed', message: `${envVar.key} removed.` })
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to remove ${envVar.key}`)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReveal() {
|
||||
if (revealed !== null) {
|
||||
setRevealed(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await revealEnvVar(envVar.key)
|
||||
setRevealed(result.value)
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to reveal ${envVar.key}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 rounded-lg bg-background/55 p-2.5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs font-medium">{envVar.key}</span>
|
||||
<Pill tone={isSet ? 'primary' : 'muted'}>
|
||||
{isSet && <Check className="size-3" />}
|
||||
{isSet ? 'Set' : 'Not set'}
|
||||
</Pill>
|
||||
</div>
|
||||
{envVar.prompt && envVar.prompt !== envVar.key && (
|
||||
<p className="mt-0.5 text-[0.7rem] text-muted-foreground">{envVar.prompt}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{envVar.url && (
|
||||
<Button asChild size="xs" title="Open provider docs" variant="ghost">
|
||||
<a href={envVar.url} rel="noreferrer" target="_blank">
|
||||
Docs
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{isSet && (
|
||||
<Button onClick={() => void handleReveal()} size="icon-xs" title="Reveal value" variant="ghost">
|
||||
{revealed !== null ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setEditing(e => !e)} size="xs" variant="outline">
|
||||
{isSet ? 'Replace' : 'Set'}
|
||||
</Button>
|
||||
{isSet && (
|
||||
<Button disabled={busy} onClick={() => void handleClear()} size="icon-xs" title="Clear value" variant="ghost">
|
||||
<Trash2 />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSet && revealed !== null && (
|
||||
<div className="rounded-md bg-background px-2.5 py-1.5 font-mono text-xs text-foreground">{revealed || '---'}</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
autoFocus
|
||||
className="min-w-52 flex-1 font-mono"
|
||||
onChange={e => setValue(e.target.value)}
|
||||
placeholder={envVar.prompt || envVar.key}
|
||||
type={envVar.default ? 'text' : 'password'}
|
||||
value={value}
|
||||
/>
|
||||
<Button disabled={busy || !value} onClick={() => void handleSave()} size="sm">
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Save />}
|
||||
Save
|
||||
</Button>
|
||||
<Button onClick={() => setEditing(false)} size="sm" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfigPanelProps) {
|
||||
const [cfg, setCfg] = useState<ToolsetConfig | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selecting, setSelecting] = useState<string | null>(null)
|
||||
const [activeProvider, setActiveProvider] = useState<string | null>(null)
|
||||
// Live per-key set/unset state, seeded from the endpoint then patched locally.
|
||||
const [envState, setEnvState] = useState<Record<string, boolean>>({})
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const next = await getToolsetConfig(toolset)
|
||||
setCfg(next)
|
||||
const seeded: Record<string, boolean> = {}
|
||||
|
||||
for (const provider of next.providers) {
|
||||
for (const ev of provider.env_vars) {
|
||||
seeded[ev.key] = ev.is_set
|
||||
}
|
||||
}
|
||||
|
||||
setEnvState(seeded)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Tool configuration failed to load')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [toolset])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
}, [refresh])
|
||||
|
||||
const providers = useMemo(() => cfg?.providers ?? [], [cfg])
|
||||
|
||||
// Default the expanded provider to the first one that is fully configured,
|
||||
// else the first provider.
|
||||
useEffect(() => {
|
||||
if (activeProvider || providers.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const configured = providers.find(p => providerConfigured(p, envState))
|
||||
setActiveProvider((configured ?? providers[0]).name)
|
||||
}, [activeProvider, providers, envState])
|
||||
|
||||
async function handleSelect(provider: ToolProvider) {
|
||||
setActiveProvider(provider.name)
|
||||
setSelecting(provider.name)
|
||||
|
||||
try {
|
||||
await selectToolsetProvider(toolset, provider.name)
|
||||
notify({ kind: 'success', title: 'Provider selected', message: `${provider.name} is now active.` })
|
||||
onConfiguredChange?.()
|
||||
} catch (err) {
|
||||
notifyError(err, `Failed to select ${provider.name}`)
|
||||
} finally {
|
||||
setSelecting(null)
|
||||
}
|
||||
}
|
||||
|
||||
function patchEnv(key: string, isSet: boolean) {
|
||||
setEnvState(c => ({ ...c, [key]: isSet }))
|
||||
onConfiguredChange?.()
|
||||
}
|
||||
|
||||
const emptyMessage = useMemo(() => {
|
||||
if (loading || !cfg) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!cfg.has_category) {
|
||||
return 'This toolset has no provider options — enable it and it works with your current setup.'
|
||||
}
|
||||
|
||||
if (providers.length === 0) {
|
||||
return 'No providers are available for this toolset right now.'
|
||||
}
|
||||
|
||||
return null
|
||||
}, [cfg, loading, providers.length])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-1 py-3 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Loading configuration...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (emptyMessage) {
|
||||
return <p className="px-1 py-3 text-xs text-muted-foreground">{emptyMessage}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 grid gap-2">
|
||||
{providers.map(provider => {
|
||||
const isActive = activeProvider === provider.name
|
||||
const configured = providerConfigured(provider, envState)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl bg-background/60" key={provider.name}>
|
||||
<button
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition hover:bg-accent/50',
|
||||
isActive && 'bg-accent/40'
|
||||
)}
|
||||
onClick={() => void handleSelect(provider)}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{provider.name}</span>
|
||||
{provider.badge && <Pill>{provider.badge}</Pill>}
|
||||
{configured && (
|
||||
<Pill tone="primary">
|
||||
<Check className="size-3" />
|
||||
Ready
|
||||
</Pill>
|
||||
)}
|
||||
</span>
|
||||
{selecting === provider.name && <Loader2 className="size-3.5 shrink-0 animate-spin" />}
|
||||
</button>
|
||||
|
||||
{isActive && (
|
||||
<div className="grid gap-2 bg-muted/20 p-3">
|
||||
{provider.tag && <p className="text-[0.72rem] text-muted-foreground">{provider.tag}</p>}
|
||||
{provider.requires_nous_auth && (
|
||||
<p className="text-[0.72rem] text-muted-foreground">
|
||||
Included with a Nous subscription — sign in to Nous Portal to activate.
|
||||
</p>
|
||||
)}
|
||||
{provider.env_vars.length === 0 ? (
|
||||
<p className="text-[0.72rem] text-muted-foreground">No API key required.</p>
|
||||
) : (
|
||||
provider.env_vars.map(ev => (
|
||||
<EnvVarField
|
||||
envVar={ev}
|
||||
isSet={Boolean(envState[ev.key])}
|
||||
key={ev.key}
|
||||
onCleared={key => patchEnv(key, false)}
|
||||
onSaved={key => patchEnv(key, true)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{provider.post_setup && (
|
||||
<p className="text-[0.72rem] text-muted-foreground">
|
||||
This provider needs an extra setup step ({provider.post_setup}). Run it from the CLI with{' '}
|
||||
<code className="font-mono">hermes tools</code> for now.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -21,7 +21,6 @@ const BLEND_MODES = [
|
||||
] as const
|
||||
|
||||
type BlendMode = (typeof BLEND_MODES)[number]
|
||||
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
|
||||
|
||||
export function Backdrop() {
|
||||
const [controlsOpen, setControlsOpen] = useState(false)
|
||||
@@ -100,7 +99,7 @@ export function Backdrop() {
|
||||
alt=""
|
||||
className="w-auto min-w-dvw object-cover"
|
||||
fetchPriority="low"
|
||||
src={assetPath('ds-assets/filler-bg0.jpg')}
|
||||
src="/ds-assets/filler-bg0.jpg"
|
||||
style={{
|
||||
height: `${statue.scale}dvh`,
|
||||
objectPosition: statue.objectPosition,
|
||||
|
||||
@@ -355,6 +355,7 @@ const MarkdownTextImpl = () => {
|
||||
return (
|
||||
<DeferStreamingText>
|
||||
<StreamdownTextPrimitive
|
||||
caret="block"
|
||||
components={components}
|
||||
containerClassName={cn(
|
||||
'aui-md prose w-full max-w-none overflow-hidden text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height) text-foreground',
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildToolView, type ToolPart } from './tool-fallback-model'
|
||||
|
||||
const part = (overrides: Partial<ToolPart>): ToolPart => ({
|
||||
args: {},
|
||||
isError: false,
|
||||
result: {},
|
||||
toolCallId: 'call_1',
|
||||
toolName: 'vision_analyze',
|
||||
type: 'tool-call',
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('buildToolView image handling', () => {
|
||||
// vision_analyze reports the input image as a local path; an <img> pointed at
|
||||
// a bare path resolves against the renderer origin and 404s, so we render the
|
||||
// tool codicon instead of a broken image.
|
||||
it('drops bare filesystem paths', () => {
|
||||
expect(buildToolView(part({ args: { path: '/Users/me/shot.png' } }), '').imageUrl).toBe('')
|
||||
expect(buildToolView(part({ result: { image_path: '/tmp/out.jpg' } }), '').imageUrl).toBe('')
|
||||
})
|
||||
|
||||
it('keeps fetchable data URLs', () => {
|
||||
const dataUrl = 'data:image/png;base64,AAAA'
|
||||
|
||||
expect(buildToolView(part({ result: { image_url: dataUrl } }), '').imageUrl).toBe(dataUrl)
|
||||
})
|
||||
|
||||
it('keeps remote http(s) image URLs', () => {
|
||||
const url = 'https://example.com/pic.webp'
|
||||
|
||||
expect(buildToolView(part({ result: { url } }), '').imageUrl).toBe(url)
|
||||
})
|
||||
})
|
||||
@@ -786,14 +786,9 @@ function toolImageUrl(args: Record<string, unknown>, result: Record<string, unkn
|
||||
return ''
|
||||
}
|
||||
|
||||
// Only inline-render images the renderer can actually fetch: data URLs or
|
||||
// remote http(s). A bare filesystem path (e.g. vision_analyze's input image)
|
||||
// resolves against the dev-server origin and 404s — fall back to the tool's
|
||||
// codicon instead of a broken <img>.
|
||||
const isDataImage = candidate.toLowerCase().startsWith('data:image/')
|
||||
const isRemoteImage = /^https?:\/\//i.test(candidate) && /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(candidate)
|
||||
|
||||
return isDataImage || isRemoteImage ? candidate : ''
|
||||
return candidate.toLowerCase().startsWith('data:image/') || /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(candidate)
|
||||
? candidate
|
||||
: ''
|
||||
}
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, FileText, Loader2, RefreshCw, Wrench } from '@/lib/icons'
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { $desktopOnboarding } from '@/store/onboarding'
|
||||
|
||||
type BusyAction = 'local' | 'repair' | 'retry' | null
|
||||
|
||||
// Recovery surface for a hard boot failure (gateway never came up, backend
|
||||
// exited during startup, bootstrap latched, …). Without this the app shell
|
||||
// renders dead — "gateway offline", no composer, only a toast — with no way
|
||||
// to retry, repair the install, switch the gateway, or find the logs.
|
||||
export function BootFailureOverlay() {
|
||||
const boot = useStore($desktopBoot)
|
||||
const onboarding = useStore($desktopOnboarding)
|
||||
const [busy, setBusy] = useState<BusyAction>(null)
|
||||
const [logs, setLogs] = useState<string[]>([])
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
|
||||
const visible = Boolean(boot.error) && !boot.running
|
||||
// While first-run onboarding owns the picker/flow we let it surface its own
|
||||
// progress; the recovery overlay is for hard failures, which it covers via a
|
||||
// higher z-index regardless of onboarding state.
|
||||
const suppressed = onboarding.flow.status !== 'idle' && onboarding.flow.status !== 'error'
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return
|
||||
}
|
||||
|
||||
void window.hermesDesktop
|
||||
?.getRecentLogs()
|
||||
.then(res => setLogs(res.lines ?? []))
|
||||
.catch(() => undefined)
|
||||
}, [visible])
|
||||
|
||||
if (!visible || suppressed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const retry = async () => {
|
||||
setBusy('retry')
|
||||
await window.hermesDesktop?.resetBootstrap().catch(() => undefined)
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const repair = async () => {
|
||||
setBusy('repair')
|
||||
await window.hermesDesktop?.repairBootstrap().catch(() => undefined)
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const switchToLocalGateway = async () => {
|
||||
setBusy('local')
|
||||
// applyConnectionConfig reloads the window from the main process.
|
||||
await window.hermesDesktop?.applyConnectionConfig({ mode: 'local' }).catch(() => undefined)
|
||||
setBusy(null)
|
||||
}
|
||||
|
||||
const openLogs = () => void window.hermesDesktop?.revealLogs().catch(() => undefined)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
|
||||
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
|
||||
<div className="flex items-start gap-3 border-b border-(--ui-stroke-tertiary) px-5 py-4">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Hermes couldn't start</h2>
|
||||
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
|
||||
The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your
|
||||
chats or settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 p-5">
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-xs text-destructive">
|
||||
{boot.error}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={Boolean(busy)} onClick={() => void retry()}>
|
||||
{busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
|
||||
Retry
|
||||
</Button>
|
||||
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline">
|
||||
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />}
|
||||
Repair install
|
||||
</Button>
|
||||
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="outline">
|
||||
{busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Use local gateway
|
||||
</Button>
|
||||
<Button onClick={openLogs} variant="ghost">
|
||||
<FileText className="size-4" />
|
||||
Open logs
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Repair re-runs the installer and can take a few minutes on a fresh machine.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{logs.length > 0 ? (
|
||||
<div className="grid gap-2">
|
||||
<button
|
||||
className="self-start text-xs font-medium text-muted-foreground transition hover:text-foreground"
|
||||
onClick={() => setShowLogs(v => !v)}
|
||||
type="button"
|
||||
>
|
||||
{showLogs ? 'Hide' : 'Show'} recent logs
|
||||
</button>
|
||||
{showLogs ? (
|
||||
<pre className="max-h-48 overflow-auto rounded-2xl border border-border bg-secondary/30 p-3 font-mono text-[0.7rem] leading-4 text-muted-foreground">
|
||||
{logs.slice(-40).join('')}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,18 +30,6 @@ interface HermesSyntaxHighlighterProps extends SyntaxHighlighterProps {
|
||||
|
||||
const SHIKI_THEME = { dark: 'github-dark-default', light: 'github-light-default' } as const
|
||||
|
||||
/**
|
||||
* `github-light-default` colors comments `#6e7781` (~4.2:1 against the code
|
||||
* card background) — borderline unreadable at our 11px code size, and worst of
|
||||
* all for shell snippets where a single `#` turns the rest of the line into one
|
||||
* long comment span. Remap light-mode comments to GitHub's darker muted gray
|
||||
* (`#57606a`, ~6.4:1). Dark mode (`#8b949e`, ~6.1:1) already reads fine, so we
|
||||
* leave it untouched. Keyed per theme name so the bump only applies in light.
|
||||
*/
|
||||
const SHIKI_COLOR_REPLACEMENTS: Record<string, Record<string, string>> = {
|
||||
'github-light-default': { '#6e7781': '#57606a' }
|
||||
}
|
||||
|
||||
export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
||||
components: { Pre },
|
||||
language,
|
||||
@@ -88,7 +76,6 @@ export const SyntaxHighlighter: FC<HermesSyntaxHighlighterProps> = ({
|
||||
<ShikiHighlighter
|
||||
addDefaultStyles={false}
|
||||
as="div"
|
||||
colorReplacements={SHIKI_COLOR_REPLACEMENTS}
|
||||
defaultColor="light-dark()"
|
||||
delay={120}
|
||||
language={language || 'text'}
|
||||
|
||||
@@ -25,8 +25,7 @@ function setProviders(providers: OAuthProvider[]) {
|
||||
mode: 'oauth',
|
||||
providers,
|
||||
reason: null,
|
||||
requested: false,
|
||||
manual: false
|
||||
requested: false
|
||||
} satisfies DesktopOnboardingState)
|
||||
}
|
||||
|
||||
@@ -40,8 +39,7 @@ afterEach(() => {
|
||||
mode: 'oauth',
|
||||
providers: null,
|
||||
reason: null,
|
||||
requested: false,
|
||||
manual: false
|
||||
requested: false
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { ModelPickerDialog } from '@/components/model-picker'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { getGlobalModelOptions } from '@/hermes'
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
@@ -23,7 +21,6 @@ import { $desktopBoot, type DesktopBootState } from '@/store/boot'
|
||||
import {
|
||||
$desktopOnboarding,
|
||||
cancelOnboardingFlow,
|
||||
closeManualOnboarding,
|
||||
confirmOnboardingModel,
|
||||
copyDeviceCode,
|
||||
copyExternalCommand,
|
||||
@@ -111,8 +108,6 @@ const PROVIDER_DISPLAY: Record<string, { order: number; title: string }> = {
|
||||
'qwen-oauth': { order: 5, title: 'Qwen Code' }
|
||||
}
|
||||
|
||||
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
|
||||
|
||||
const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
|
||||
pkce: 'Opens your browser to sign in, then continues here',
|
||||
device_code: 'Opens a verification page in your browser — Hermes connects automatically',
|
||||
@@ -148,19 +143,14 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
||||
// Mount from frame 1 so we replace the boot overlay seamlessly. The
|
||||
// configured field stays null until the runtime check resolves; only then
|
||||
// do we know whether to dismiss (true) or surface the picker (false).
|
||||
// EXCEPTION: manual mode (user opened the selector from a working app to
|
||||
// add/switch a provider) shows the overlay regardless of configured state.
|
||||
if (onboarding.configured === true && !onboarding.manual) {
|
||||
if (onboarding.configured === true) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { flow } = onboarding
|
||||
const rawReason = onboarding.reason?.trim() || null
|
||||
const reason = rawReason && !isProviderSetupErrorMessage(rawReason) ? rawReason : null
|
||||
// In manual mode the app is already configured, so the flow is "ready"
|
||||
// immediately — no runtime gate needed. Otherwise wait for the readiness
|
||||
// check (configured === false) before showing the picker.
|
||||
const ready = onboarding.manual || (enabled && onboarding.configured === false)
|
||||
const ready = enabled && onboarding.configured === false
|
||||
const showPicker = flow.status === 'idle' || flow.status === 'success'
|
||||
|
||||
return (
|
||||
@@ -168,17 +158,6 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
|
||||
<div className="w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
|
||||
<Header />
|
||||
<div className="grid gap-3 p-5">
|
||||
{onboarding.manual ? (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
className="text-xs font-medium text-muted-foreground transition hover:text-foreground"
|
||||
onClick={() => closeManualOnboarding()}
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{reason ? <ReasonNotice reason={reason} /> : null}
|
||||
{ready ? showPicker ? <Picker ctx={ctx} /> : <FlowPanel ctx={ctx} flow={flow} /> : <Preparing boot={boot} />}
|
||||
</div>
|
||||
@@ -200,6 +179,10 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
|
||||
const hasError = Boolean(boot.error)
|
||||
const installing = boot.phase.startsWith('runtime.')
|
||||
|
||||
const resetToLocalGateway = async () => {
|
||||
await window.hermesDesktop?.applyConnectionConfig({ mode: 'local' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3" role="status">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -220,7 +203,16 @@ function Preparing({ boot }: { boot: DesktopBootState }) {
|
||||
<span className="truncate">{boot.message}</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
{hasError ? <p className="text-xs text-destructive">{boot.error}</p> : null}
|
||||
{hasError ? (
|
||||
<div className="grid gap-3">
|
||||
<p className="text-xs text-destructive">{boot.error}</p>
|
||||
<div>
|
||||
<Button onClick={() => void resetToLocalGateway()} size="sm" variant="outline">
|
||||
Use local gateway
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -341,7 +333,7 @@ function FeaturedProviderRow({
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<img alt="" className="size-5 shrink-0 rounded" src={assetPath('apple-touch-icon.png')} />
|
||||
<img alt="" className="size-5 shrink-0 rounded" src="/apple-touch-icon.png" />
|
||||
<span className="text-base font-semibold">{providerTitle(provider)}</span>
|
||||
{loggedIn ? (
|
||||
<ConnectedTag />
|
||||
@@ -666,18 +658,6 @@ function ConfirmingModelPanel({
|
||||
// a familiar UI for users who'll see this picker again later.
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
|
||||
// Pull pricing + tier for the just-picked default so the confirm card
|
||||
// shows the same $/Mtok + Free/Pro info the picker and CLI do.
|
||||
const options = useQuery({
|
||||
queryKey: ['onboarding-model-options', flow.providerSlug],
|
||||
queryFn: () => getGlobalModelOptions()
|
||||
})
|
||||
const providerRow = options.data?.providers?.find(
|
||||
p => String(p.slug).toLowerCase() === flow.providerSlug.toLowerCase()
|
||||
)
|
||||
const price = providerRow?.pricing?.[flow.currentModel]
|
||||
const freeTier = providerRow?.free_tier
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
|
||||
@@ -688,25 +668,8 @@ function ConfirmingModelPanel({
|
||||
<div className="grid gap-3 rounded-2xl border border-border bg-background/60 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Default model</p>
|
||||
{freeTier === true && (
|
||||
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||
Free tier
|
||||
</span>
|
||||
)}
|
||||
{freeTier === false && (
|
||||
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Default model</p>
|
||||
<p className="mt-1 truncate font-mono text-sm">{flow.currentModel}</p>
|
||||
{price && (price.input || price.output) && (
|
||||
<p className="mt-1 font-mono text-xs text-muted-foreground">
|
||||
{price.free ? 'Free' : `${price.input || '?'} in / ${price.output || '?'} out per Mtok`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button disabled={flow.saving} onClick={() => setPickerOpen(true)} size="sm" variant="outline">
|
||||
Change
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { ModelOptionProvider, ModelOptionsResponse, ModelPricing } from '@/types/hermes'
|
||||
import type { ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import type { HermesGateway } from '../hermes'
|
||||
import { getGlobalModelOptions } from '../hermes'
|
||||
import { cn } from '../lib/utils'
|
||||
import { startManualOnboarding } from '../store/onboarding'
|
||||
|
||||
import { InlineNotice } from './notifications'
|
||||
import { Button } from './ui/button'
|
||||
@@ -43,12 +42,6 @@ export function ModelPickerDialog({
|
||||
contentClassName
|
||||
}: ModelPickerDialogProps) {
|
||||
const [persistGlobal, setPersistGlobal] = useState(!sessionId)
|
||||
// Own the search term so we can filter manually. cmdk's built-in
|
||||
// shouldFilter reorders items by its fuzzy-match score (≈alphabetical with
|
||||
// an empty query), which destroys the backend's curated order. We disable
|
||||
// it and do a plain substring filter that preserves array order — matching
|
||||
// the `hermes model` CLI picker, which shows the curated list verbatim.
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const modelOptions = useQuery({
|
||||
queryKey: ['model-options', sessionId || 'global'],
|
||||
@@ -84,15 +77,6 @@ export function ModelPickerDialog({
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
// Open the full onboarding provider selector to add/switch a provider.
|
||||
// Reuses the entire onboarding flow (OAuth rows, API-key form, device-code,
|
||||
// model-confirm) instead of duplicating provider UI here. Closes the picker
|
||||
// so the onboarding overlay (z-1300) isn't rendered underneath it.
|
||||
const addProvider = () => {
|
||||
startManualOnboarding()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className={cn('max-h-[85vh] max-w-2xl gap-0 overflow-hidden p-0', contentClassName)}>
|
||||
@@ -104,13 +88,8 @@ export function ModelPickerDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Command className="rounded-none bg-card" shouldFilter={false}>
|
||||
<CommandInput
|
||||
autoFocus
|
||||
onValueChange={setSearch}
|
||||
placeholder="Filter providers and models..."
|
||||
value={search}
|
||||
/>
|
||||
<Command className="rounded-none bg-card">
|
||||
<CommandInput autoFocus placeholder="Filter providers and models..." />
|
||||
<CommandList className="max-h-96">
|
||||
{!loading && !error && <CommandEmpty>No models found.</CommandEmpty>}
|
||||
<ModelResults
|
||||
@@ -120,7 +99,6 @@ export function ModelPickerDialog({
|
||||
loading={loading}
|
||||
onSelectModel={selectModel}
|
||||
providers={providers}
|
||||
search={search}
|
||||
/>
|
||||
</CommandList>
|
||||
</Command>
|
||||
@@ -135,14 +113,9 @@ export function ModelPickerDialog({
|
||||
{sessionId ? 'Persist globally (otherwise this session only)' : 'Persist globally'}
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={addProvider} variant="ghost">
|
||||
Add provider
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<Button onClick={() => onOpenChange(false)} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -155,8 +128,7 @@ function ModelResults({
|
||||
providers,
|
||||
currentModel,
|
||||
currentProvider,
|
||||
onSelectModel,
|
||||
search
|
||||
onSelectModel
|
||||
}: {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
@@ -164,7 +136,6 @@ function ModelResults({
|
||||
currentModel: string
|
||||
currentProvider: string
|
||||
onSelectModel: (provider: ModelOptionProvider, model: string) => void
|
||||
search: string
|
||||
}) {
|
||||
if (loading) {
|
||||
return <LoadingResults />
|
||||
@@ -184,30 +155,15 @@ function ModelResults({
|
||||
return <div className="px-4 py-6 text-sm text-muted-foreground">No authenticated providers.</div>
|
||||
}
|
||||
|
||||
const q = search.trim().toLowerCase()
|
||||
const matches = (provider: ModelOptionProvider, model: string) =>
|
||||
!q ||
|
||||
model.toLowerCase().includes(q) ||
|
||||
provider.name.toLowerCase().includes(q) ||
|
||||
provider.slug.toLowerCase().includes(q)
|
||||
|
||||
// Only configured providers (those with curated models) are selectable
|
||||
// here. Switching to a NOT-yet-configured provider goes through the
|
||||
// "Add provider" footer button, which opens the full onboarding selector.
|
||||
const configured = providers.filter(p => (p.models ?? []).length > 0)
|
||||
|
||||
return (
|
||||
<>
|
||||
{configured.map(provider => {
|
||||
// Preserve the backend's curated order — filter in place, no re-sort.
|
||||
const models = (provider.models ?? []).filter(m => matches(provider, m))
|
||||
{providers.map(provider => {
|
||||
const models = provider.models ?? []
|
||||
|
||||
if (models.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const unavailable = new Set(provider.unavailable_models ?? [])
|
||||
|
||||
return (
|
||||
<CommandGroup heading={<ProviderHeading provider={provider} />} key={provider.slug}>
|
||||
{provider.warning && (
|
||||
@@ -219,37 +175,22 @@ function ModelResults({
|
||||
)}
|
||||
{models.map(model => {
|
||||
const isCurrent = model === currentModel && provider.slug === currentProvider
|
||||
const price = provider.pricing?.[model]
|
||||
const locked = unavailable.has(model)
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className={cn(
|
||||
'flex items-center gap-2 pl-6 font-mono',
|
||||
'pl-6 font-mono',
|
||||
isCurrent &&
|
||||
'bg-primary text-primary-foreground data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground',
|
||||
locked && 'cursor-not-allowed opacity-45'
|
||||
'bg-primary text-primary-foreground data-[selected=true]:bg-primary data-[selected=true]:text-primary-foreground'
|
||||
)}
|
||||
disabled={locked}
|
||||
key={`${provider.slug}:${model}`}
|
||||
onSelect={() => {
|
||||
if (!locked) {
|
||||
onSelectModel(provider, model)
|
||||
}
|
||||
}}
|
||||
value={`${provider.slug}:${model}`}
|
||||
onSelect={() => onSelectModel(provider, model)}
|
||||
value={`${provider.name} ${provider.slug} ${model}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{model}</span>
|
||||
{locked && <span className="shrink-0 text-[0.62rem] uppercase tracking-wide opacity-80">Pro</span>}
|
||||
<ModelPrice isCurrent={isCurrent} price={price} />
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
{unavailable.size > 0 && (
|
||||
<div className="px-6 pb-2 pt-1 text-[0.62rem] leading-relaxed text-muted-foreground">
|
||||
Pro models need a paid Nous subscription.
|
||||
</div>
|
||||
)}
|
||||
</CommandGroup>
|
||||
)
|
||||
})}
|
||||
@@ -257,39 +198,6 @@ function ModelResults({
|
||||
)
|
||||
}
|
||||
|
||||
// Compact In/Out $/Mtok price tag, mirroring the CLI picker's price columns.
|
||||
// Renders nothing when pricing is unavailable for the model.
|
||||
function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boolean }) {
|
||||
if (!price || (!price.input && !price.output)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (price.free) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-sm px-1 py-0.5 text-[0.62rem] font-semibold uppercase tracking-wide',
|
||||
isCurrent ? 'bg-primary-foreground/20' : 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
|
||||
)}
|
||||
>
|
||||
Free
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 text-[0.66rem] tabular-nums',
|
||||
isCurrent ? 'text-primary-foreground/80' : 'text-muted-foreground'
|
||||
)}
|
||||
title="Input / Output price per million tokens"
|
||||
>
|
||||
{price.input || '?'} / {price.output || '?'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingResults() {
|
||||
return (
|
||||
<CommandGroup heading={<Skeleton className="h-3 w-32" />}>
|
||||
@@ -303,25 +211,12 @@ function LoadingResults() {
|
||||
}
|
||||
|
||||
function ProviderHeading({ provider }: { provider: ModelOptionProvider }) {
|
||||
// free_tier is only set for Nous. true → "Free tier", false → "Pro".
|
||||
const tierBadge =
|
||||
provider.free_tier === true ? (
|
||||
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
|
||||
Free tier
|
||||
</span>
|
||||
) : provider.free_tier === false ? (
|
||||
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
|
||||
Pro
|
||||
</span>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">{provider.name}</span>
|
||||
<span className="font-mono text-xs font-normal normal-case tracking-normal text-muted-foreground">
|
||||
{provider.slug} · {provider.total_models ?? provider.models?.length ?? 0}
|
||||
</span>
|
||||
{tierBadge}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
Vendored
-3
@@ -27,8 +27,6 @@ declare global {
|
||||
setPreviewShortcutActive?: (active: boolean) => void
|
||||
openExternal: (url: string) => Promise<void>
|
||||
fetchLinkTitle: (url: string) => Promise<string>
|
||||
revealLogs: () => Promise<{ ok: boolean; path: string; error?: string }>
|
||||
getRecentLogs: () => Promise<{ path: string; lines: string[] }>
|
||||
readDir: (path: string) => Promise<HermesReadDirResult>
|
||||
gitRoot?: (path: string) => Promise<string | null>
|
||||
terminal: {
|
||||
@@ -47,7 +45,6 @@ declare global {
|
||||
onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void
|
||||
getBootstrapState: () => Promise<DesktopBootstrapState>
|
||||
resetBootstrap: () => Promise<{ ok: boolean }>
|
||||
repairBootstrap: () => Promise<{ ok: boolean }>
|
||||
onBootstrapEvent: (callback: (payload: DesktopBootstrapEvent) => void) => () => void
|
||||
getVersion: () => Promise<DesktopVersionInfo>
|
||||
updates: {
|
||||
|
||||
@@ -36,7 +36,6 @@ import type {
|
||||
SessionSearchResponse,
|
||||
SkillInfo,
|
||||
StatusResponse,
|
||||
ToolsetConfig,
|
||||
ToolsetInfo
|
||||
} from '@/types/hermes'
|
||||
|
||||
@@ -95,7 +94,6 @@ export type {
|
||||
SessionSearchResult,
|
||||
SkillInfo,
|
||||
StatusResponse,
|
||||
ToolsetConfig,
|
||||
ToolsetInfo
|
||||
} from '@/types/hermes'
|
||||
|
||||
@@ -239,17 +237,6 @@ export function setEnvVar(key: string, value: string): Promise<{ ok: boolean }>
|
||||
})
|
||||
}
|
||||
|
||||
export function validateProviderCredential(
|
||||
key: string,
|
||||
value: string
|
||||
): Promise<{ ok: boolean; reachable: boolean; message: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; reachable: boolean; message: string }>({
|
||||
path: '/api/providers/validate',
|
||||
method: 'POST',
|
||||
body: { key, value }
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteEnvVar(key: string): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
path: '/api/env',
|
||||
@@ -321,34 +308,6 @@ export function getToolsets(): Promise<ToolsetInfo[]> {
|
||||
})
|
||||
}
|
||||
|
||||
export function toggleToolset(
|
||||
name: string,
|
||||
enabled: boolean
|
||||
): Promise<{ ok: boolean; name: string; enabled: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; name: string; enabled: boolean }>({
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}`,
|
||||
method: 'PUT',
|
||||
body: { enabled }
|
||||
})
|
||||
}
|
||||
|
||||
export function getToolsetConfig(name: string): Promise<ToolsetConfig> {
|
||||
return window.hermesDesktop.api<ToolsetConfig>({
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}/config`
|
||||
})
|
||||
}
|
||||
|
||||
export function selectToolsetProvider(
|
||||
name: string,
|
||||
provider: string
|
||||
): Promise<{ ok: boolean; name: string; provider: string }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean; name: string; provider: string }>({
|
||||
path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`,
|
||||
method: 'PUT',
|
||||
body: { provider }
|
||||
})
|
||||
}
|
||||
|
||||
export function getMessagingPlatforms(): Promise<MessagingPlatformsResponse> {
|
||||
return window.hermesDesktop.api<MessagingPlatformsResponse>({
|
||||
path: '/api/messaging/platforms'
|
||||
@@ -490,22 +449,6 @@ export function getGlobalModelOptions(): Promise<ModelOptionsResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
export interface RecommendedDefaultModel {
|
||||
provider: string
|
||||
model: string
|
||||
/** True/false for Nous (free vs paid tier); null for other providers. */
|
||||
free_tier: boolean | null
|
||||
}
|
||||
|
||||
// Recommended default model for a freshly-authenticated provider. Mirrors the
|
||||
// curation `hermes model` does — for Nous it honors the free/paid tier so a
|
||||
// free user gets a free model instead of a paid default.
|
||||
export function getRecommendedDefaultModel(provider: string): Promise<RecommendedDefaultModel> {
|
||||
return window.hermesDesktop.api<RecommendedDefaultModel>({
|
||||
path: `/api/model/recommended-default?provider=${encodeURIComponent(provider)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function setGlobalModel(
|
||||
provider: string,
|
||||
model: string
|
||||
|
||||
@@ -68,8 +68,7 @@ const ERROR_SUMMARIES: { test: (msg: string) => boolean; summarize: (msg: string
|
||||
},
|
||||
{
|
||||
test: msg => /method not allowed/i.test(msg),
|
||||
summarize: () =>
|
||||
'The desktop backend rejected that request (405 Method Not Allowed). Try restarting Hermes Desktop.'
|
||||
summarize: () => 'The desktop backend does not support that audio endpoint yet. Restart Hermes Desktop.'
|
||||
},
|
||||
{
|
||||
test: msg => /microphone permission/i.test(msg),
|
||||
|
||||
@@ -29,7 +29,6 @@ function baseState(overrides: Partial<DesktopOnboardingState> = {}): DesktopOnbo
|
||||
providers: null,
|
||||
reason: null,
|
||||
requested: false,
|
||||
manual: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,12 @@ import { atom } from 'nanostores'
|
||||
import {
|
||||
cancelOAuthSession,
|
||||
getGlobalModelOptions,
|
||||
getRecommendedDefaultModel,
|
||||
listOAuthProviders,
|
||||
pollOAuthSession,
|
||||
setEnvVar,
|
||||
setModelAssignment,
|
||||
startOAuthLogin,
|
||||
submitOAuthCode,
|
||||
validateProviderCredential
|
||||
submitOAuthCode
|
||||
} from '@/hermes'
|
||||
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
@@ -55,11 +53,6 @@ export interface DesktopOnboardingState {
|
||||
providers: null | OAuthProvider[]
|
||||
reason: null | string
|
||||
requested: boolean
|
||||
/** True when the user explicitly opened the provider selector to add /
|
||||
* switch providers from an already-configured app (e.g. via the model
|
||||
* picker's "Add provider" button). Forces the overlay to show the picker
|
||||
* even when configured === true, and adds a close affordance. */
|
||||
manual: boolean
|
||||
}
|
||||
|
||||
export interface OnboardingContext {
|
||||
@@ -106,8 +99,7 @@ const INITIAL: DesktopOnboardingState = {
|
||||
mode: 'oauth',
|
||||
providers: null,
|
||||
reason: null,
|
||||
requested: false,
|
||||
manual: false
|
||||
requested: false
|
||||
}
|
||||
|
||||
export const $desktopOnboarding = atom<DesktopOnboardingState>(INITIAL)
|
||||
@@ -142,36 +134,6 @@ function notifyReady(provider: string) {
|
||||
notify({ kind: 'success', title: 'Hermes is ready', message: `${provider} connected.` })
|
||||
}
|
||||
|
||||
// Human-friendly labels for tools auto-routed through the Nous Tool Gateway,
|
||||
// mirroring hermes_cli/nous_subscription._GATEWAY_TOOL_LABELS so the GUI and
|
||||
// CLI describe the same thing.
|
||||
const GATEWAY_TOOL_LABELS: Record<string, string> = {
|
||||
browser: 'browser automation',
|
||||
image_gen: 'image generation',
|
||||
tts: 'text-to-speech',
|
||||
video_gen: 'video generation',
|
||||
web: 'web search & extract'
|
||||
}
|
||||
|
||||
// When switching to Nous auto-routes unconfigured tools through the Tool
|
||||
// Gateway, tell the user which ones — same information the CLI prints. Silent
|
||||
// when nothing changed (subscriber already configured, has own keys, etc.).
|
||||
function notifyGatewayTools(tools: string[] | undefined) {
|
||||
if (!tools || tools.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const labels = tools.map(t => GATEWAY_TOOL_LABELS[t] ?? t)
|
||||
const list = labels.length === 1 ? labels[0] : `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`
|
||||
|
||||
notify({
|
||||
durationMs: 8000,
|
||||
kind: 'info',
|
||||
message: `${list} now run through your Nous subscription — no separate API keys needed.`,
|
||||
title: 'Tool Gateway enabled'
|
||||
})
|
||||
}
|
||||
|
||||
// After credentials are persisted, ask the backend which provider+models
|
||||
// are now authenticated. Pick the first curated model for the matching
|
||||
// provider as a sensible default, persist it via /api/model/set, and
|
||||
@@ -212,28 +174,9 @@ async function fetchProviderDefaultModel(
|
||||
return null
|
||||
}
|
||||
|
||||
// Prefer the backend's recommended default — it mirrors the curation
|
||||
// `hermes model` does (for Nous it honors the user's free/paid tier, so a
|
||||
// free user gets a free model rather than a paid default like opus). Fall
|
||||
// back to the first curated model if the endpoint can't resolve one.
|
||||
let defaultModel = String(models[0])
|
||||
try {
|
||||
const recommended = await getRecommendedDefaultModel(String(matched.slug))
|
||||
if (recommended.model && models.map(String).includes(recommended.model)) {
|
||||
defaultModel = recommended.model
|
||||
} else if (recommended.model) {
|
||||
// Recommended model isn't in the curated options list (e.g. a Portal
|
||||
// free-recommendation the picker list didn't include); trust it anyway.
|
||||
defaultModel = recommended.model
|
||||
}
|
||||
} catch {
|
||||
// Endpoint unavailable — keep models[0]. Non-fatal: the confirm card still
|
||||
// shows and the user can change it.
|
||||
}
|
||||
|
||||
return {
|
||||
providerSlug: String(matched.slug),
|
||||
defaultModel
|
||||
defaultModel: String(models[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,12 +221,11 @@ async function completeWithModelConfirm(
|
||||
// (3) If they bail out (e.g., refresh the page), they still end up
|
||||
// with a working config, not an empty-model fallback.
|
||||
try {
|
||||
const res = await setModelAssignment({
|
||||
await setModelAssignment({
|
||||
scope: 'main',
|
||||
provider: defaults.providerSlug,
|
||||
model: defaults.defaultModel
|
||||
})
|
||||
notifyGatewayTools(res.gateway_tools)
|
||||
} catch {
|
||||
// Persistence failed — still show the confirm card so the user can
|
||||
// pick something explicitly. The backend will pick its own default
|
||||
@@ -332,28 +274,6 @@ export function requestDesktopOnboarding(reason = DEFAULT_ONBOARDING_REASON) {
|
||||
patch({ reason: reason.trim() || DEFAULT_ONBOARDING_REASON, requested: true })
|
||||
}
|
||||
|
||||
// Open the onboarding provider selector on demand from an already-configured
|
||||
// app — e.g. the model picker's "Add provider" button. Reuses the entire
|
||||
// onboarding flow (OAuth rows, API-key form, model-confirm) instead of
|
||||
// duplicating provider UI. Sets manual=true so the overlay shows the picker
|
||||
// even though configured===true, and refreshes the provider list.
|
||||
export function startManualOnboarding(reason = 'Add or switch inference provider.') {
|
||||
patch({
|
||||
manual: true,
|
||||
requested: true,
|
||||
reason: reason.trim() || DEFAULT_ONBOARDING_REASON,
|
||||
flow: { status: 'idle' }
|
||||
})
|
||||
void refreshProviders()
|
||||
}
|
||||
|
||||
// Dismiss a manually-opened provider selector without touching the existing
|
||||
// (working) configuration. Only valid in the manual path — the unconfigured
|
||||
// first-run flow has no close affordance because the app can't run yet.
|
||||
export function closeManualOnboarding() {
|
||||
patch({ manual: false, requested: false, flow: { status: 'idle' } })
|
||||
}
|
||||
|
||||
export function completeDesktopOnboarding() {
|
||||
clearPoll()
|
||||
writeCachedConfigured(true)
|
||||
@@ -363,8 +283,7 @@ export function completeDesktopOnboarding() {
|
||||
mode: 'oauth',
|
||||
providers: null,
|
||||
reason: null,
|
||||
requested: false,
|
||||
manual: false
|
||||
requested: false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -373,15 +292,6 @@ export function setOnboardingMode(mode: OnboardingMode) {
|
||||
}
|
||||
|
||||
export async function refreshOnboarding(ctx: OnboardingContext) {
|
||||
// Manual mode (user opened the selector from a working app): never
|
||||
// auto-dismiss on runtime-ready — the whole point is to let them add /
|
||||
// switch a provider while already configured. Just ensure the provider
|
||||
// list is loaded and show the picker.
|
||||
if ($desktopOnboarding.get().manual) {
|
||||
await refreshProviders()
|
||||
return false
|
||||
}
|
||||
|
||||
const runtime = await checkRuntime(ctx)
|
||||
|
||||
if (runtime.ready) {
|
||||
@@ -578,19 +488,6 @@ export async function saveOnboardingApiKey(envKey: string, value: string, label:
|
||||
return { ok: false, message: 'Enter a value first.' }
|
||||
}
|
||||
|
||||
// Live-probe the credential BEFORE persisting so a mistyped key never lands
|
||||
// in .env. A rejected key (reachable && !ok) hard-blocks; an unreachable
|
||||
// probe (offline / provider down) falls through and saves with the usual
|
||||
// runtime check, so we don't strand offline users.
|
||||
try {
|
||||
const probe = await validateProviderCredential(envKey, trimmed)
|
||||
if (!probe.ok && probe.reachable) {
|
||||
return { ok: false, message: probe.message || `That ${label} key was rejected.` }
|
||||
}
|
||||
} catch {
|
||||
// Validation endpoint unavailable — don't block; fall through to save.
|
||||
}
|
||||
|
||||
try {
|
||||
await setEnvVar(envKey, trimmed)
|
||||
let stillFailing = false
|
||||
|
||||
@@ -50,36 +50,6 @@ export const resetUpdateApplyState = () => $updateApply.set(IDLE)
|
||||
const UPDATE_TOAST_ID = 'desktop-update-available'
|
||||
const UPDATE_TOAST_DISMISSED_KEY = 'hermes:update-toast-dismissed-sha'
|
||||
|
||||
// Must match tui_gateway's DESKTOP_BACKEND_CONTRACT that this build was written
|
||||
// against. The backend reports its own value in session runtime info; a lower
|
||||
// value (or none — a pre-GUI checkout) means GUI<->backend skew.
|
||||
const REQUIRED_BACKEND_CONTRACT = 1
|
||||
const SKEW_TOAST_ID = 'backend-contract-skew'
|
||||
|
||||
/**
|
||||
* Guard against a desktop GUI talking to a backend that predates its contract
|
||||
* (e.g. a bb/gui-built app pointed at a `main` checkout). Rather than failing
|
||||
* cryptically downstream, surface a persistent warning with a one-click align
|
||||
* that runs the normal update flow (which self-heals to the right branch).
|
||||
*/
|
||||
export function reportBackendContract(contract: number | undefined): void {
|
||||
if ((contract ?? 0) >= REQUIRED_BACKEND_CONTRACT) {
|
||||
dismissNotification(SKEW_TOAST_ID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notify({
|
||||
action: { label: 'Update Hermes', onClick: () => void applyUpdates() },
|
||||
durationMs: 0,
|
||||
id: SKEW_TOAST_ID,
|
||||
kind: 'warning',
|
||||
message:
|
||||
'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
|
||||
title: 'Backend out of date'
|
||||
})
|
||||
}
|
||||
|
||||
function markToastDismissed(sha: string | undefined) {
|
||||
if (sha) {
|
||||
persistString(UPDATE_TOAST_DISMISSED_KEY, sha)
|
||||
@@ -184,7 +154,6 @@ export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promis
|
||||
|
||||
try {
|
||||
const result = await bridge.apply(opts)
|
||||
|
||||
// CLI install with no staged updater: not an error — the user just runs
|
||||
// `hermes update` themselves. Land on a dedicated manual state so the
|
||||
// overlay shows the command + copy button instead of a dead retry loop.
|
||||
@@ -197,7 +166,6 @@ export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promis
|
||||
command: result.command ?? 'hermes update'
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
@@ -948,6 +948,55 @@ canvas {
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
/* Streaming caret + comet tail. Anchored to the cursor (not the block's
|
||||
right edge) so it reads on short last lines. Spread inflates the bg-color
|
||||
veil so it overlays text instead of haloing the tiny dither block. */
|
||||
[data-slot='aui_assistant-message-content']
|
||||
[data-status='running']
|
||||
> div
|
||||
> *:last-child::after {
|
||||
content: '' !important;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 0.6em;
|
||||
height: 1em;
|
||||
margin-left: 0.18em;
|
||||
/* Net-zero the caret's inline advance so it paints at the text end without
|
||||
consuming layout width. Otherwise its ~0.78em footprint can wrap the last
|
||||
line mid-stream, and removing the caret on completion un-wraps it — the
|
||||
visible "layout shift after the cursor goes away". */
|
||||
margin-right: calc(-0.6em - 0.18em);
|
||||
vertical-align: middle;
|
||||
border-radius: 0.09375rem;
|
||||
background: repeating-conic-gradient(currentColor 0% 25%, transparent 0% 50%) 0 0 / 0.125rem 0.125rem;
|
||||
color: color-mix(in srgb, var(--dt-foreground) 72%, transparent);
|
||||
box-shadow:
|
||||
-0.8ch 0 1.4ch 0.55em color-mix(in srgb, var(--dt-background) 80%, transparent),
|
||||
-3ch 0 2.4ch 0.55em color-mix(in srgb, var(--dt-background) 64%, transparent),
|
||||
-6.5ch 0 3.6ch 0.5em color-mix(in srgb, var(--dt-background) 44%, transparent),
|
||||
-11ch 0 5ch 0.45em color-mix(in srgb, var(--dt-background) 28%, transparent),
|
||||
-16ch 0 6.4ch 0.4em color-mix(in srgb, var(--dt-background) 16%, transparent),
|
||||
-22ch 0 7.6ch 0.35em color-mix(in srgb, var(--dt-background) 8%, transparent),
|
||||
0 0 0.4ch color-mix(in srgb, var(--dt-foreground) 28%, transparent);
|
||||
animation: hermes-stream-caret 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes hermes-stream-caret {
|
||||
0%, 100% { opacity: 0.95; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-slot='aui_assistant-message-content']
|
||||
[data-status='running']
|
||||
> div
|
||||
> *:last-child::after {
|
||||
animation: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Live thinking preview window. Pairs with the ResizeObserver in
|
||||
ThinkingDisclosure that pins scrollTop to the bottom — older lines fade
|
||||
into the top mask while the latest tokens settle in below. */
|
||||
|
||||
@@ -185,17 +185,6 @@ export interface ModelInfoResponse {
|
||||
provider: string
|
||||
}
|
||||
|
||||
export interface ModelPricing {
|
||||
/** Formatted $/Mtok input price, e.g. "$3.00", or "free", or "" if unknown. */
|
||||
input: string
|
||||
/** Formatted $/Mtok output price. */
|
||||
output: string
|
||||
/** Formatted $/Mtok cached-input price, or null when the model has none. */
|
||||
cache: string | null
|
||||
/** True when the model costs nothing (free tier eligible). */
|
||||
free: boolean
|
||||
}
|
||||
|
||||
export interface ModelOptionProvider {
|
||||
is_current?: boolean
|
||||
models?: string[]
|
||||
@@ -203,13 +192,6 @@ export interface ModelOptionProvider {
|
||||
slug: string
|
||||
total_models?: number
|
||||
warning?: string
|
||||
/** Per-model pricing keyed by model id (present when the picker requested
|
||||
* pricing and the provider supports live pricing). */
|
||||
pricing?: Record<string, ModelPricing>
|
||||
/** Nous only: whether the current account is on the free tier. */
|
||||
free_tier?: boolean
|
||||
/** Nous only: paid models a free-tier user cannot select (shown disabled). */
|
||||
unavailable_models?: string[]
|
||||
}
|
||||
|
||||
export interface ModelOptionsResponse {
|
||||
@@ -290,7 +272,6 @@ export interface SessionRuntimeInfo {
|
||||
config_warning?: string
|
||||
credential_warning?: string
|
||||
cwd?: string
|
||||
desktop_contract?: number
|
||||
fast?: boolean
|
||||
model?: string
|
||||
personality?: string
|
||||
@@ -455,29 +436,6 @@ export interface ToolsetInfo {
|
||||
tools: string[]
|
||||
}
|
||||
|
||||
export interface ToolEnvVar {
|
||||
key: string
|
||||
prompt: string
|
||||
url: string | null
|
||||
default: string | null
|
||||
is_set: boolean
|
||||
}
|
||||
|
||||
export interface ToolProvider {
|
||||
name: string
|
||||
badge: string
|
||||
tag: string
|
||||
env_vars: ToolEnvVar[]
|
||||
post_setup: string | null
|
||||
requires_nous_auth: boolean
|
||||
}
|
||||
|
||||
export interface ToolsetConfig {
|
||||
name: string
|
||||
has_category: boolean
|
||||
providers: ToolProvider[]
|
||||
}
|
||||
|
||||
export interface SessionSearchResult {
|
||||
model: string | null
|
||||
role: string | null
|
||||
@@ -555,10 +513,6 @@ export interface ModelAssignmentRequest {
|
||||
}
|
||||
|
||||
export interface ModelAssignmentResponse {
|
||||
/** Toolset keys auto-routed through the Nous Tool Gateway as a result of
|
||||
* switching the main provider to Nous. Empty unless provider === 'nous'
|
||||
* and the user is a paid subscriber with unconfigured tools. */
|
||||
gateway_tools?: string[]
|
||||
model?: string
|
||||
ok: boolean
|
||||
provider?: string
|
||||
|
||||
@@ -787,10 +787,8 @@ def AIAgent(*args, **kwargs):
|
||||
|
||||
|
||||
def get_tool_definitions(*args, **kwargs):
|
||||
from hermes_cli.mcp_startup import wait_for_mcp_discovery
|
||||
from model_tools import get_tool_definitions as _get_tool_definitions
|
||||
|
||||
wait_for_mcp_discovery()
|
||||
return _get_tool_definitions(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -898,12 +896,9 @@ def _prepare_deferred_agent_startup() -> None:
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
from hermes_cli.mcp_startup import start_background_mcp_discovery
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
|
||||
start_background_mcp_discovery(
|
||||
logger=logger,
|
||||
thread_name="termux-cli-mcp-discovery",
|
||||
)
|
||||
discover_mcp_tools()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"MCP tool discovery failed at deferred CLI startup",
|
||||
@@ -1542,17 +1537,9 @@ def _query_osc11_background() -> str | None:
|
||||
Most modern terminals reply with \x1b]11;rgb:RRRR/GGGG/BBBB\x1b\\
|
||||
within a few ms. We wait up to 100ms total before giving up.
|
||||
Returns "#RRGGBB" or None on timeout / non-tty.
|
||||
|
||||
Skipped over SSH: the round-trip routinely exceeds our 100ms budget, so a
|
||||
late reply lands after prompt_toolkit has grabbed the tty — its payload
|
||||
leaks in as typed text and the BEL terminator reads as Ctrl+G (open
|
||||
editor), trapping the user in a stray editor. Remote sessions fall back to
|
||||
COLORFGBG / env hints / the dark default instead.
|
||||
"""
|
||||
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
||||
return None
|
||||
if any(os.environ.get(v) for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")):
|
||||
return None
|
||||
try:
|
||||
import termios
|
||||
import tty
|
||||
@@ -1600,11 +1587,8 @@ def _query_osc11_background() -> str | None:
|
||||
r, g, b = norm(m.group(1)), norm(m.group(2)), norm(m.group(3))
|
||||
return f"#{r:02X}{g:02X}{b:02X}"
|
||||
finally:
|
||||
# TCSAFLUSH discards any unread input as it restores the original
|
||||
# attributes — scrubs a slow/partial OSC 11 reply out of the tty
|
||||
# buffer before prompt_toolkit can read it as keystrokes.
|
||||
try:
|
||||
termios.tcsetattr(fd, termios.TCSAFLUSH, old)
|
||||
termios.tcsetattr(fd, termios.TCSANOW, old)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -3578,17 +3562,8 @@ class HermesCLI:
|
||||
|
||||
compressor = getattr(agent, "context_compressor", None)
|
||||
if compressor:
|
||||
# last_prompt_tokens is parked at the -1 sentinel right after a
|
||||
# compression, until the next real API call reports a prompt count
|
||||
# (awaiting_real_usage_after_compression). The status bar must not
|
||||
# render that sentinel verbatim — it produced "-1/200K" / "-1%".
|
||||
# Clamp it to 0 so the one transitional turn reads as empty context.
|
||||
context_tokens = getattr(compressor, "last_prompt_tokens", 0) or 0
|
||||
if context_tokens < 0:
|
||||
context_tokens = 0
|
||||
context_length = getattr(compressor, "context_length", 0) or 0
|
||||
if context_length < 0:
|
||||
context_length = 0
|
||||
snapshot["context_tokens"] = context_tokens
|
||||
snapshot["context_length"] = context_length or None
|
||||
snapshot["compressions"] = getattr(compressor, "compression_count", 0) or 0
|
||||
@@ -4897,10 +4872,6 @@ class HermesCLI:
|
||||
if not self._ensure_runtime_credentials():
|
||||
return False
|
||||
|
||||
from hermes_cli.mcp_startup import wait_for_mcp_discovery
|
||||
|
||||
wait_for_mcp_discovery()
|
||||
|
||||
# Initialize SQLite session store for CLI sessions (if not already done in __init__)
|
||||
if self._session_db is None:
|
||||
try:
|
||||
@@ -5559,7 +5530,7 @@ class HermesCLI:
|
||||
# Also undo the last conversation turn so the agent's context
|
||||
# matches the restored filesystem state
|
||||
if self.conversation_history:
|
||||
self.undo_last(prefill=False)
|
||||
self.undo_last()
|
||||
print(" Chat turn undone to match restored file state.")
|
||||
else:
|
||||
print(f" ❌ {result['error']}")
|
||||
@@ -7103,156 +7074,37 @@ class HermesCLI:
|
||||
print(f"(^_^)b Retrying: \"{last_message[:60]}{'...' if len(last_message) > 60 else ''}\"")
|
||||
return last_message
|
||||
|
||||
def undo_last(self, n: int = 1, prefill: bool = True):
|
||||
"""Back up N user turns: truncate history, soft-delete on disk, prefill.
|
||||
|
||||
Walks backwards N user messages and discards everything from the
|
||||
Nth-from-last user message onward (its assistant response, tool
|
||||
calls, etc.). ``n`` defaults to 1 (the last exchange); ``/undo 3``
|
||||
backs up three user turns. If ``n`` exceeds the number of user
|
||||
turns, it backs up to the oldest one.
|
||||
|
||||
Beyond the in-memory ``conversation_history`` slice, this also:
|
||||
• soft-deletes the truncated rows in SessionDB (``active=0``) so
|
||||
they're hidden from re-prompts and search but kept for audit;
|
||||
• notifies memory providers via ``on_session_switch(rewound=True)``;
|
||||
• mirrors /branch's agent surgery (system-prompt invalidation +
|
||||
flush-index reset);
|
||||
• when ``prefill`` is set and an input buffer is available,
|
||||
pre-fills the composer with the backed-up message text so it
|
||||
can be edited and resubmitted.
|
||||
|
||||
``prefill=False`` is used by callers that drive the undo
|
||||
programmatically (e.g. checkpoint rollback) and don't want to
|
||||
touch the user's input buffer.
|
||||
def undo_last(self):
|
||||
"""Remove the last user/assistant exchange from conversation history.
|
||||
|
||||
Walks backwards and removes all messages from the last user message
|
||||
onward (including assistant responses, tool calls, etc.).
|
||||
"""
|
||||
if not self.conversation_history:
|
||||
print("(._.) No messages to undo.")
|
||||
return
|
||||
|
||||
if n < 1:
|
||||
n = 1
|
||||
|
||||
# Walk backwards collecting the indices of the last N user messages.
|
||||
user_indices = []
|
||||
|
||||
# Walk backwards to find the last user message
|
||||
last_user_idx = None
|
||||
for i in range(len(self.conversation_history) - 1, -1, -1):
|
||||
if self.conversation_history[i].get("role") == "user":
|
||||
user_indices.append(i)
|
||||
if len(user_indices) >= n:
|
||||
break
|
||||
|
||||
if not user_indices:
|
||||
last_user_idx = i
|
||||
break
|
||||
|
||||
if last_user_idx is None:
|
||||
print("(._.) No user message found to undo.")
|
||||
return
|
||||
|
||||
# The oldest of the collected user messages is our truncation point.
|
||||
cut_idx = user_indices[-1]
|
||||
turns_undone = len(user_indices)
|
||||
|
||||
removed_count = len(self.conversation_history) - cut_idx
|
||||
removed_msg = self.conversation_history[cut_idx].get("content", "")
|
||||
removed_text = self._undo_content_to_text(removed_msg)
|
||||
|
||||
# Truncate the in-memory history to before that user message.
|
||||
self.conversation_history = self.conversation_history[:cut_idx]
|
||||
|
||||
# Soft-delete the truncated rows on disk so re-prompts and search
|
||||
# see the clean transcript while the rows survive for audit.
|
||||
rewound_rows = 0
|
||||
if self._session_db is not None and self.session_id:
|
||||
try:
|
||||
recents = self._session_db.list_recent_user_messages(
|
||||
self.session_id, limit=max(turns_undone, 10)
|
||||
)
|
||||
if recents:
|
||||
target_idx = min(turns_undone - 1, len(recents) - 1)
|
||||
target_id = recents[target_idx]["id"]
|
||||
result = self._session_db.rewind_to_message(
|
||||
self.session_id, target_id
|
||||
)
|
||||
rewound_rows = result.get("rewound_count", 0)
|
||||
# Prefer the DB's decoded target text for the prefill —
|
||||
# it's the canonical persisted copy.
|
||||
db_text = self._undo_content_to_text(
|
||||
(result.get("target_message") or {}).get("content")
|
||||
)
|
||||
if db_text:
|
||||
removed_text = db_text
|
||||
except ValueError as e:
|
||||
# Non-user target / cross-session — keep the in-memory undo
|
||||
# but skip the soft-delete; surface a debug-level note.
|
||||
logger.debug("undo: soft-delete skipped: %s", e)
|
||||
except Exception as e:
|
||||
logger.debug("undo: soft-delete failed: %s", e)
|
||||
|
||||
# Agent surgery: invalidate the system-prompt cache and reset the
|
||||
# flush index so the next turn re-flushes from the truncated head.
|
||||
if self.agent is not None:
|
||||
if hasattr(self.agent, "_invalidate_system_prompt"):
|
||||
try:
|
||||
self.agent._invalidate_system_prompt()
|
||||
except Exception:
|
||||
pass
|
||||
if hasattr(self.agent, "_last_flushed_db_idx"):
|
||||
try:
|
||||
self.agent._last_flushed_db_idx = len(self.conversation_history)
|
||||
except Exception:
|
||||
pass
|
||||
# Notify memory providers — same hook /branch fires, with the
|
||||
# rewound flag so per-turn document caches invalidate (#6672, #21910).
|
||||
try:
|
||||
_mm = getattr(self.agent, "_memory_manager", None)
|
||||
if _mm is not None and self.session_id:
|
||||
_mm.on_session_switch(
|
||||
self.session_id,
|
||||
parent_session_id="",
|
||||
reset=False,
|
||||
rewound=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
turn_word = "turn" if turns_undone == 1 else "turns"
|
||||
msg_count = rewound_rows or removed_count
|
||||
print(
|
||||
f"(^_^)b Undid {turns_undone} {turn_word} ({msg_count} message(s)). "
|
||||
f"Backed up to: \"{removed_text[:60]}{'...' if len(removed_text) > 60 else ''}\""
|
||||
)
|
||||
|
||||
# Count how many messages we're removing
|
||||
removed_count = len(self.conversation_history) - last_user_idx
|
||||
removed_msg = self.conversation_history[last_user_idx].get("content", "")
|
||||
|
||||
# Truncate history to before the last user message
|
||||
self.conversation_history = self.conversation_history[:last_user_idx]
|
||||
|
||||
print(f"(^_^)b Undid {removed_count} message(s). Removed: \"{removed_msg[:60]}{'...' if len(removed_msg) > 60 else ''}\"")
|
||||
remaining = len(self.conversation_history)
|
||||
print(f" {remaining} message(s) remaining in history.")
|
||||
|
||||
# Pre-fill the composer with the backed-up message so the user can
|
||||
# edit and resubmit (Claude-Code-style). Editable, not auto-sent.
|
||||
if prefill and removed_text:
|
||||
self._prefill_input_buffer(removed_text)
|
||||
|
||||
@staticmethod
|
||||
def _undo_content_to_text(content) -> str:
|
||||
"""Flatten message content (str or content-part list) to plain text."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts = [
|
||||
p.get("text", "")
|
||||
for p in content
|
||||
if isinstance(p, dict) and p.get("type") == "text"
|
||||
]
|
||||
return "\n".join(t for t in parts if t)
|
||||
return ""
|
||||
|
||||
def _prefill_input_buffer(self, text: str) -> None:
|
||||
"""Place ``text`` in the active prompt_toolkit buffer, editable."""
|
||||
app = getattr(self, "_app", None)
|
||||
if app is None:
|
||||
return
|
||||
try:
|
||||
buf = app.current_buffer
|
||||
buf.text = text
|
||||
if hasattr(buf, "cursor_position"):
|
||||
buf.cursor_position = len(text)
|
||||
app.invalidate()
|
||||
except Exception as e:
|
||||
logger.debug("undo: prefill buffer failed: %s", e)
|
||||
|
||||
def _run_curses_picker(self, title: str, items: list[str], default_index: int = 0) -> int | None:
|
||||
"""Run curses_single_select via run_in_terminal so prompt_toolkit handles terminal ownership cleanly."""
|
||||
@@ -8718,29 +8570,13 @@ class HermesCLI:
|
||||
# Re-queue the message so process_loop sends it to the agent
|
||||
self._pending_input.put(retry_msg)
|
||||
elif canonical == "undo":
|
||||
# Parse optional turn count: "/undo" → 1, "/undo 3" → 3.
|
||||
_undo_n = 1
|
||||
_undo_parts = cmd_original.split()
|
||||
if len(_undo_parts) > 1:
|
||||
try:
|
||||
_undo_n = int(_undo_parts[1])
|
||||
except ValueError:
|
||||
print(f"(._.) Invalid count {_undo_parts[1]!r} — use /undo or /undo N.")
|
||||
return
|
||||
if _undo_n < 1:
|
||||
_undo_n = 1
|
||||
_undo_desc = (
|
||||
"This removes the last user/assistant exchange from history."
|
||||
if _undo_n == 1
|
||||
else f"This removes the last {_undo_n} user turns from history."
|
||||
)
|
||||
if self._confirm_destructive_slash(
|
||||
"undo",
|
||||
_undo_desc,
|
||||
"This removes the last user/assistant exchange from history.",
|
||||
cmd_original=cmd_original,
|
||||
) is None:
|
||||
return
|
||||
self.undo_last(_undo_n)
|
||||
self.undo_last()
|
||||
elif canonical == "branch":
|
||||
self._handle_branch_command(cmd_original)
|
||||
elif canonical == "save":
|
||||
@@ -13092,13 +12928,6 @@ class HermesCLI:
|
||||
if event.app.is_running:
|
||||
event.app.exit()
|
||||
event.app.current_buffer.reset(append_to_history=True)
|
||||
# Force a repaint: process_command() prints through
|
||||
# patch_stdout (scrolls output above the prompt) and never
|
||||
# invalidates the app, so the just-cleared input area can
|
||||
# keep showing the submitted text until some unrelated
|
||||
# redraw fires. Every other early-return branch in this
|
||||
# handler invalidates after reset — match them.
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Handle /steer while the agent is running immediately on the
|
||||
@@ -13110,13 +12939,6 @@ class HermesCLI:
|
||||
if self._should_handle_steer_command_inline(text, has_images=has_images):
|
||||
self.process_command(text)
|
||||
event.app.current_buffer.reset(append_to_history=True)
|
||||
# Force a repaint after clearing the buffer. /steer is
|
||||
# dispatched mid-run while the agent streams output through
|
||||
# patch_stdout; process_command() never invalidates the
|
||||
# app, so without this the submitted "/steer <text>" can
|
||||
# linger in the input area (looking unsent) and invite an
|
||||
# accidental re-submit. See issue #34569.
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Snapshot and clear attached images
|
||||
@@ -15201,96 +15023,6 @@ class HermesCLI:
|
||||
# Main Entry Point
|
||||
# ============================================================================
|
||||
|
||||
def _run_kanban_goal_loop_q(cli: "HermesCLI", first_response: str) -> None:
|
||||
"""Drive a kanban goal_mode worker through the Ralph-style goal loop.
|
||||
|
||||
Called from the quiet single-query path AFTER the worker's first turn,
|
||||
only when ``HERMES_KANBAN_GOAL_MODE`` is set (dispatcher-spawned
|
||||
goal_mode card). Wires the worker's ``run_conversation`` and the kanban
|
||||
DB into ``goals.run_kanban_goal_loop``. All errors are swallowed by the
|
||||
caller — a broken goal loop must never wedge a worker, the dispatcher's
|
||||
claim TTL / crash detection is the backstop.
|
||||
"""
|
||||
import os as _os
|
||||
|
||||
task_id = (_os.environ.get("HERMES_KANBAN_TASK") or "").strip()
|
||||
if not task_id:
|
||||
return
|
||||
|
||||
from hermes_cli import kanban_db as _kb
|
||||
from hermes_cli.goals import run_kanban_goal_loop as _run_loop, DEFAULT_MAX_TURNS as _DEF_TURNS
|
||||
|
||||
# Resolve goal text from the card (title + body = the acceptance
|
||||
# criteria the judge evaluates against).
|
||||
conn = _kb.connect()
|
||||
try:
|
||||
task = _kb.get_task(conn, task_id)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if task is None:
|
||||
return
|
||||
|
||||
goal_parts = [task.title or ""]
|
||||
if task.body:
|
||||
goal_parts.append(task.body)
|
||||
goal_text = "\n\n".join(p for p in goal_parts if p).strip()
|
||||
if not goal_text:
|
||||
return
|
||||
|
||||
max_turns = task.goal_max_turns or _DEF_TURNS
|
||||
|
||||
def _run_turn(prompt: str) -> str:
|
||||
result = cli.agent.run_conversation(
|
||||
user_message=prompt,
|
||||
conversation_history=cli.conversation_history,
|
||||
)
|
||||
# Keep session_id in sync if mid-run compression rotated it.
|
||||
if (
|
||||
getattr(cli.agent, "session_id", None)
|
||||
and cli.agent.session_id != cli.session_id
|
||||
):
|
||||
cli.session_id = cli.agent.session_id
|
||||
resp = result.get("final_response", "") if isinstance(result, dict) else str(result)
|
||||
if resp:
|
||||
print(resp)
|
||||
return resp or ""
|
||||
|
||||
def _task_status() -> "str | None":
|
||||
c = _kb.connect()
|
||||
try:
|
||||
t = _kb.get_task(c, task_id)
|
||||
return t.status if t is not None else None
|
||||
finally:
|
||||
try:
|
||||
c.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _block(reason: str) -> None:
|
||||
c = _kb.connect()
|
||||
try:
|
||||
_kb.block_task(c, task_id, reason=reason)
|
||||
finally:
|
||||
try:
|
||||
c.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_run_loop(
|
||||
task_id=task_id,
|
||||
goal_text=goal_text,
|
||||
run_turn=_run_turn,
|
||||
task_status_fn=_task_status,
|
||||
block_fn=_block,
|
||||
max_turns=max_turns,
|
||||
first_response=first_response or "",
|
||||
log=lambda m: logger.info("%s", m),
|
||||
)
|
||||
|
||||
|
||||
def main(
|
||||
query: str = None,
|
||||
q: str = None,
|
||||
@@ -15688,20 +15420,6 @@ def main(
|
||||
print(f"Error: {result['error']}", file=sys.stderr)
|
||||
elif response:
|
||||
print(response)
|
||||
|
||||
# Kanban goal-loop mode: a worker spawned for a
|
||||
# goal_mode card keeps working in THIS session until an
|
||||
# auxiliary judge agrees the card is done, the worker
|
||||
# terminates the task itself, or the turn budget runs
|
||||
# out (→ sticky block). Gated on the env vars the
|
||||
# dispatcher sets in `_default_spawn`; a no-op for every
|
||||
# normal worker and every non-kanban `-q` run.
|
||||
if os.environ.get("HERMES_KANBAN_GOAL_MODE") == "1":
|
||||
try:
|
||||
_run_kanban_goal_loop_q(cli, response)
|
||||
except Exception as _goal_exc:
|
||||
logger.debug("kanban goal loop failed: %s", _goal_exc)
|
||||
|
||||
# Session ID goes to stderr so piped stdout is clean.
|
||||
print(f"\nsession_id: {cli.session_id}", file=sys.stderr)
|
||||
|
||||
|
||||
@@ -42,6 +42,4 @@ if [ -d /run/service/.s6-svscan ]; then
|
||||
done
|
||||
fi
|
||||
|
||||
# Skip the drop when already non-root.
|
||||
[ "$(id -u)" = 0 ] || exec /opt/hermes/.venv/bin/python -m hermes_cli.container_boot
|
||||
exec s6-setuidgid hermes /opt/hermes/.venv/bin/python -m hermes_cli.container_boot
|
||||
|
||||
@@ -16,11 +16,10 @@
|
||||
# first arg is an executable → exec it directly (sleep, bash, sh, …)
|
||||
# first arg is anything else → exec `hermes <args>` (subcommand passthrough)
|
||||
#
|
||||
# Drop to hermes via s6-setuidgid, but skip it when already non-root.
|
||||
# We drop to the hermes user via `s6-setuidgid` so the supervised
|
||||
# workload runs unprivileged (UID 10000 by default).
|
||||
set -e
|
||||
|
||||
drop() { [ "$(id -u)" = 0 ] && set -- s6-setuidgid hermes "$@"; exec "$@"; }
|
||||
|
||||
# HOME comes through with-contenv as /root (the /init context). Override
|
||||
# to the hermes user's home before dropping privileges so libraries that
|
||||
# resolve paths via $HOME (e.g. discord lockfile under XDG_STATE_HOME)
|
||||
@@ -32,13 +31,13 @@ cd /opt/data
|
||||
. /opt/hermes/.venv/bin/activate
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
drop hermes
|
||||
exec s6-setuidgid hermes hermes
|
||||
fi
|
||||
|
||||
if command -v "$1" >/dev/null 2>&1; then
|
||||
# Bare executable — pass through directly.
|
||||
drop "$@"
|
||||
exec s6-setuidgid hermes "$@"
|
||||
fi
|
||||
|
||||
# Hermes subcommand pass-through.
|
||||
drop hermes "$@"
|
||||
exec s6-setuidgid hermes hermes "$@"
|
||||
|
||||
@@ -47,9 +47,6 @@ case "${HERMES_DASHBOARD_INSECURE:-}" in
|
||||
1|true|TRUE|True|yes|YES|Yes) insecure="--insecure" ;;
|
||||
esac
|
||||
|
||||
# Skip the drop when already non-root.
|
||||
# shellcheck disable=SC2086 # word-splitting of $insecure is intentional
|
||||
[ "$(id -u)" = 0 ] || exec hermes dashboard --host "$dash_host" --port "$dash_port" --no-open $insecure
|
||||
# shellcheck disable=SC2086 # word-splitting of $insecure is intentional
|
||||
exec s6-setuidgid hermes hermes dashboard \
|
||||
--host "$dash_host" --port "$dash_port" --no-open $insecure
|
||||
|
||||
+13
-54
@@ -20,9 +20,6 @@ set -eu
|
||||
HERMES_HOME="${HERMES_HOME:-/opt/data}"
|
||||
INSTALL_DIR="/opt/hermes"
|
||||
|
||||
# Drop to hermes via s6-setuidgid, but skip it when already non-root.
|
||||
as_hermes() { [ "$(id -u)" = 0 ] || { "$@"; return; }; s6-setuidgid hermes "$@"; }
|
||||
|
||||
# --- Bootstrap HERMES_HOME as root ---
|
||||
# Create the directory (and any missing parents) while we still have root
|
||||
# privileges so the chown checks below see real metadata and the later
|
||||
@@ -35,14 +32,6 @@ as_hermes() { [ "$(id -u)" = 0 ] || { "$@"; return; }; s6-setuidgid hermes "$@";
|
||||
# is a no-op if the dir already exists. (#18482, salvages #18488)
|
||||
mkdir -p "$HERMES_HOME"
|
||||
|
||||
# Numeric UID/GID validation: must be digits only, 1000-65534
|
||||
validate_uid_gid() {
|
||||
case "$1" in
|
||||
''|*[!0-9]*) return 1 ;;
|
||||
*) [ "$1" -ge 1000 ] && [ "$1" -le 65534 ] ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# --- UID/GID remap ---
|
||||
# Accept PUID/PGID as aliases for HERMES_UID/HERMES_GID. NAS users (UGOS,
|
||||
# Synology, unRAID) expect the LinuxServer.io PUID/PGID convention and
|
||||
@@ -53,11 +42,11 @@ validate_uid_gid() {
|
||||
HERMES_UID="${HERMES_UID:-${PUID:-}}"
|
||||
HERMES_GID="${HERMES_GID:-${PGID:-}}"
|
||||
|
||||
if [ -n "${HERMES_UID:-}" ] && validate_uid_gid "$HERMES_UID" && [ "$HERMES_UID" != "$(id -u hermes)" ]; then
|
||||
if [ -n "${HERMES_UID:-}" ] && [ "$HERMES_UID" != "$(id -u hermes)" ]; then
|
||||
echo "[stage2] Changing hermes UID to $HERMES_UID"
|
||||
usermod -u "$HERMES_UID" hermes
|
||||
fi
|
||||
if [ -n "${HERMES_GID:-}" ] && validate_uid_gid "$HERMES_GID" && [ "$HERMES_GID" != "$(id -g hermes)" ]; then
|
||||
if [ -n "${HERMES_GID:-}" ] && [ "$HERMES_GID" != "$(id -g hermes)" ]; then
|
||||
echo "[stage2] Changing hermes GID to $HERMES_GID"
|
||||
# -o allows non-unique GID (e.g. macOS GID 20 "staff" may already
|
||||
# exist as "dialout" in the Debian-based container image).
|
||||
@@ -131,7 +120,9 @@ done
|
||||
# mkdir -p block below seeds. Keep them in sync if the seed list changes.
|
||||
actual_hermes_uid=$(id -u hermes)
|
||||
needs_chown=false
|
||||
if [ "$(stat -c %u "$HERMES_HOME" 2>/dev/null)" != "$actual_hermes_uid" ]; then
|
||||
if [ -n "${HERMES_UID:-}" ] && [ "$HERMES_UID" != "10000" ]; then
|
||||
needs_chown=true
|
||||
elif [ "$(stat -c %u "$HERMES_HOME" 2>/dev/null)" != "$actual_hermes_uid" ]; then
|
||||
needs_chown=true
|
||||
fi
|
||||
if [ "$needs_chown" = true ]; then
|
||||
@@ -187,33 +178,6 @@ if [ -d "$HERMES_HOME/profiles" ]; then
|
||||
chown -R hermes:hermes "$HERMES_HOME/profiles" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Reset ownership of hermes-owned top-level state files on every boot.
|
||||
# The targeted data-volume chown above only covers hermes-owned
|
||||
# *subdirectories*; loose state files living directly under $HERMES_HOME
|
||||
# are missed. When those files are created or rewritten by
|
||||
# `docker exec <container> hermes …` (root unless `-u` is passed) they
|
||||
# land root-owned, and the unprivileged hermes runtime then hits
|
||||
# PermissionError on next startup (e.g. gateway.lock / state.db /
|
||||
# auth.json), producing a gateway restart loop.
|
||||
#
|
||||
# We use an explicit allowlist rather than a blanket `find -user root`
|
||||
# sweep so host-owned files in a bind-mounted $HERMES_HOME are never
|
||||
# touched — same targeted-ownership contract as the subdir chown above
|
||||
# (issue #19788, PR #19795). The list mirrors the top-level *file*
|
||||
# entries of hermes_cli.profile_distribution.USER_OWNED_EXCLUDE plus the
|
||||
# runtime lock files; keep them in sync if that set changes.
|
||||
for f in \
|
||||
auth.json auth.lock .env \
|
||||
state.db state.db-shm state.db-wal \
|
||||
hermes_state.db \
|
||||
response_store.db response_store.db-shm response_store.db-wal \
|
||||
gateway.pid gateway.lock gateway_state.json processes.json \
|
||||
active_profile; do
|
||||
if [ -e "$HERMES_HOME/$f" ]; then
|
||||
chown hermes:hermes "$HERMES_HOME/$f" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# --- config.yaml permissions ---
|
||||
# Ensure config.yaml is readable by the hermes runtime user even if it
|
||||
# was edited on the host after initial ownership setup.
|
||||
@@ -229,7 +193,7 @@ fi
|
||||
# Use direct `mkdir -p` invocation (no `sh -c "..."` wrapper) so the
|
||||
# shell isn't a second interpreter — defends against $HERMES_HOME values
|
||||
# containing shell metacharacters. PR #30136 review item O2.
|
||||
as_hermes mkdir -p \
|
||||
s6-setuidgid hermes mkdir -p \
|
||||
"$HERMES_HOME/cron" \
|
||||
"$HERMES_HOME/sessions" \
|
||||
"$HERMES_HOME/logs" \
|
||||
@@ -246,7 +210,7 @@ as_hermes mkdir -p \
|
||||
# the hermes user so ownership matches the file's documented owner.
|
||||
# tee is invoked directly via s6-setuidgid (no `sh -c` wrapper) for the
|
||||
# same shell-metacharacter safety described above.
|
||||
printf 'docker\n' | as_hermes tee "$HERMES_HOME/.install_method" >/dev/null \
|
||||
printf 'docker\n' | s6-setuidgid hermes tee "$HERMES_HOME/.install_method" >/dev/null \
|
||||
|| true
|
||||
|
||||
# --- Seed config files (only on first boot) ---
|
||||
@@ -254,7 +218,7 @@ seed_one() {
|
||||
dest=$1
|
||||
src=$2
|
||||
if [ ! -f "$HERMES_HOME/$dest" ] && [ -f "$INSTALL_DIR/$src" ]; then
|
||||
as_hermes cp "$INSTALL_DIR/$src" "$HERMES_HOME/$dest"
|
||||
s6-setuidgid hermes cp "$INSTALL_DIR/$src" "$HERMES_HOME/$dest"
|
||||
fi
|
||||
}
|
||||
seed_one ".env" ".env.example"
|
||||
@@ -285,7 +249,7 @@ fi
|
||||
# the python binary's own bin-stub already sets up (sys.path is rooted
|
||||
# at the venv's site-packages by virtue of running .venv/bin/python).
|
||||
if [ -d "$INSTALL_DIR/skills" ]; then
|
||||
as_hermes "$INSTALL_DIR/.venv/bin/python" "$INSTALL_DIR/tools/skills_sync.py" \
|
||||
s6-setuidgid hermes "$INSTALL_DIR/.venv/bin/python" "$INSTALL_DIR/tools/skills_sync.py" \
|
||||
|| echo "[stage2] Warning: skills_sync.py failed; continuing"
|
||||
fi
|
||||
|
||||
@@ -309,10 +273,9 @@ fi
|
||||
# shared libraries (libGLESv2.so, libEGL.so, ...) which inherit the
|
||||
# executable bit from Playwright's tarball but are NOT browser binaries.
|
||||
# We only accept files whose basename is chrome / chromium /
|
||||
# chrome-headless-shell / headless_shell / chromium-browser. Compare
|
||||
# PR #18635's earlier ``find | grep -Ei 'chrome|chromium'`` which would
|
||||
# match the path ``.../chrome-headless-shell-linux64/libGLESv2.so`` and
|
||||
# pick a .so.
|
||||
# chrome-headless-shell / chromium-browser. Compare PR #18635's earlier
|
||||
# ``find | grep -Ei 'chrome|chromium'`` which would match the path
|
||||
# ``.../chrome-headless-shell-linux64/libGLESv2.so`` and pick a .so.
|
||||
# - Quietly skipped when $PLAYWRIGHT_BROWSERS_PATH doesn't exist (e.g.
|
||||
# custom builds that strip Playwright).
|
||||
if [ -z "${AGENT_BROWSER_EXECUTABLE_PATH:-}" ] && \
|
||||
@@ -320,17 +283,13 @@ if [ -z "${AGENT_BROWSER_EXECUTABLE_PATH:-}" ] && \
|
||||
[ -d "$PLAYWRIGHT_BROWSERS_PATH" ]; then
|
||||
browser_bin=$(find "$PLAYWRIGHT_BROWSERS_PATH" -type f -executable \
|
||||
\( -name 'chrome' -o -name 'chromium' \
|
||||
-o -name 'chrome-headless-shell' -o -name 'headless_shell' \
|
||||
-o -name 'chromium-browser' \) \
|
||||
-o -name 'chrome-headless-shell' -o -name 'chromium-browser' \) \
|
||||
2>/dev/null | head -n 1)
|
||||
if [ -n "$browser_bin" ]; then
|
||||
echo "[stage2] Found agent-browser Chromium binary: $browser_bin"
|
||||
# Write to s6's container_environment so with-contenv picks it
|
||||
# up for all supervised services (main-hermes, dashboard, etc.).
|
||||
# Idempotent: each boot overwrites with the current path.
|
||||
# Some container runtimes / s6-overlay versions do not create the
|
||||
# envdir before cont-init hooks run, so create it defensively.
|
||||
mkdir -p /run/s6/container_environment
|
||||
printf '%s' "$browser_bin" > /run/s6/container_environment/AGENT_BROWSER_EXECUTABLE_PATH
|
||||
else
|
||||
echo "[stage2] Warning: no Chromium binary under $PLAYWRIGHT_BROWSERS_PATH; browser tool may fail"
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
# Telegram DM User-Managed Multi-Session Topics Implementation Plan
|
||||
|
||||
> **For Hermes:** Use test-driven-development for implementation. Use subagent-driven-development only after this plan is split into small reviewed tasks.
|
||||
|
||||
**Goal:** Add an opt-in Telegram DM multi-session mode where Telegram user-created private-chat topics become independent Hermes session lanes, while the root DM becomes a system lobby.
|
||||
|
||||
**Architecture:** Rely on Telegram's native private-chat topic UI. Users create new topics with the `+` button; Hermes maps each `message_thread_id` to a separate session lane. Hermes does not create topics for normal `/new` flow and does not try to manage topic lifecycle beyond activation/status, root-lobby behavior, and restoring legacy sessions into a user-created topic.
|
||||
|
||||
**Tech Stack:** Hermes gateway, Telegram Bot API 9.4+, python-telegram-bot adapter, SQLite SessionDB / side tables, pytest.
|
||||
|
||||
---
|
||||
|
||||
## 1. Product decisions
|
||||
|
||||
### Accepted
|
||||
|
||||
- PR-quality implementation: migrations, tests, docs, backwards compatibility.
|
||||
- Use SQLite persistence, not JSON sidecars.
|
||||
- Live status suffixes in topic titles are out of MVP.
|
||||
- Topic title sync/editing is out of MVP except future-compatible storage if cheap.
|
||||
- User creates Telegram topics manually through the Telegram bot interface.
|
||||
- `/new` does **not** create Telegram topics.
|
||||
- Root/main DM becomes a system lobby after activation.
|
||||
- Existing Telegram behavior remains unchanged until the feature is activated/enabled.
|
||||
- Migration of old sessions is supported through `/topic` listing and `/topic <session_id>` restore inside a user-created topic.
|
||||
|
||||
### Telegram API assumptions verified from Bot API docs
|
||||
|
||||
- `getMe` returns bot `User` fields:
|
||||
- `has_topics_enabled`: forum/topic mode enabled in private chats.
|
||||
- `allows_users_to_create_topics`: users may create/delete topics in private chats.
|
||||
- `createForumTopic` works for private chats with a user, but MVP does not rely on it for normal flow.
|
||||
- `Message.message_thread_id` identifies a topic in private chats.
|
||||
- `sendMessage` supports `message_thread_id` for private-chat topics.
|
||||
- `pinChatMessage` is allowed in private chats.
|
||||
|
||||
---
|
||||
|
||||
## 2. Target UX
|
||||
|
||||
### 2.1 Activation from root/main DM
|
||||
|
||||
User sends:
|
||||
|
||||
```text
|
||||
/topic
|
||||
```
|
||||
|
||||
Hermes:
|
||||
|
||||
1. calls Telegram `getMe`;
|
||||
2. verifies `has_topics_enabled` and `allows_users_to_create_topics`;
|
||||
3. enables multi-session topic mode for this Telegram DM user/chat;
|
||||
4. sends an onboarding message;
|
||||
5. pins the onboarding message if configured;
|
||||
6. shows old/unlinked sessions that can be restored into topics.
|
||||
|
||||
Suggested onboarding text:
|
||||
|
||||
```text
|
||||
Multi-session mode is enabled.
|
||||
|
||||
Create new Hermes chats with the + button in this bot interface. Each Telegram topic is an independent Hermes session, so you can work on different tasks in parallel.
|
||||
|
||||
This main chat is reserved for system commands, status, and session management.
|
||||
|
||||
To restore an old session:
|
||||
1. Use /topic here to see unlinked sessions.
|
||||
2. Create a new topic with the + button.
|
||||
3. Send /topic <session_id> inside that topic.
|
||||
```
|
||||
|
||||
### 2.2 Root/main DM after activation
|
||||
|
||||
Root DM is a system lobby.
|
||||
|
||||
Allowed/system commands include at least:
|
||||
|
||||
- `/topic`
|
||||
- `/status`
|
||||
- `/sessions` if available
|
||||
- `/usage`
|
||||
- `/help`
|
||||
- `/platforms`
|
||||
|
||||
Normal user prompts in root DM do not enter the agent loop. Reply:
|
||||
|
||||
```text
|
||||
This main chat is reserved for system commands.
|
||||
|
||||
To chat with Hermes, create a new topic using the + button in this bot interface. Each topic works as an independent Hermes session.
|
||||
```
|
||||
|
||||
`/new` in root DM does not create a session/topic. Reply:
|
||||
|
||||
```text
|
||||
To start a new parallel Hermes chat, create a new topic with the + button in this bot interface.
|
||||
|
||||
Each topic is an independent Hermes session. Use /new inside a topic only if you want to replace that topic's current session.
|
||||
```
|
||||
|
||||
### 2.3 First message in a user-created topic
|
||||
|
||||
When a user creates a Telegram topic and sends the first message there:
|
||||
|
||||
1. Hermes receives a Telegram DM message with `message_thread_id`.
|
||||
2. Hermes derives the existing thread-aware `session_key` from `(platform=telegram, chat_type=dm, chat_id, thread_id)`.
|
||||
3. If no binding exists, Hermes creates a fresh Hermes session for this topic lane and persists the binding.
|
||||
4. The message runs through the normal agent loop for that lane.
|
||||
|
||||
### 2.4 `/new` inside a non-main topic
|
||||
|
||||
`/new` remains supported but replaces the session attached to the current topic lane.
|
||||
|
||||
Hermes should warn:
|
||||
|
||||
```text
|
||||
Started a new Hermes session in this topic.
|
||||
|
||||
Tip: for parallel work, create a new topic with the + button instead of using /new here. /new replaces the session attached to the current topic.
|
||||
```
|
||||
|
||||
### 2.5 `/topic` in root/main DM after activation
|
||||
|
||||
Shows:
|
||||
|
||||
- mode enabled/disabled;
|
||||
- last capability check result;
|
||||
- whether intro message is pinned if known;
|
||||
- count of known topic bindings;
|
||||
- list of old/unlinked sessions.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Telegram multi-session topics are enabled.
|
||||
|
||||
Create new Hermes chats with the + button in this bot interface.
|
||||
|
||||
Unlinked previous sessions:
|
||||
1. 2026-05-01 Research notes — id: abc123
|
||||
2. 2026-04-30 Deploy debugging — id: def456
|
||||
3. Untitled session — id: ghi789
|
||||
|
||||
To restore one:
|
||||
1. Create a new topic with the + button.
|
||||
2. Open that topic.
|
||||
3. Send /topic <id>
|
||||
```
|
||||
|
||||
### 2.6 `/topic` inside a non-main topic
|
||||
|
||||
Without args, show the current topic binding:
|
||||
|
||||
```text
|
||||
This topic is linked to:
|
||||
Session: Research notes
|
||||
ID: abc123
|
||||
|
||||
Use /new to replace this topic with a fresh session.
|
||||
For parallel work, create another topic with the + button.
|
||||
```
|
||||
|
||||
### 2.7 `/topic <session_id>` inside a non-main topic
|
||||
|
||||
Restore an old/unlinked session into the current user-created topic.
|
||||
|
||||
Behavior:
|
||||
|
||||
1. reject if not in Telegram DM topic;
|
||||
2. verify session belongs to the same Telegram user/chat or is a safe legacy root DM session for this user;
|
||||
3. reject if session is already linked to another active topic in MVP;
|
||||
4. `SessionStore.switch_session(current_topic_session_key, target_session_id)`;
|
||||
5. upsert binding with `managed_mode = restored`;
|
||||
6. send two messages into the topic:
|
||||
- session restored confirmation;
|
||||
- last Hermes assistant message if available.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
Session restored: Research notes
|
||||
|
||||
Last Hermes message:
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Persistence model
|
||||
|
||||
Use SQLite, but topic-mode schema changes are **explicit opt-in migrations**, not automatic startup reconciliation.
|
||||
|
||||
Important rollback-safety rule:
|
||||
|
||||
- upgrading Hermes and starting the gateway must not create Telegram topic-mode tables or columns;
|
||||
- old/default Telegram behavior must keep working on the existing `state.db`;
|
||||
- the first `/topic` activation path calls an idempotent explicit migration, then enables topic mode for that chat;
|
||||
- if activation fails before the migration is needed, the database remains in the pre-topic-mode shape.
|
||||
|
||||
### 3.1 No eager `sessions` table mutation for MVP
|
||||
|
||||
Do **not** add `chat_id`, `chat_type`, `thread_id`, or `session_key` columns to `sessions` as part of ordinary `SessionDB()` startup. The existing declarative `_reconcile_columns()` mechanism would add them eagerly on every process start, which violates the managed-migration requirement.
|
||||
|
||||
For MVP, keep origin/session-lane data in topic-specific side tables created only by the explicit `/topic` migration. Legacy unlinked sessions can be discovered conservatively from existing data (`source = telegram`, `user_id = current Telegram user`) plus absence from topic bindings.
|
||||
|
||||
If future PRs need richer origin metadata for all gateway sessions, introduce it behind a separate explicit migration/command or a compatibility-reviewed schema bump.
|
||||
|
||||
### 3.2 Explicit `/topic` migration API
|
||||
|
||||
Add an idempotent method such as:
|
||||
|
||||
```python
|
||||
def apply_telegram_topic_migration(self) -> None: ...
|
||||
```
|
||||
|
||||
It creates only topic-mode side tables/indexes and records:
|
||||
|
||||
```text
|
||||
state_meta.telegram_dm_topic_schema_version = 1
|
||||
```
|
||||
|
||||
This method is called from `/topic` activation/status paths before reading or writing topic-mode state. It is not called from generic `SessionDB.__init__`, gateway startup, CLI startup, or auto-maintenance.
|
||||
|
||||
### 3.3 `telegram_dm_topic_mode`
|
||||
|
||||
Stores per-user/chat activation state. Created only by `apply_telegram_topic_migration()`.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `chat_id` primary key
|
||||
- `user_id`
|
||||
- `enabled`
|
||||
- `activated_at`
|
||||
- `updated_at`
|
||||
- `has_topics_enabled`
|
||||
- `allows_users_to_create_topics`
|
||||
- `capability_checked_at`
|
||||
- `intro_message_id`
|
||||
- `pinned_message_id`
|
||||
|
||||
### 3.4 `telegram_dm_topic_bindings`
|
||||
|
||||
Stores Telegram topic/thread to Hermes session binding. Created only by `apply_telegram_topic_migration()`.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- `chat_id`
|
||||
- `thread_id`
|
||||
- `user_id`
|
||||
- `session_key`
|
||||
- `session_id`
|
||||
- `managed_mode`
|
||||
- `auto`
|
||||
- `restored`
|
||||
- `new_replaced`
|
||||
- `linked_at`
|
||||
- `updated_at`
|
||||
|
||||
Recommended constraints:
|
||||
|
||||
- primary key `(chat_id, thread_id)`;
|
||||
- unique index on `session_id` for MVP to prevent one session linked to multiple topics;
|
||||
- index `(user_id, chat_id)` for status/listing.
|
||||
|
||||
### 3.5 Unlinked session semantics
|
||||
|
||||
For MVP, a session is unlinked if:
|
||||
|
||||
- `source = telegram`;
|
||||
- `user_id = current Telegram user`;
|
||||
- no row in `telegram_dm_topic_bindings` has `session_id = session_id`.
|
||||
|
||||
This is intentionally conservative until a future explicit migration adds richer cross-platform origin metadata.
|
||||
|
||||
Never dedupe by title.
|
||||
|
||||
---
|
||||
|
||||
## 4. Config
|
||||
|
||||
Suggested config block:
|
||||
|
||||
```yaml
|
||||
platforms:
|
||||
telegram:
|
||||
extra:
|
||||
multisession_topics:
|
||||
enabled: false
|
||||
mode: user_managed_topics
|
||||
root_chat_behavior: system_lobby
|
||||
pin_intro_message: true
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `enabled: false` means existing Telegram behavior is unchanged.
|
||||
- Activation via `/topic` may create per-chat enabled state only if global config permits it.
|
||||
- `root_chat_behavior: system_lobby` is the MVP behavior for activated chats.
|
||||
|
||||
---
|
||||
|
||||
## 5. Command behavior summary
|
||||
|
||||
### `/topic` root/main DM
|
||||
|
||||
- If not activated: capability check, activate, send/pin onboarding, list unlinked sessions.
|
||||
- If activated: show status and unlinked sessions.
|
||||
|
||||
### `/topic` non-main topic
|
||||
|
||||
- Show current binding.
|
||||
|
||||
### `/topic <session_id>` root/main DM
|
||||
|
||||
Reject with instructions:
|
||||
|
||||
```text
|
||||
Create a new topic with the + button, open it, then send /topic <session_id> there to restore this session.
|
||||
```
|
||||
|
||||
### `/topic <session_id>` non-main topic
|
||||
|
||||
Restore that session into this topic if ownership/linking checks pass.
|
||||
|
||||
### `/new` root/main DM when activated
|
||||
|
||||
Reply with instructions to use the `+` button. Do not enter agent loop.
|
||||
|
||||
### `/new` non-main topic
|
||||
|
||||
Create a new session in the current topic lane, persist/update binding, warn that `+` is preferred for parallel work.
|
||||
|
||||
### Normal text root/main DM when activated
|
||||
|
||||
Reply with system-lobby instruction. Do not enter agent loop.
|
||||
|
||||
### Normal text non-main topic
|
||||
|
||||
Normal Hermes agent flow for that topic's session lane.
|
||||
|
||||
---
|
||||
|
||||
## 6. PR breakdown
|
||||
|
||||
### PR 1 — Explicit topic-mode schema migration
|
||||
|
||||
**Goal:** Add rollback-safe SQLite support for Telegram topic mode without mutating `state.db` on ordinary upgrade/startup.
|
||||
|
||||
**Files likely touched:**
|
||||
|
||||
- `hermes_state.py`
|
||||
- tests under `tests/`
|
||||
|
||||
**Tests first:**
|
||||
|
||||
1. opening an old/current DB with `SessionDB()` does not create topic-mode tables or `sessions` origin columns;
|
||||
2. calling `apply_telegram_topic_migration()` creates `telegram_dm_topic_mode` and `telegram_dm_topic_bindings` idempotently;
|
||||
3. migration records `state_meta.telegram_dm_topic_schema_version = 1`.
|
||||
|
||||
### PR 2 — Topic mode activation and binding APIs
|
||||
|
||||
**Goal:** Add SQLite persistence for activation and topic bindings.
|
||||
|
||||
**Tests first:**
|
||||
|
||||
1. enable/check mode row round-trips;
|
||||
2. binding upsert and lookup by `(chat_id, user_id, thread_id)`;
|
||||
3. linked sessions are excluded from unlinked list.
|
||||
|
||||
### PR 3 — `/topic` activation/status command
|
||||
|
||||
**Goal:** Implement root activation/status/listing behavior.
|
||||
|
||||
**Tests first:**
|
||||
|
||||
1. `/topic` in root checks `getMe` capabilities and records activation;
|
||||
2. capability failure returns readable instructions;
|
||||
3. activated root `/topic` lists unlinked sessions.
|
||||
|
||||
### PR 4 — System lobby behavior
|
||||
|
||||
**Goal:** Prevent root chat from entering agent loop after activation.
|
||||
|
||||
**Tests first:**
|
||||
|
||||
1. normal text in activated root returns lobby instruction;
|
||||
2. `/new` in activated root returns `+` button instruction;
|
||||
3. non-activated root behavior is unchanged.
|
||||
|
||||
### PR 5 — Auto-bind user-created topics
|
||||
|
||||
**Goal:** First message in non-main topic creates/uses an independent session lane.
|
||||
|
||||
**Tests first:**
|
||||
|
||||
1. new topic message creates binding with `auto_created`;
|
||||
2. repeated topic message reuses same binding/lane;
|
||||
3. two topics in same DM do not share sessions.
|
||||
|
||||
### PR 6 — Restore legacy sessions into a topic
|
||||
|
||||
**Goal:** Implement `/topic <session_id>` in non-main topics.
|
||||
|
||||
**Tests first:**
|
||||
|
||||
1. root `/topic <id>` rejects with instructions;
|
||||
2. topic `/topic <id>` switches current topic lane to target session;
|
||||
3. restore rejects sessions from other users/chats;
|
||||
4. restore rejects already-linked sessions;
|
||||
5. restore emits confirmation and last Hermes assistant message.
|
||||
|
||||
### PR 7 — `/new` inside topic updates binding
|
||||
|
||||
**Goal:** Keep existing `/new` semantics but persist topic binding replacement.
|
||||
|
||||
**Tests first:**
|
||||
|
||||
1. `/new` in topic creates a new session for same topic lane;
|
||||
2. binding updates to `managed_mode = new_replaced`;
|
||||
3. response includes guidance to use `+` for parallel work.
|
||||
|
||||
### PR 8 — Docs and polish
|
||||
|
||||
**Goal:** Document the feature and Telegram setup.
|
||||
|
||||
**Files likely touched:**
|
||||
|
||||
- `website/docs/user-guide/messaging/telegram.md`
|
||||
- maybe `website/docs/user-guide/sessions.md`
|
||||
|
||||
Docs must explain:
|
||||
|
||||
- BotFather/Telegram settings for topic mode and user-created topics;
|
||||
- `/topic` activation;
|
||||
- root system lobby;
|
||||
- using `+` for new parallel chats;
|
||||
- restoring old sessions with `/topic <id>` inside a topic;
|
||||
- limitations.
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing / quality gates
|
||||
|
||||
Run targeted tests after each TDD cycle, then broader tests before completion.
|
||||
|
||||
Suggested commands after inspection confirms test paths:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_hermes_state.py -q
|
||||
python -m pytest tests/gateway/ -q
|
||||
python -m pytest tests/ -o 'addopts=' -q
|
||||
```
|
||||
|
||||
Do not ship without verifying disabled-feature backwards compatibility.
|
||||
|
||||
---
|
||||
|
||||
## 8. Definition of done for MVP
|
||||
|
||||
- `/topic` activates/checks Telegram DM multi-session mode.
|
||||
- Root DM becomes a system lobby after activation.
|
||||
- Onboarding message tells users to create new chats with the Telegram `+` button.
|
||||
- Onboarding message can be pinned in private chat.
|
||||
- User-created topics automatically become independent Hermes session lanes.
|
||||
- `/new` in root gives instructions, not a new agent run.
|
||||
- `/new` in a topic creates a new session in that topic and warns that `+` is preferred for parallel work.
|
||||
- `/topic` in root lists unlinked old sessions.
|
||||
- `/topic <session_id>` inside a topic restores that session and sends confirmation + last Hermes assistant message.
|
||||
- Ownership checks prevent restoring other users' sessions.
|
||||
- Already-linked sessions are not restored into a second topic in MVP.
|
||||
- Existing Telegram behavior is unchanged when the feature is disabled.
|
||||
- Tests and docs are included.
|
||||
@@ -0,0 +1,434 @@
|
||||
# s6-overlay Supervision for Per-Profile Gateways in Docker — Implementation Plan
|
||||
|
||||
> **Status: shipped.** Phases 0–5 landed via PR
|
||||
> [NousResearch/hermes-agent#30136](https://github.com/NousResearch/hermes-agent/pull/30136)
|
||||
> in May 2026. This document is preserved as a post-implementation reference
|
||||
> for the architecture and the resolved design questions. The phase-by-phase
|
||||
> TDD walkthrough (≈2,800 lines) and the v2/v3 re-validation preambles have
|
||||
> been removed — the canonical implementation history is the PR commit log
|
||||
> (`git log --oneline a957ef083..a6f7171a5 -- 'docker/*' 'hermes_cli/service_manager.py' …`).
|
||||
> Open Questions are collapsed into a single Decision Log table; full
|
||||
> deliberations live in PR review comments.
|
||||
|
||||
**Goal:** Replace `tini` with s6-overlay as PID 1 in the Hermes Docker image so
|
||||
that the main hermes process, the dashboard, and dynamically-created
|
||||
per-profile gateways all run as supervised services (auto-restart on crash,
|
||||
clean shutdown, signal forwarding, zombie reaping). Preserve every existing
|
||||
`docker run …` invocation pattern — including interactive TUI.
|
||||
|
||||
**Architecture:** s6-overlay's `/init` is the container ENTRYPOINT, running
|
||||
s6-svscan as PID 1. Main hermes and the dashboard are declared as static
|
||||
s6-rc services at image build time. Per-profile gateways — which users create
|
||||
*after* the image is built (`hermes profile create coder` →
|
||||
`coder gateway start`) — are registered dynamically by writing service
|
||||
directories under a scandir watched by s6-svscan. A `ServiceManager` protocol
|
||||
abstracts the install/start/stop/restart surface across the init systems we
|
||||
care about (systemd on Linux host, launchd on macOS host, Scheduled Tasks on
|
||||
native Windows host, s6 inside container) and adds a second tier for runtime
|
||||
service registration that only s6 implements.
|
||||
|
||||
**Tech Stack:**
|
||||
|
||||
- [s6-overlay](https://github.com/just-containers/s6-overlay) v3.2.3.0
|
||||
(noarch + per-arch tarballs ~15 MB). SHA256-pinned via build ARGs;
|
||||
multi-arch via `TARGETARCH` (amd64 → `x86_64`, arm64 → `aarch64`).
|
||||
- Debian 13.4 base image (unchanged).
|
||||
- [hadolint](https://github.com/hadolint/hadolint) for the Dockerfile +
|
||||
[shellcheck](https://github.com/koalaman/shellcheck) for entrypoint scripts.
|
||||
- Python subprocess wrappers for `s6-svc`, `s6-svstat`, `s6-svscanctl`.
|
||||
- Existing systemd/launchd/windows surface in `hermes_cli/gateway.py` and
|
||||
`hermes_cli/gateway_windows.py`.
|
||||
|
||||
**Scope:**
|
||||
|
||||
- Container-only (host-side systemd/launchd/windows behavior is preserved,
|
||||
not modified).
|
||||
- s6-overlay only (no pure-Python fallback).
|
||||
- Architecture A (s6 owns PID 1; tini is removed).
|
||||
- Interactive TUI must keep working:
|
||||
`docker run -it --rm nousresearch/hermes-agent:latest --tui`.
|
||||
- Dynamic registration is limited to per-profile gateways — one service per
|
||||
profile, created when a profile is created, torn down when deleted. A
|
||||
`gateway-default` slot is always registered for the root HERMES_HOME
|
||||
profile so `hermes gateway start` (no `-p`) has somewhere to land.
|
||||
|
||||
**Out of scope:**
|
||||
|
||||
- Host-side dynamic supervision (systemd-run / launchd transient plists) —
|
||||
not needed.
|
||||
- Pure-Python supervisor fallback — not needed.
|
||||
- Arbitrary user-defined supervised processes inside the container — only
|
||||
profile gateways.
|
||||
- Migration of existing per-profile systemd unit generation to s6 on the
|
||||
host side.
|
||||
- Non-Docker container runtimes (Podman rootless validated reactively).
|
||||
- UX polish around in-container profile lifecycle (e.g. a nice status view
|
||||
of all supervised profile gateways) — deferred to follow-up.
|
||||
|
||||
---
|
||||
|
||||
## Background From The Codebase
|
||||
|
||||
> **Note on line numbers:** This section refers to functions and structures
|
||||
> by name only. Use `grep -n 'def <name>' <file>` to locate anything below
|
||||
> if you need the current line.
|
||||
|
||||
### Pre-s6 container init (what we replaced)
|
||||
|
||||
The original `Dockerfile` declared
|
||||
`ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/opt/hermes/docker/entrypoint.sh" ]`.
|
||||
tini was PID 1, reaped zombies, forwarded SIGTERM to the process group. The
|
||||
old `docker/entrypoint.sh`:
|
||||
|
||||
1. `gosu` privilege drop from root → `hermes` UID.
|
||||
2. Copied `.env.example`, `cli-config.yaml.example`, `SOUL.md` into
|
||||
`$HERMES_HOME` if missing.
|
||||
3. Synced bundled skills via `tools/skills_sync.py`.
|
||||
4. Optionally backgrounded `hermes dashboard` in a subshell when
|
||||
`HERMES_DASHBOARD=1` — **not supervised**, no restart.
|
||||
5. `exec hermes "$@"` — tini's sole direct child.
|
||||
|
||||
Known limitations: dashboard crash → stays dead; dashboard fails at startup →
|
||||
silent; gateway crash → dashboard dies too. The May 4, 2026 decision was
|
||||
"leave as is" because nothing in the container needed supervision then.
|
||||
Adding per-profile gateway supervision changed that.
|
||||
|
||||
### ServiceManager surface (what we wrapped, not refactored)
|
||||
|
||||
All init-system logic lives in **`hermes_cli/gateway.py`** (~5,400 LOC at
|
||||
re-validation). The systemd/launchd code is ~1,500 lines of that, plus a
|
||||
separate **`hermes_cli/gateway_windows.py`** (~690 LOC) for Windows
|
||||
Scheduled Tasks.
|
||||
|
||||
| Layer | Systemd functions | Launchd functions | Windows functions |
|
||||
|---|---|---|---|
|
||||
| **Detection** | `supports_systemd_services()`, `_systemd_operational()`, `_wsl_systemd_operational()`, `_container_systemd_operational()` | `is_macos()` | `is_windows()`, `gateway_windows.is_installed()` |
|
||||
| **Paths** | `get_systemd_unit_path(system)`, `get_service_name()` | `get_launchd_plist_path()`, `get_launchd_label()` | `gateway_windows.get_task_name()`, `get_task_script_path()`, `get_startup_entry_path()` |
|
||||
| **Install/lifecycle** | `systemd_install(force, system, run_as_user)`, `systemd_uninstall(system)`, `systemd_start/stop/restart(system)` | `launchd_install(force)`, `launchd_uninstall/start/stop/restart` | `gateway_windows.install/uninstall/start/stop/restart` |
|
||||
| **Probes** | `_probe_systemd_service_running(system)`, `_read_systemd_unit_properties(system)`, `_wait_for_systemd_service_restart`, `_recover_pending_systemd_restart` | `_probe_launchd_service_running()` | `gateway_windows.is_task_registered()`, `_pid_exists` helper |
|
||||
| **D-Bus plumbing** | `_ensure_user_systemd_env`, `_user_systemd_socket_ready`, `_user_systemd_private_socket_path`, `get_systemd_linger_status` | — | — |
|
||||
| **Unit/plist generation** | `generate_systemd_unit(system, run_as_user)`, `systemd_unit_is_current`, `refresh_systemd_unit_if_needed` | plist templating in `launchd_install` | `_build_gateway_cmd_script`, `_build_startup_launcher`, `_write_task_script` |
|
||||
|
||||
Container-relevant callers outside `gateway.py`:
|
||||
|
||||
- `hermes_cli/status.py` — gained an `s6` branch for in-container runs.
|
||||
- `hermes_cli/profiles.py` — `create_profile` / `delete_profile` register and
|
||||
unregister with s6 inside the container (no-op on host).
|
||||
- `hermes_cli/doctor.py` — `_check_gateway_service_linger` skips on s6, and a
|
||||
new "Service Supervisor" section reports main-hermes / dashboard /
|
||||
profile-gateway counts via the ServiceManager.
|
||||
- `hermes_cli/gateway.py::gateway_command` — the
|
||||
`elif is_container():` rejection arms that refused gateway lifecycle
|
||||
operations were removed; the `_dispatch_via_service_manager_if_s6` helper
|
||||
intercepts start/stop/restart and routes them through s6.
|
||||
|
||||
### Per-profile gateway spawning
|
||||
|
||||
`hermes gateway start`, `coder gateway start` (profile alias), and
|
||||
`hermes -p <profile> gateway start` all spawn a gateway process scoped to a
|
||||
given profile. See
|
||||
[Profiles: Running Gateways](https://hermes-agent.nousresearch.com/docs/user-guide/profiles#running-gateways).
|
||||
On host, lifecycle is managed via per-profile systemd units
|
||||
(`hermes-gateway-<profile>.service`); inside the container, an s6 service at
|
||||
`/run/service/gateway-<name>/` is registered when the profile is created and
|
||||
torn down when it's deleted.
|
||||
|
||||
**Persistence across container restart:** `/run/service/` is tmpfs —
|
||||
service registrations are wiped when the container restarts. Profile
|
||||
directories at `/opt/data/profiles/<name>/` live on the persistent VOLUME,
|
||||
and each one records its gateway's last state in `gateway_state.json`.
|
||||
`/etc/cont-init.d/02-reconcile-profiles` walks the persistent profiles on
|
||||
every container boot, recreates the s6 service slots via
|
||||
`hermes_cli/container_boot.py`, and auto-starts those whose last recorded
|
||||
state was `running`. Profiles whose last state was `stopped`,
|
||||
`startup_failed`, `starting`, or absent get their slot recreated in the
|
||||
`down` state and wait for explicit user action. `docker restart` is therefore
|
||||
invisible to a user with running profile gateways: they come back up;
|
||||
stopped ones stay stopped.
|
||||
|
||||
### s6-overlay constraints
|
||||
|
||||
- **Root/non-root model:** `/init` runs as root to set up the supervision
|
||||
tree, install signal handlers, and run the stage2 hook that does
|
||||
`usermod`/`chown`. Each supervised service drops to UID 10000 via
|
||||
`s6-setuidgid hermes` in its `run` script. The per-service `s6-supervise`
|
||||
monitor stays root so it can signal its child regardless of UID. Net
|
||||
effect: hermes and all its subprocesses run as UID 10000 exactly as
|
||||
before; only the supervision tree itself runs as root.
|
||||
- v3.2.3.0 has limited non-root support for running `/init` itself as
|
||||
non-root — some tools (`fix-attrs`, `logutil-service`) assume root. We
|
||||
don't hit this because `/init` runs as root.
|
||||
- Scandir hard cap: `services_max` default 1000, configurable to 160,000.
|
||||
- `/command/with-contenv` sources `/run/s6/container_environment/*` into
|
||||
service env — convenient for passing `HERMES_HOME` etc.
|
||||
- s6 signal semantics: service crash triggers `s6-supervise` restart after
|
||||
1s; override with a `finish` script.
|
||||
- Zombie reaping: PID 1 (s6-svscan) reaps all zombies non-blockingly on
|
||||
SIGCHLD. Any subagent subprocess spawned by the main hermes process is
|
||||
reaped automatically.
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### D1. s6-overlay replaces tini entirely
|
||||
|
||||
Container ENTRYPOINT is `/init`, PID 1 is s6-svscan. The main hermes
|
||||
process, the dashboard, and every per-profile gateway run as supervised
|
||||
services. This is a single breaking change to the container contract.
|
||||
|
||||
### D2. Main hermes is an s6 service with container-exit semantics
|
||||
|
||||
The contract "container exits when `hermes` exits" is preserved via a
|
||||
service `finish` script that writes to
|
||||
`/run/s6-linux-init-container-results/exitcode` and calls
|
||||
`/run/s6/basedir/bin/halt`. All five supported invocations work:
|
||||
|
||||
| `docker run <image> …` | Behavior |
|
||||
|---|---|
|
||||
| (no args) | `hermes` with no args, container exits when hermes exits |
|
||||
| `chat -q "..."` | `hermes chat -q "..."`, container exits with hermes exit code |
|
||||
| `sleep infinity` | `sleep infinity` directly (long-lived sandbox mode) |
|
||||
| `bash` | interactive `bash` directly |
|
||||
| `docker run -it … --tui` | interactive Ink TUI with real TTY — see D9 |
|
||||
|
||||
`docker/main-wrapper.sh` detects whether `$1` is an executable on PATH and
|
||||
routes either to "run this as a one-shot main service" or "wrap with
|
||||
hermes".
|
||||
|
||||
### D3. Static services at build time; dynamic (per-profile) services at runtime
|
||||
|
||||
s6 offers two mechanisms:
|
||||
|
||||
- **s6-rc** (declarative, compile-then-swap): used for main hermes and the
|
||||
dashboard — they're known at image build time.
|
||||
- **scandir** (drop a directory + `s6-svscanctl -a`): used for per-profile
|
||||
gateways — profiles are user-created after the image is built.
|
||||
|
||||
Per-profile gateway service dirs live at `/run/service/gateway-<profile>/`
|
||||
(tmpfs, hermes-writable). s6-svscan picks them up on rescan.
|
||||
|
||||
### D4. ServiceManager protocol with two methods for runtime registration
|
||||
|
||||
Host paths (systemd, launchd, Windows Scheduled Tasks) need only
|
||||
install/start/stop/restart of pre-declared services. Inside the container,
|
||||
we additionally need to register services at runtime when a profile is
|
||||
created. The protocol exposes this directly:
|
||||
|
||||
```python
|
||||
class ServiceManager(Protocol):
|
||||
kind: ServiceManagerKind # "systemd" | "launchd" | "windows" | "s6" | "none"
|
||||
|
||||
# Lifecycle of an already-declared service
|
||||
def start(self, name: str) -> None: ...
|
||||
def stop(self, name: str) -> None: ...
|
||||
def restart(self, name: str) -> None: ...
|
||||
def is_running(self, name: str) -> bool: ...
|
||||
|
||||
# Runtime registration (container-only; hosts raise NotImplementedError)
|
||||
def supports_runtime_registration(self) -> bool: ...
|
||||
def register_profile_gateway(
|
||||
self, profile: str, *,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
) -> None: ...
|
||||
def unregister_profile_gateway(self, profile: str) -> None: ...
|
||||
def list_profile_gateways(self) -> list[str]: ...
|
||||
```
|
||||
|
||||
Systemd, launchd, and Windows backends raise `NotImplementedError` on the
|
||||
registration methods. Only the s6 backend implements them. Callers check
|
||||
`supports_runtime_registration()` before calling.
|
||||
|
||||
The scope is intentionally narrow: it's specifically "register/unregister a
|
||||
profile gateway," not a general-purpose process-management API.
|
||||
|
||||
### D5. Per-profile gateway service spec is fixed, not user-provided
|
||||
|
||||
Every profile gateway has the same command shape
|
||||
(`hermes -p <profile> gateway run`, or `hermes gateway run` for the default
|
||||
profile). The s6 backend generates the `run` script from a fixed template
|
||||
given the profile name — no arbitrary command list. This keeps the API
|
||||
surface tight and prevents callers from accidentally registering
|
||||
non-gateway services.
|
||||
|
||||
Port selection is governed by the profile's `config.yaml`
|
||||
(`[gateway] port = …`) — the single source of truth. (The original plan
|
||||
proposed a Python-side SHA-256 port allocator with a 600-port range; it was
|
||||
retired during PR review because it was dead code through the entire stack.)
|
||||
|
||||
### D6. Add detect_service_manager() alongside supports_systemd_services()
|
||||
|
||||
`supports_systemd_services()` stays as-is (host code paths unchanged). A new
|
||||
`detect_service_manager() -> Literal["systemd", "launchd", "windows", "s6", "none"]`
|
||||
composes existing detection functions (`is_macos()`, `is_windows()`,
|
||||
`supports_systemd_services()`, `is_container()` + `_s6_running()`) and adds
|
||||
an s6 branch for container detection. Host call sites continue to use the
|
||||
existing functions; container-only code (the profile hooks) uses the new one.
|
||||
|
||||
`_s6_running()` probes `/proc/1/comm` (world-readable) and
|
||||
`/run/s6/basedir`. The earlier `/proc/1/exe` probe was root-only readable
|
||||
and silently failed for the unprivileged hermes user (UID 10000), making
|
||||
the entire runtime-registration path inert in production — caught in PR
|
||||
review.
|
||||
|
||||
### D7. Wrap existing systemd/launchd/windows functions, don't rewrite them
|
||||
|
||||
`SystemdServiceManager` / `LaunchdServiceManager` / `WindowsServiceManager`
|
||||
are thin adapters over the existing `systemd_*` / `launchd_*` module-level
|
||||
functions in `hermes_cli/gateway.py` and the
|
||||
`gateway_windows.install/uninstall/start/stop/restart/is_installed`
|
||||
functions in `hermes_cli/gateway_windows.py`. We get the abstraction
|
||||
without rewriting ~2,200 LOC of working code.
|
||||
|
||||
### D8. Profile create/delete hooks register/unregister the s6 service
|
||||
|
||||
When `hermes profile create <name>` runs inside the container, the
|
||||
profile-creation code path calls
|
||||
`ServiceManager.register_profile_gateway(<name>)` if
|
||||
`supports_runtime_registration()` is True. When `hermes profile delete
|
||||
<name>` runs, it calls `unregister_profile_gateway(<name>)`. On host, both
|
||||
calls are no-ops (registration not supported; existing systemd unit
|
||||
generation continues to handle install/uninstall).
|
||||
|
||||
Existing per-profile `hermes -p <profile> gateway start/stop/restart` CLI
|
||||
commands continue to work — in the container they dispatch to
|
||||
`ServiceManager.start/stop/restart("gateway-<profile>")`, which translates
|
||||
to `s6-svc -u`/`-d`/`-t` on the service dir.
|
||||
|
||||
`hermes gateway start` (no `-p`) targets a special `gateway-default` slot
|
||||
that's always registered by the cont-init reconciler. Its run script omits
|
||||
the `-p` flag and runs against the root `$HERMES_HOME` profile.
|
||||
|
||||
`--all` lifecycle (`hermes gateway stop --all`, `... restart --all`)
|
||||
iterates `mgr.list_profile_gateways()` through s6 so s6's `want up`/`want
|
||||
down` flips correctly. Without this, `--all` fell through to `pkill`
|
||||
followed by s6-supervise auto-restart — net effect: kick instead of stop.
|
||||
|
||||
### D9. Interactive TUI bypasses s6 service-mode and runs as CMD for TTY passthrough
|
||||
|
||||
`docker run -it --rm <image> --tui` needs a real TTY connected to container
|
||||
stdin/stdout for Ink raw-mode keyboard input, cursor control, and SIGWINCH.
|
||||
Running the TUI as a normal s6 service fails because s6-supervise
|
||||
disconnects service stdio from the container TTY (documented:
|
||||
[s6-overlay#230](https://github.com/just-containers/s6-overlay/issues/230)).
|
||||
|
||||
**The pattern:** s6-overlay's `/init` execs a CMD as the container's "main
|
||||
program" after the supervision tree is up. The CMD inherits
|
||||
stdin/stdout/stderr from `/init` — which in `-it` mode is the container
|
||||
TTY. The stage2 hook detects the TUI case and short-circuits the
|
||||
main-hermes service so the hermes CMD becomes that main program.
|
||||
|
||||
```sh
|
||||
# In docker/stage2-hook.sh
|
||||
_is_tui_invocation() {
|
||||
for arg in "$@"; do
|
||||
case "$arg" in --tui|-T) return 0 ;; esac
|
||||
done
|
||||
case "${HERMES_TUI:-}" in 1|true|TRUE|yes) return 0 ;; esac
|
||||
if [ -t 0 ] && [ $# -eq 0 ]; then return 0; fi
|
||||
return 1
|
||||
}
|
||||
```
|
||||
|
||||
And in `docker/s6-rc.d/main-hermes/run`:
|
||||
|
||||
```sh
|
||||
if [ -f /var/run/s6/container_environment/HERMES_TUI_MODE ]; then
|
||||
exec sleep infinity # s6-overlay will exec CMD as the TTY-connected main
|
||||
fi
|
||||
exec s6-setuidgid hermes hermes ${HERMES_ARGS:-}
|
||||
```
|
||||
|
||||
In TUI mode main hermes is effectively unsupervised (same as the pre-s6
|
||||
behavior with tini — acceptable because the user is interactively
|
||||
present). Dashboard and profile gateways still get full s6 supervision via
|
||||
their separate services.
|
||||
|
||||
The integration test `test_tty_passthrough_to_container` uses `tput cols`
|
||||
and `COLUMNS=123` as the probe.
|
||||
|
||||
---
|
||||
|
||||
## Risk Register
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Phase 2 breaks a downstream user's Dockerfile that `FROM`s ours | Medium | Medium | Release notes call out ENTRYPOINT change; the test harness (`tests/docker/`) gives high confidence in behavior parity |
|
||||
| TUI TTY passthrough fails on some Docker versions | Low | High | Harness includes `test_tty_passthrough_to_container` as a hard gate; fallback plan = s6-fdholder ([s6-overlay#230](https://github.com/just-containers/s6-overlay/issues/230) Solution 2) |
|
||||
| s6-overlay non-root quirks (logutil-service, fix-attrs) bite us | Low | Low | Supervisor runs as root, services drop — sidesteps these issues |
|
||||
| Podman rootless UID mapping confuses s6 | Medium | Low | Documented as supported, fix reactively; a Podman + Docker environment is stood up for validation |
|
||||
| Test harness is flaky (docker daemon issues, timing) | Medium | Low | Generous timeouts; skip when docker unavailable; polling helpers replace fixed sleeps in `test_container_restart.py` |
|
||||
| Profile gateway crash loop masks a real config error | Low | Medium | s6 `finish` script `max_restarts` cap (planned follow-up); operators see crash-looping logs in `$HERMES_HOME/logs/gateways/<profile>/` |
|
||||
| Dockerfile+entrypoint drift from linter (hadolint/shellcheck) reveals latent bugs | Low | Low | CI lint jobs catch them; fix or document ignore with rationale |
|
||||
| Stale `gateway.pid` from a dead container collides with an unrelated live PID in the restarted container | Low | Medium | Cont-init reconciliation removes `gateway.pid` and `processes.json` from every profile dir on boot, before any new gateway starts |
|
||||
| `docker restart` silently loses per-profile gateway registrations (tmpfs scandir wiped) | High (without mitigation) | High | Cont-init reconciliation re-registers from persistent `$HERMES_HOME/profiles/` and auto-starts those last seen `running`; outcome recorded to `$HERMES_HOME/logs/container-boot.log` (size-bounded, rotates to `.1` at 256 KiB) |
|
||||
| A `running` gateway that's actually broken auto-restarts into a crash loop after every container restart | Low | Medium | s6 `finish` script `max_restarts` cap (planned); follow-up: `hermes doctor` alerts when N consecutive container restarts ended in `startup_failed` |
|
||||
| `_s6_running()` detection works as root but silently fails for unprivileged hermes user, making runtime-registration path inert | High (without mitigation) | High | **Caught in PR review.** Detection now probes `/proc/1/comm` (world-readable) + `/run/s6/basedir`. Docker integration tests refactored to `docker exec -u hermes` so the realistic runtime user is exercised |
|
||||
| `s6-svscanctl` from hermes hits EACCES on the root-owned control FIFO | Medium | Medium | `02-reconcile-profiles` chowns `/run/service/.s6-svscan/{control,lock}` to hermes after stage1 creates them |
|
||||
| Per-service `supervise/control` FIFO is root-owned by s6-supervise, blocking `s6-svc` from hermes | Known | Medium | Surfaced cleanly as `S6CommandError` (with rc + stderr) instead of raw `CalledProcessError`. Permission fix tracked as a follow-up (small SUID helper, polling chown loop in cont-init.d, or replace `s6-svc` with `down`-marker manipulation) |
|
||||
|
||||
---
|
||||
|
||||
## Decision Log
|
||||
|
||||
| # | Question | Decision |
|
||||
|---|---|---|
|
||||
| OQ1 | Gate Phase 2 behind env var? | Ship directly (Hermes is pre-1.0; users can pin the previous image) |
|
||||
| OQ2 | s6 root model | Root `/init`, drop per-service via `s6-setuidgid hermes` |
|
||||
| OQ3 | Dashboard opt-in mechanism | Always declared as an s6 service; `03-dashboard-toggle` cont-init script writes a `down` marker when `HERMES_DASHBOARD` is unset so `s6-svstat` reports the slot's real state |
|
||||
| OQ4 | Podman rootless | Supported, fix reactively |
|
||||
| OQ5 | Service naming | `gateway-<profile>` (matches pre-existing `hermes-gateway-<profile>.service` systemd convention) |
|
||||
| OQ6 | — (retired; no subagent gateways in scope) | — |
|
||||
| OQ7 | Resource limits per profile gateway | Defer (no per-cgroup limits; rely on the container's overall limit) |
|
||||
| OQ8 | Log persistence | `$HERMES_HOME/logs/gateways/<profile>/`. The log path is sourced from runtime `$HERMES_HOME` via `with-contenv`, NOT Python-substituted at registration time |
|
||||
| OQ9 | TUI passthrough | Trust the documented [s6-overlay#230](https://github.com/just-containers/s6-overlay/issues/230) Solution 1; harness includes a TTY passthrough hard-gate test |
|
||||
|
||||
**Post-merge additions from PR #30136 review:**
|
||||
|
||||
- **Multi-arch tarballs:** `TARGETARCH` mapped to `x86_64` / `aarch64`;
|
||||
per-arch tarball fetched via `curl` because `ADD` doesn't honor BuildKit
|
||||
args.
|
||||
- **SHA256 verification:** all three tarballs (noarch, symlinks, per-arch)
|
||||
pinned via build ARGs and verified with `sha256sum -c` against a single
|
||||
checksum file (avoids hadolint DL4006 piped-shell warning).
|
||||
- **`gateway-default` slot:** always registered by the reconciler so
|
||||
`hermes gateway start` (no `-p`) has somewhere to land.
|
||||
- **Friendly lifecycle errors:** `GatewayNotRegisteredError` and
|
||||
`S6CommandError` translate `CalledProcessError` into actionable CLI
|
||||
messages.
|
||||
- **Atomic publication in the reconciler:** mirrors
|
||||
`register_profile_gateway`'s tmp+rename pattern.
|
||||
- **`container-boot.log` rotation:** 256 KiB soft cap, rotated to `.1`.
|
||||
- **`port` parameter retired:** allocator + kwarg were dead code through
|
||||
the entire stack; `config.yaml` is the single source of truth.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [x] Test harness (`tests/docker/`) passes against the s6 image
|
||||
- [x] hadolint + shellcheck run green in CI
|
||||
- [x] `docker run -it --rm hermes-agent --tui` starts the Ink TUI with
|
||||
working keyboard input, cursor control, and resize (SIGWINCH)
|
||||
- [x] Dashboard crashes are recovered by s6 within ~2s
|
||||
- [x] `hermes profile create test` inside a container creates
|
||||
`/run/service/gateway-test/`
|
||||
- [x] `hermes -p test gateway start` inside a container dispatches through s6
|
||||
- [x] `hermes -p test gateway stop` inside a container cleanly stops via s6
|
||||
- [x] `hermes profile delete test` inside a container removes
|
||||
`/run/service/gateway-test/`
|
||||
- [x] Profile gateway logs persist at
|
||||
`$HERMES_HOME/logs/gateways/test/current`
|
||||
- [x] `hermes status` inside the container shows `Manager: s6`
|
||||
- [x] `hermes gateway start` (no `-p`) inside a container targets
|
||||
`gateway-default` and runs against the root profile
|
||||
- [x] `hermes gateway stop --all` / `... restart --all` iterate every
|
||||
profile gateway under s6 instead of pkill-then-supervise-restart
|
||||
- [x] `docker restart` survives per-profile gateway registrations via the
|
||||
cont-init reconciler; running gateways come back up, stopped ones
|
||||
stay down
|
||||
- [x] Multi-arch image builds for both `linux/amd64` and `linux/arm64`
|
||||
- [x] s6-overlay tarballs are SHA256-verified at build time
|
||||
- [x] No systemd/launchd host-side functions were modified (only wrapped)
|
||||
- [x] `hermes gateway install/start/stop` on Linux host and macOS host
|
||||
behave identically to pre-change
|
||||
@@ -0,0 +1,152 @@
|
||||
# ACP Zed Pre-Edit Approval Diffs Implementation Plan
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Gate file mutations in ACP/Zed behind explicit pre-edit approval with a structured diff, similar to Codex/Kimi edit review behavior.
|
||||
|
||||
**Architecture:** Hermes already renders edit diffs after tools run. This PR adds a pre-mutation permission gate for file mutation tools. Intercept `write_file`, `patch`, and eventually `skill_manage` before they mutate disk; compute proposed old/new content; send ACP `session/request_permission` with `kind="edit"` and diff content; only execute the mutation after approval. Rejections return a clear tool result and leave files unchanged.
|
||||
|
||||
**Tech Stack:** Python, ACP `request_permission`, `FileEditToolCallContent` / `acp.tool_diff_content`, Hermes file tools, pytest with temp files.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Confirm current ACP diff/permission schema
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
/home/nour/.hermes/hermes-agent/venv/bin/python - <<'PY'
|
||||
from acp.schema import RequestPermissionRequest, ToolCallUpdate
|
||||
import acp, inspect
|
||||
print(RequestPermissionRequest.model_fields)
|
||||
print(ToolCallUpdate.model_fields)
|
||||
print(inspect.signature(acp.tool_diff_content))
|
||||
PY
|
||||
```
|
||||
|
||||
Record actual field names. Do not rely on stale examples.
|
||||
|
||||
### Task 2: Add denied-write test
|
||||
|
||||
**Objective:** A rejected `write_file` must not mutate disk.
|
||||
|
||||
**Files:**
|
||||
- Create/modify: `tests/acp/test_edit_approval.py`
|
||||
|
||||
Test shape:
|
||||
|
||||
```python
|
||||
def test_write_file_rejected_by_acp_permission_does_not_mutate(tmp_path):
|
||||
path = tmp_path / "demo.txt"
|
||||
path.write_text("old")
|
||||
|
||||
# Install fake ACP edit approval callback returning reject_once.
|
||||
# Invoke the same interception function that the terminal/tool path will call.
|
||||
|
||||
result = maybe_gate_file_edit(
|
||||
tool_name="write_file",
|
||||
args={"path": str(path), "content": "new"},
|
||||
approval_requester=fake_reject,
|
||||
)
|
||||
|
||||
assert path.read_text() == "old"
|
||||
assert "rejected" in result.lower()
|
||||
```
|
||||
|
||||
The exact function name will be created in Task 4.
|
||||
|
||||
### Task 3: Add approved-write test
|
||||
|
||||
**Objective:** Approved writes proceed and include diff content in permission request.
|
||||
|
||||
Assert:
|
||||
|
||||
- fake requester received tool call `kind == "edit"`
|
||||
- content includes diff block for `demo.txt`
|
||||
- after approval, file content is changed
|
||||
|
||||
### Task 4: Implement edit proposal computation
|
||||
|
||||
**Files:**
|
||||
- Create: `acp_adapter/edit_approval.py`
|
||||
|
||||
Add pure helpers first:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class EditProposal:
|
||||
path: str
|
||||
old_text: str | None
|
||||
new_text: str
|
||||
title: str
|
||||
|
||||
|
||||
def proposal_for_write_file(args: dict[str, Any]) -> EditProposal:
|
||||
path = str(args["path"])
|
||||
old_text = Path(path).read_text(encoding="utf-8") if Path(path).exists() else None
|
||||
new_text = str(args.get("content", ""))
|
||||
return EditProposal(path=path, old_text=old_text, new_text=new_text, title=f"Edit {path}")
|
||||
```
|
||||
|
||||
For `patch`, start with replace-mode only. V4A/multi-file patches can be a second task or second PR if too risky.
|
||||
|
||||
### Task 5: Implement ACP permission requester
|
||||
|
||||
**Files:**
|
||||
- Modify: `acp_adapter/permissions.py` or new `acp_adapter/edit_approval.py`
|
||||
|
||||
Build request with:
|
||||
|
||||
```python
|
||||
acp.tool_diff_content(path=proposal.path, old_text=proposal.old_text, new_text=proposal.new_text)
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- allow once
|
||||
- reject once
|
||||
- optionally allow always/reject always only after policy storage exists
|
||||
|
||||
Default deny on exception/cancel/timeout.
|
||||
|
||||
### Task 6: Intercept file mutation tools before execution
|
||||
|
||||
**Objective:** Ensure mutation cannot happen before approval.
|
||||
|
||||
**Files:**
|
||||
- Likely modify: `model_tools.py` or `acp_adapter/server.py` session-context tool wrapper
|
||||
|
||||
Do not bury this inside post-execution `acp_adapter/events.py`; that is too late.
|
||||
|
||||
Preferred design:
|
||||
|
||||
- set an ACP session contextvar around `agent.run_conversation(...)`
|
||||
- in the central tool execution path, before dispatching `write_file`/`patch`, call the ACP edit approval gate if contextvar exists
|
||||
- if rejected, return a normal tool result string like `{"success": false, "error": "Edit rejected by user"}`
|
||||
- if approved, continue to original tool implementation
|
||||
|
||||
### Task 7: Expand patch coverage
|
||||
|
||||
Add tests for:
|
||||
|
||||
- `patch` replace mode approved/rejected
|
||||
- creating a new file via `write_file`
|
||||
- missing old string -> should fail before approval or return normal patch error, but must not mutate
|
||||
- permission requester exception -> deny and no mutation
|
||||
|
||||
### Task 8: Verification
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
scripts/run_tests.sh tests/acp/test_edit_approval.py tests/acp/test_events.py tests/acp/test_tools.py -q
|
||||
```
|
||||
|
||||
Then run manual Zed verification:
|
||||
|
||||
1. Ask Hermes ACP to edit a small file.
|
||||
2. Confirm Zed shows a diff before mutation.
|
||||
3. Reject and verify file unchanged.
|
||||
4. Approve and verify file changed.
|
||||
|
||||
**Do not merge** without manual reject-path verification.
|
||||
+5
-43
@@ -474,13 +474,6 @@ class GatewayConfig:
|
||||
|
||||
# Delivery settings
|
||||
always_log_local: bool = True # Always save cron outputs to local files
|
||||
# Drop outbound "silence narration" messages (e.g. *(silent)*, 🔇, a bare
|
||||
# ".") pre-send. These are model hallucinations emitted when a persona has
|
||||
# nothing actionable to say; in bot-to-bot channels they mirror back and
|
||||
# forth, burning tokens and crashing models. Substrate-level guard that
|
||||
# survives SOUL.md/prompt drift across providers. Opt out with False for
|
||||
# raw passthrough.
|
||||
filter_silence_narration: bool = True
|
||||
|
||||
# STT settings
|
||||
stt_enabled: bool = True # Whether to auto-transcribe inbound voice messages
|
||||
@@ -589,7 +582,6 @@ class GatewayConfig:
|
||||
"quick_commands": self.quick_commands,
|
||||
"sessions_dir": str(self.sessions_dir),
|
||||
"always_log_local": self.always_log_local,
|
||||
"filter_silence_narration": self.filter_silence_narration,
|
||||
"stt_enabled": self.stt_enabled,
|
||||
"group_sessions_per_user": self.group_sessions_per_user,
|
||||
"thread_sessions_per_user": self.thread_sessions_per_user,
|
||||
@@ -658,9 +650,6 @@ class GatewayConfig:
|
||||
quick_commands=quick_commands,
|
||||
sessions_dir=sessions_dir,
|
||||
always_log_local=_coerce_bool(data.get("always_log_local"), True),
|
||||
filter_silence_narration=_coerce_bool(
|
||||
data.get("filter_silence_narration"), True
|
||||
),
|
||||
stt_enabled=_coerce_bool(stt_enabled, True),
|
||||
group_sessions_per_user=_coerce_bool(group_sessions_per_user, True),
|
||||
thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False),
|
||||
@@ -768,32 +757,21 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if "always_log_local" in yaml_cfg:
|
||||
gw_data["always_log_local"] = yaml_cfg["always_log_local"]
|
||||
|
||||
if "filter_silence_narration" in yaml_cfg:
|
||||
gw_data["filter_silence_narration"] = yaml_cfg[
|
||||
"filter_silence_narration"
|
||||
]
|
||||
|
||||
if "unauthorized_dm_behavior" in yaml_cfg:
|
||||
gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior(
|
||||
yaml_cfg.get("unauthorized_dm_behavior"),
|
||||
"pair",
|
||||
)
|
||||
|
||||
# Merge platform config into gw_data so runtime-only settings under
|
||||
# ``gateway.platforms`` are loaded the same way as top-level
|
||||
# ``platforms``. Merge nested first so top-level config keeps
|
||||
# precedence, matching the existing gateway.streaming fallback.
|
||||
gateway_cfg = yaml_cfg.get("gateway")
|
||||
gateway_platforms = gateway_cfg.get("platforms") if isinstance(gateway_cfg, dict) else None
|
||||
# Merge platforms section from config.yaml into gw_data so that
|
||||
# nested keys like platforms.webhook.extra.routes are loaded.
|
||||
yaml_platforms = yaml_cfg.get("platforms")
|
||||
platforms_data = gw_data.setdefault("platforms", {})
|
||||
if not isinstance(platforms_data, dict):
|
||||
platforms_data = {}
|
||||
gw_data["platforms"] = platforms_data
|
||||
|
||||
def _merge_platform_map(source_platforms: Any) -> None:
|
||||
if not isinstance(source_platforms, dict):
|
||||
return
|
||||
for plat_name, plat_block in source_platforms.items():
|
||||
if isinstance(yaml_platforms, dict):
|
||||
for plat_name, plat_block in yaml_platforms.items():
|
||||
if not isinstance(plat_block, dict):
|
||||
continue
|
||||
existing = platforms_data.get(plat_name, {})
|
||||
@@ -807,10 +785,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if merged_extra:
|
||||
merged["extra"] = merged_extra
|
||||
platforms_data[plat_name] = merged
|
||||
|
||||
_merge_platform_map(gateway_platforms)
|
||||
_merge_platform_map(yaml_cfg.get("platforms"))
|
||||
if platforms_data:
|
||||
gw_data["platforms"] = platforms_data
|
||||
# Iterate built-in platforms plus any registered plugin platforms
|
||||
# so plugin authors get the same shared-key bridging (#24836).
|
||||
@@ -916,18 +890,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if entry.apply_yaml_config_fn is None:
|
||||
continue
|
||||
platform_cfg = yaml_cfg.get(entry.name)
|
||||
# Fall back to the platform's block under ``platforms`` /
|
||||
# ``gateway.platforms`` so adapter hooks still run when the
|
||||
# user configured the platform only under those nested paths
|
||||
# (e.g. ``platforms.discord.extra.allow_from``) and not via a
|
||||
# top-level ``discord:`` block.
|
||||
if not isinstance(platform_cfg, dict):
|
||||
for _src in (gateway_platforms, yaml_cfg.get("platforms")):
|
||||
if isinstance(_src, dict):
|
||||
_candidate = _src.get(entry.name)
|
||||
if isinstance(_candidate, dict):
|
||||
platform_cfg = _candidate
|
||||
break
|
||||
if not isinstance(platform_cfg, dict):
|
||||
continue
|
||||
try:
|
||||
|
||||
@@ -9,8 +9,6 @@ Routes messages to the appropriate destination based on:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
@@ -23,32 +21,6 @@ logger = logging.getLogger(__name__)
|
||||
MAX_PLATFORM_OUTPUT = 4000
|
||||
TRUNCATED_VISIBLE = 3800
|
||||
|
||||
# Matches strings that are *only* a "silence" narration with optional markdown
|
||||
# wrappers. Covers: *(silent)*, _silent_, `silent`, ~silent~, (silent), silent,
|
||||
# 🔇, a bare ".", "…", and the whitespace/marker-padded variants seen in the
|
||||
# wild. Anchored to start/end so substantive messages that merely *contain* the
|
||||
# word "silent" are never matched.
|
||||
_SILENCE_NARRATION = re.compile(
|
||||
r'^[\s*_~`]*\(?\s*(silent|silence|no\s+response|no\s+reply)\s*\.?\)?[\s*_~`]*$'
|
||||
r'|^[\s*_~`]*[\U0001F507\.\u2026]+[\s*_~`]*$',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _is_silence_narration(content: Optional[str]) -> bool:
|
||||
"""Return True when ``content`` is *only* a silence-narration token.
|
||||
|
||||
Length-guarded (real messages are longer) and anchored to the whole string
|
||||
so legitimate prose like "The deployment ran silently" or "Silence is
|
||||
golden — here is the plan..." is never flagged.
|
||||
"""
|
||||
if not content:
|
||||
return False
|
||||
stripped = content.strip()
|
||||
if not stripped or len(stripped) > 64: # length guard
|
||||
return False
|
||||
return bool(_SILENCE_NARRATION.match(stripped))
|
||||
|
||||
from .config import Platform, GatewayConfig
|
||||
from .session import SessionSource
|
||||
|
||||
@@ -289,18 +261,6 @@ class DeliveryRouter:
|
||||
path.write_text(content)
|
||||
return path
|
||||
|
||||
def _filter_silence_narration_enabled(self) -> bool:
|
||||
"""Whether the outbound silence-narration filter is active.
|
||||
|
||||
``HERMES_FILTER_SILENCE_NARRATION`` env var overrides config when set;
|
||||
otherwise the ``gateway.filter_silence_narration`` config flag wins
|
||||
(default True).
|
||||
"""
|
||||
env = os.getenv("HERMES_FILTER_SILENCE_NARRATION")
|
||||
if env is not None:
|
||||
return env.strip().lower() in ("1", "true", "yes", "on")
|
||||
return bool(getattr(self.config, "filter_silence_narration", True))
|
||||
|
||||
async def _deliver_to_platform(
|
||||
self,
|
||||
target: DeliveryTarget,
|
||||
@@ -326,27 +286,6 @@ class DeliveryRouter:
|
||||
+ f"\n\n... [truncated, full output saved to {saved_path}]"
|
||||
)
|
||||
|
||||
# Substrate-level anti-loop guard: drop hallucinated "silence narration"
|
||||
# (*(silent)*, 🔇, a bare ".", etc.) before it ever reaches the adapter.
|
||||
# In bot-to-bot channels these tokens mirror back and forth until a
|
||||
# model crashes with "no content after all retries". Behavioral prompt
|
||||
# rules drift across providers; this single chokepoint covers every
|
||||
# platform adapter regardless of which persona's prompt failed.
|
||||
# Local/file delivery (_deliver_local) is a separate path and is never
|
||||
# filtered — saved silence has no loop risk.
|
||||
if self._filter_silence_narration_enabled() and _is_silence_narration(content):
|
||||
logger.warning(
|
||||
"Dropped silence-narration outbound to %s (chat=%s): %r",
|
||||
target.platform.value,
|
||||
target.chat_id,
|
||||
content[:40],
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"filtered": "silence_narration",
|
||||
"delivered": False,
|
||||
}
|
||||
|
||||
send_metadata = dict(metadata or {})
|
||||
is_named_telegram_private_topic = False
|
||||
named_telegram_private_topic_name: Optional[str] = None
|
||||
|
||||
+19
-135
@@ -484,7 +484,7 @@ sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
from hermes_constants import get_default_hermes_root, get_hermes_dir, get_hermes_home
|
||||
from hermes_constants import get_hermes_dir, get_hermes_home
|
||||
|
||||
|
||||
GATEWAY_SECRET_CAPTURE_UNSUPPORTED_MESSAGE = (
|
||||
@@ -827,7 +827,6 @@ def cache_video_from_bytes(data: bytes, ext: str = ".mp4") -> str:
|
||||
DOCUMENT_CACHE_DIR = get_hermes_dir("cache/documents", "document_cache")
|
||||
SCREENSHOT_CACHE_DIR = get_hermes_dir("cache/screenshots", "browser_screenshots")
|
||||
_HERMES_HOME = get_hermes_home()
|
||||
_HERMES_ROOT = get_default_hermes_root()
|
||||
MEDIA_DELIVERY_ALLOW_DIRS_ENV = "HERMES_MEDIA_ALLOW_DIRS"
|
||||
MEDIA_DELIVERY_TRUST_RECENT_ENV = "HERMES_MEDIA_TRUST_RECENT_FILES"
|
||||
MEDIA_DELIVERY_TRUST_RECENT_SECONDS_ENV = "HERMES_MEDIA_TRUST_RECENT_SECONDS"
|
||||
@@ -955,14 +954,11 @@ def _media_delivery_denied_paths() -> List[Path]:
|
||||
home = Path(os.path.expanduser("~"))
|
||||
for sub in _MEDIA_DELIVERY_DENIED_HOME_SUBPATHS:
|
||||
denied.append(home / sub)
|
||||
# The active Hermes profile and shared Hermes root both contain control
|
||||
# files and credentials. Only cache subdirectories under them are
|
||||
# explicitly allowlisted above.
|
||||
for hermes_root in (_HERMES_HOME, _HERMES_ROOT):
|
||||
denied.append(hermes_root / ".env")
|
||||
denied.append(hermes_root / "auth.json")
|
||||
denied.append(hermes_root / "credentials")
|
||||
denied.append(hermes_root / "config.yaml")
|
||||
# The Hermes home itself contains credentials (auth.json, .env) — only the
|
||||
# cache subdirectories under it are explicitly allowlisted above.
|
||||
denied.append(_HERMES_HOME / ".env")
|
||||
denied.append(_HERMES_HOME / "auth.json")
|
||||
denied.append(_HERMES_HOME / "credentials")
|
||||
return denied
|
||||
|
||||
|
||||
@@ -1195,12 +1191,10 @@ _MEDIA_EXT_ALTERNATION = "|".join(
|
||||
# bare-path detector (extract_local_files) downstream rather than silently
|
||||
# deleted. Shared by the non-streaming dispatch path and the streaming
|
||||
# consumer so both behave identically.
|
||||
# Path anchors: ``~/`` (Unix home-relative), ``/`` (Unix absolute),
|
||||
# ``X:\\`` or ``X:/`` (Windows drive-letter absolute — #34632).
|
||||
MEDIA_TAG_CLEANUP_RE = re.compile(
|
||||
r'''[`"']?MEDIA:\s*'''
|
||||
r'''(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|'''
|
||||
r'''(?:~/|/|[A-Za-z]:[/\\])\S+(?:[^\S\n]+\S+)*?\.(?:''' + _MEDIA_EXT_ALTERNATION + r'''))'''
|
||||
r'''(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:''' + _MEDIA_EXT_ALTERNATION + r'''))'''
|
||||
r'''(?=[\s`"',;:)\]}]|$)[`"']?''',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
@@ -2579,88 +2573,6 @@ class BasePlatformAdapter(ABC):
|
||||
logger.warning("Skipping unsafe local file path: %s", _log_safe_path(raw))
|
||||
return safe_paths
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _mask_protected_spans(content: str) -> str:
|
||||
"""Replace content inside fenced code blocks, inline code spans,
|
||||
and blockquotes with spaces to prevent MEDIA: false positives.
|
||||
|
||||
Preserves character count so regex match offsets stay valid.
|
||||
Skips masking backtick-quoted paths in MEDIA: tags (e.g.
|
||||
``MEDIA:`/path/to/file.png` ``) to avoid breaking path extraction.
|
||||
"""
|
||||
chars = list(content)
|
||||
n = len(chars)
|
||||
|
||||
# Build list of (start, end) spans to mask
|
||||
spans: list = []
|
||||
|
||||
# Fenced code blocks: ```...```
|
||||
for m in re.finditer(r'```[^\n]*\n.*?```', content, re.DOTALL):
|
||||
spans.append((m.start(), m.end()))
|
||||
|
||||
# Inline code: `...` but NOT backtick-quoted paths in MEDIA: tags
|
||||
for m in re.finditer(r'`[^`\n]+`', content):
|
||||
start = m.start()
|
||||
# Check if this is a backtick-quoted path after MEDIA:
|
||||
prefix = content[max(0, start - 20):start]
|
||||
if re.search(r'MEDIA:\s*$', prefix):
|
||||
continue # This is a MEDIA path quote, not inline code
|
||||
spans.append((start, m.end()))
|
||||
|
||||
# Blockquote lines: > at line start
|
||||
for m in re.finditer(r'^>.*$', content, re.MULTILINE):
|
||||
spans.append((m.start(), m.end()))
|
||||
|
||||
# Apply masking
|
||||
for start, end in spans:
|
||||
for i in range(start, end):
|
||||
if chars[i] != '\n':
|
||||
chars[i] = ' '
|
||||
|
||||
return ''.join(chars)
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _mask_json_string_media(content: str) -> str:
|
||||
"""Blank out ``MEDIA:<bare-path>`` occurrences that sit inside a JSON
|
||||
string *value* so they are never delivered as real attachments.
|
||||
|
||||
Serialized tool results frequently embed a previous reply's text, e.g.::
|
||||
|
||||
{"result": "MEDIA:/Users/x/.hermes/media/generated/stale.png"}
|
||||
|
||||
Here the ``MEDIA:`` is part of stored text, not an outbound directive,
|
||||
but the bare-path branch of ``MEDIA_TAG_CLEANUP_RE`` would still match it
|
||||
and re-deliver a stale file. (Regression report #34375.)
|
||||
|
||||
The discriminator is precise so legitimate tags are untouched:
|
||||
|
||||
* Only spans opened by a JSON value-context quote (``:``, ``,``, ``{`` or
|
||||
``[`` immediately before the ``"``) are considered.
|
||||
* Within such a span, only a ``MEDIA:`` followed by a **bare** path
|
||||
(``/``, ``~/`` or ``X:\\``) is masked. A ``MEDIA:"..."`` quoted-path
|
||||
tag — a real LLM output format the extractor supports — is not bare and
|
||||
is left alone.
|
||||
* Tags at line start, after prose whitespace, or indented are outside any
|
||||
JSON value span and are never affected.
|
||||
|
||||
Offsets are preserved (matched chars replaced with spaces, newlines kept)
|
||||
so downstream match positions stay valid.
|
||||
"""
|
||||
if '"' not in content or "MEDIA:" not in content:
|
||||
return content
|
||||
chars = list(content)
|
||||
# JSON value-context string: a quote preceded by : , { or [ (optional ws),
|
||||
# capturing the (escape-aware) string body up to the closing quote.
|
||||
for m in re.finditer(r'(?<=[:,{\[])\s*"((?:[^"\\\n]|\\.)*)"', content):
|
||||
seg = m.group(1)
|
||||
if re.search(r'MEDIA:\s*(?:~/|/|[A-Za-z]:[/\\])', seg):
|
||||
for i in range(m.start(1), m.end(1)):
|
||||
if chars[i] != '\n':
|
||||
chars[i] = ' '
|
||||
return ''.join(chars)
|
||||
|
||||
@staticmethod
|
||||
def extract_media(content: str) -> Tuple[List[Tuple[str, bool]], str]:
|
||||
"""
|
||||
@@ -2703,15 +2615,7 @@ class BasePlatformAdapter(ABC):
|
||||
# set is the shared MEDIA_DELIVERY_EXTS source of truth (built once into
|
||||
# MEDIA_TAG_CLEANUP_RE) so it can never drift from extract_local_files.
|
||||
media_pattern = MEDIA_TAG_CLEANUP_RE
|
||||
# Mask example/stored MEDIA: paths before scanning so they are never
|
||||
# delivered as real attachments:
|
||||
# - code blocks / inline code / blockquotes hold prose examples (#35695)
|
||||
# - serialized JSON string values hold stored tool-result text (#34375)
|
||||
# Both maskers are offset-preserving (chars -> spaces) so match offsets
|
||||
# stay valid; chaining them masks the union of both protected regions.
|
||||
scan_content = BasePlatformAdapter._mask_protected_spans(content)
|
||||
scan_content = BasePlatformAdapter._mask_json_string_media(scan_content)
|
||||
for match in media_pattern.finditer(scan_content):
|
||||
for match in media_pattern.finditer(content):
|
||||
path = match.group("path").strip()
|
||||
if len(path) >= 2 and path[0] == path[-1] and path[0] in "`\"'":
|
||||
path = path[1:-1].strip()
|
||||
@@ -2724,24 +2628,10 @@ class BasePlatformAdapter(ABC):
|
||||
# and dropping every other attachment in the response.
|
||||
continue
|
||||
|
||||
# Remove the delivered MEDIA tags from the user-visible text. Mask a
|
||||
# length-equal copy of ``cleaned`` (same union of protected regions) to
|
||||
# *locate* the real tag spans, then delete exactly those spans from the
|
||||
# *unmasked* ``cleaned``. Masking is only a locator — protected spans
|
||||
# (code blocks, quotes, JSON-embedded MEDIA: text) must survive verbatim
|
||||
# in the delivered text, not be blanked to whitespace. Masking
|
||||
# ``cleaned`` (not ``content``) keeps offsets valid after the
|
||||
# [[audio_as_voice]] / [[as_document]] directives are removed.
|
||||
# Remove MEDIA tags from content (including surrounding quote/backtick wrappers)
|
||||
if media:
|
||||
masked_cleaned = BasePlatformAdapter._mask_protected_spans(cleaned)
|
||||
masked_cleaned = BasePlatformAdapter._mask_json_string_media(masked_cleaned)
|
||||
spans = [m.span() for m in media_pattern.finditer(masked_cleaned)]
|
||||
if spans:
|
||||
chars = list(cleaned)
|
||||
for start, end in sorted(spans, reverse=True):
|
||||
del chars[start:end]
|
||||
cleaned = "".join(chars)
|
||||
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
|
||||
cleaned = media_pattern.sub('', cleaned)
|
||||
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned).strip()
|
||||
|
||||
return media, cleaned
|
||||
|
||||
@@ -2775,10 +2665,9 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
# (?<![/:\w.]) prevents matching inside URLs (e.g. https://…/img.png)
|
||||
# and relative paths (./foo.png)
|
||||
# (?:~/|/) anchors to absolute or home-relative Unix paths
|
||||
# (?:[A-Za-z]:[/\\]) anchors to Windows drive-letter paths (#34632)
|
||||
# (?:~/|/) anchors to absolute or home-relative paths
|
||||
path_re = re.compile(
|
||||
r'(?<![/:\w.])(?:~/|/|[A-Za-z]:[/\\])(?:[\w.\-]+[/\\])*[\w.\-]+\.(?:' + ext_part + r')\b',
|
||||
r'(?<![/:\w.])(?:~/|/)(?:[\w.\-]+/)*[\w.\-]+\.(?:' + ext_part + r')\b',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@@ -3844,7 +3733,6 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
# Call the handler (this can take a while with tool calls)
|
||||
response = await self._message_handler(event)
|
||||
is_ephemeral_response = isinstance(response, EphemeralReply)
|
||||
|
||||
# Slash-command handlers may return an EphemeralReply sentinel to
|
||||
# request that their reply message auto-delete after a TTL (used
|
||||
@@ -3902,16 +3790,12 @@ class BasePlatformAdapter(ABC):
|
||||
if images:
|
||||
logger.info("[%s] extract_images found %d image(s) in response (%d chars)", self.name, len(images), len(response))
|
||||
|
||||
local_files = []
|
||||
if not is_ephemeral_response:
|
||||
# Auto-detect bare local file paths for native media delivery
|
||||
# (helps small models that don't use MEDIA: syntax). Skip
|
||||
# system/command notices so config paths stay visible text
|
||||
# instead of becoming native uploads.
|
||||
local_files, text_content = self.extract_local_files(text_content)
|
||||
local_files = self.filter_local_delivery_paths(local_files)
|
||||
if local_files:
|
||||
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
|
||||
# Auto-detect bare local file paths for native media delivery
|
||||
# (helps small models that don't use MEDIA: syntax)
|
||||
local_files, text_content = self.extract_local_files(text_content)
|
||||
local_files = self.filter_local_delivery_paths(local_files)
|
||||
if local_files:
|
||||
logger.info("[%s] extract_local_files found %d file(s) in response", self.name, len(local_files))
|
||||
|
||||
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
|
||||
# Gated via ``_should_auto_tts_for_chat``: fires when the chat has
|
||||
|
||||
@@ -14,7 +14,6 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
@@ -61,8 +60,6 @@ _MESSAGE_EVENTS = {"new-message", "message", "updated-message"}
|
||||
_PHONE_RE = re.compile(r"\+?\d{7,15}")
|
||||
_EMAIL_RE = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+")
|
||||
|
||||
_GUID_CACHE_SIZE = 500 # LRU cap for resolved chat-GUID lookups
|
||||
|
||||
|
||||
def _redact(text: str) -> str:
|
||||
"""Redact phone numbers and emails from log output."""
|
||||
@@ -131,7 +128,7 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
self._runner = None
|
||||
self._private_api_enabled: Optional[bool] = None
|
||||
self._helper_connected: bool = False
|
||||
self._guid_cache: OrderedDict[str, str] = OrderedDict()
|
||||
self._guid_cache: Dict[str, str] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API helpers
|
||||
@@ -368,7 +365,6 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
if ";" in target:
|
||||
return target
|
||||
if target in self._guid_cache:
|
||||
self._guid_cache.move_to_end(target)
|
||||
return self._guid_cache[target]
|
||||
try:
|
||||
payload = await self._api_post(
|
||||
@@ -381,14 +377,10 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
if identifier == target:
|
||||
if guid:
|
||||
self._guid_cache[target] = guid
|
||||
while len(self._guid_cache) > _GUID_CACHE_SIZE:
|
||||
self._guid_cache.popitem(last=False)
|
||||
return guid
|
||||
for part in chat.get("participants", []) or []:
|
||||
if (part.get("address") or "").strip() == target and guid:
|
||||
self._guid_cache[target] = guid
|
||||
while len(self._guid_cache) > _GUID_CACHE_SIZE:
|
||||
self._guid_cache.popitem(last=False)
|
||||
return guid
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -240,7 +240,6 @@ _FEISHU_REACTION_FAILURE = "CrossMark"
|
||||
# drain on completion; the cap is a safeguard against unbounded growth from
|
||||
# delete-failures, not a capacity plan.
|
||||
_FEISHU_PROCESSING_REACTION_CACHE_SIZE = 1024
|
||||
_FEISHU_MESSAGE_TEXT_CACHE_SIZE = 512 # LRU cap for reply-context message text lookups
|
||||
|
||||
# QR onboarding constants
|
||||
_ONBOARD_ACCOUNTS_URLS = {
|
||||
@@ -1453,7 +1452,7 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
self._sent_message_ids_to_chat: Dict[str, str] = {} # message_id → chat_id (for reaction routing)
|
||||
self._sent_message_id_order: List[str] = [] # LRU order for _sent_message_ids_to_chat
|
||||
self._chat_info_cache: Dict[str, Dict[str, Any]] = {}
|
||||
self._message_text_cache: "OrderedDict[str, Optional[str]]" = OrderedDict()
|
||||
self._message_text_cache: Dict[str, Optional[str]] = {}
|
||||
self._app_lock_identity: Optional[str] = None
|
||||
self._text_batch_state = FeishuBatchState()
|
||||
self._pending_text_batches = self._text_batch_state.events
|
||||
@@ -3960,7 +3959,6 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
if not self._client or not message_id:
|
||||
return None
|
||||
if message_id in self._message_text_cache:
|
||||
self._message_text_cache.move_to_end(message_id)
|
||||
return self._message_text_cache[message_id]
|
||||
try:
|
||||
request = self._build_get_message_request(message_id)
|
||||
@@ -3982,8 +3980,6 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
mentions=parent_mentions,
|
||||
)
|
||||
self._message_text_cache[message_id] = text
|
||||
while len(self._message_text_cache) > _FEISHU_MESSAGE_TEXT_CACHE_SIZE:
|
||||
self._message_text_cache.popitem(last=False)
|
||||
return text
|
||||
except Exception:
|
||||
logger.warning("[Feishu] Failed to fetch parent message %s", message_id, exc_info=True)
|
||||
|
||||
+26
-140
@@ -595,11 +595,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
metadata: Optional[Dict[str, Any]],
|
||||
) -> bool:
|
||||
if cls._metadata_direct_messages_topic_id(metadata) is not None:
|
||||
return bool(
|
||||
metadata
|
||||
and metadata.get("telegram_dm_topic_reply_fallback")
|
||||
and cls._metadata_reply_to_message_id(metadata) is not None
|
||||
)
|
||||
return False
|
||||
if metadata and metadata.get("telegram_dm_topic_created_for_send"):
|
||||
return False
|
||||
return bool(
|
||||
@@ -844,41 +840,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
stack.append(context)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_pool_timeout(error: Exception) -> bool:
|
||||
"""Return True when a Telegram TimedOut wraps an httpx pool timeout.
|
||||
|
||||
PTB converts ``httpx.PoolTimeout`` into ``telegram.error.TimedOut`` with
|
||||
a message that explicitly states the request was *not* sent
|
||||
(``"Pool timeout: All connections in the connection pool are occupied.
|
||||
Request was *not* sent to Telegram."``). Because the request never left
|
||||
the process, re-sending is safe and cannot duplicate -- the opposite of
|
||||
a generic TimedOut, which may have reached Telegram. We match the
|
||||
wrapped ``httpx.PoolTimeout`` class as well as the message string so the
|
||||
check survives PTB message-wording changes.
|
||||
"""
|
||||
seen: set[int] = set()
|
||||
stack: list[BaseException] = [error]
|
||||
while stack:
|
||||
cur = stack.pop()
|
||||
ident = id(cur)
|
||||
if ident in seen:
|
||||
continue
|
||||
seen.add(ident)
|
||||
name = cur.__class__.__name__.lower()
|
||||
text = str(cur).lower()
|
||||
if "pooltimeout" in name or "pool timeout" in text or (
|
||||
"connection pool" in text and "occupied" in text
|
||||
):
|
||||
return True
|
||||
cause = getattr(cur, "__cause__", None)
|
||||
context = getattr(cur, "__context__", None)
|
||||
if cause is not None:
|
||||
stack.append(cause)
|
||||
if context is not None:
|
||||
stack.append(context)
|
||||
return False
|
||||
|
||||
def _coerce_bool_extra(self, key: str, default: bool = False) -> bool:
|
||||
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
|
||||
if value is None:
|
||||
@@ -2040,15 +2001,11 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# TimedOut is also a subclass of NetworkError. A
|
||||
# generic timeout may have reached Telegram, so don't
|
||||
# retry; a wrapped ConnectTimeout means no connection
|
||||
# was established, so retrying is safe. A pool timeout
|
||||
# (httpx pool exhausted) is explicitly "not sent to
|
||||
# Telegram" -- retrying through the loop is safe and
|
||||
# prevents silent drops when the pool frees up.
|
||||
# was established, so retrying is safe.
|
||||
if (
|
||||
_TimedOut
|
||||
and isinstance(send_err, _TimedOut)
|
||||
and not self._looks_like_connect_timeout(send_err)
|
||||
and not self._looks_like_pool_timeout(send_err)
|
||||
):
|
||||
raise
|
||||
if _send_attempt < 2:
|
||||
@@ -2108,14 +2065,12 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=False, error="message_too_long")
|
||||
# TimedOut usually means the request may have reached Telegram —
|
||||
# mark as non-retryable so _send_with_retry() doesn't re-send.
|
||||
# Exceptions: a wrapped ConnectTimeout (no connection established)
|
||||
# and an httpx pool timeout (request explicitly not sent) -- both
|
||||
# are safe to re-send and must not be silently dropped.
|
||||
# Exception: wrapped ConnectTimeout, where no connection was
|
||||
# established; retrying is safe and prevents silent drops.
|
||||
_to = locals().get("_TimedOut")
|
||||
is_timeout = (_to and isinstance(e, _to)) or "timed out" in err_str
|
||||
is_connect_timeout = self._looks_like_connect_timeout(e)
|
||||
is_pool_timeout = self._looks_like_pool_timeout(e)
|
||||
return SendResult(success=False, error=str(e), retryable=(is_connect_timeout or is_pool_timeout or not is_timeout))
|
||||
return SendResult(success=False, error=str(e), retryable=(is_connect_timeout or not is_timeout))
|
||||
|
||||
async def send_or_update_status(
|
||||
self,
|
||||
@@ -2849,8 +2804,21 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return slug
|
||||
|
||||
try:
|
||||
# Build provider buttons — folds provider groups (display only).
|
||||
keyboard = self._build_provider_keyboard(providers)
|
||||
# Build provider buttons — 2 per row
|
||||
buttons: list = []
|
||||
for p in providers:
|
||||
count = p.get("total_models", len(p.get("models", [])))
|
||||
label = f"{p['name']} ({count})"
|
||||
if p.get("is_current"):
|
||||
label = f"✓ {label}"
|
||||
# Compact callback data: mp:<slug> (max 64 bytes)
|
||||
buttons.append(
|
||||
InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
|
||||
)
|
||||
|
||||
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
|
||||
rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
|
||||
keyboard = InlineKeyboardMarkup(rows)
|
||||
|
||||
provider_label = get_label(current_provider)
|
||||
text = self.format_message(
|
||||
@@ -2897,56 +2865,6 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
_MODEL_PAGE_SIZE = 8
|
||||
|
||||
def _build_provider_keyboard(self, providers: list):
|
||||
"""Build the top-level provider keyboard, folding provider groups.
|
||||
|
||||
Provider families (Kimi/Moonshot, MiniMax, xAI Grok, ...) collapse to
|
||||
a single ``mpg:<gid>`` button; tapping it drills into a member
|
||||
sub-keyboard. Single providers (and groups with only one authenticated
|
||||
member) render as direct ``mp:<slug>`` buttons. Grouping mirrors the
|
||||
CLI ``hermes model`` picker via the shared ``group_providers`` fold,
|
||||
so all surfaces stay consistent.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.models import group_providers
|
||||
except Exception:
|
||||
group_providers = None
|
||||
|
||||
by_slug = {p.get("slug"): p for p in providers}
|
||||
|
||||
def _provider_button(p):
|
||||
count = p.get("total_models", len(p.get("models", [])))
|
||||
label = f"{p['name']} ({count})"
|
||||
if p.get("is_current"):
|
||||
label = f"✓ {label}"
|
||||
return InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
|
||||
|
||||
buttons: list = []
|
||||
if group_providers is not None:
|
||||
for row in group_providers([p.get("slug") for p in providers]):
|
||||
if row["kind"] == "group":
|
||||
members = [by_slug[m] for m in row["members"] if m in by_slug]
|
||||
count = sum(
|
||||
m.get("total_models", len(m.get("models", []))) for m in members
|
||||
)
|
||||
label = f"{row['label']} ▸ ({count})"
|
||||
if any(m.get("is_current") for m in members):
|
||||
label = f"✓ {label}"
|
||||
buttons.append(
|
||||
InlineKeyboardButton(label, callback_data=f"mpg:{row['group_id']}")
|
||||
)
|
||||
else:
|
||||
p = by_slug.get(row["slug"])
|
||||
if p is not None:
|
||||
buttons.append(_provider_button(p))
|
||||
else:
|
||||
for p in providers:
|
||||
buttons.append(_provider_button(p))
|
||||
|
||||
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
|
||||
rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
|
||||
return InlineKeyboardMarkup(rows)
|
||||
|
||||
def _build_model_keyboard(self, models: list, page: int) -> tuple:
|
||||
"""Build paginated model buttons. Returns (keyboard, page_info_text)."""
|
||||
page_size = self._MODEL_PAGE_SIZE
|
||||
@@ -3125,23 +3043,10 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# Clean up state
|
||||
self._model_picker_state.pop(chat_id, None)
|
||||
|
||||
elif data.startswith("mpg:"):
|
||||
# --- Provider group selected: show member providers ---
|
||||
group_id = data[4:]
|
||||
try:
|
||||
from hermes_cli.models import PROVIDER_GROUPS
|
||||
_label, _desc, member_slugs = PROVIDER_GROUPS.get(group_id, ("", "", []))
|
||||
except Exception:
|
||||
_label, member_slugs = "", []
|
||||
|
||||
by_slug = {p["slug"]: p for p in state["providers"]}
|
||||
members = [by_slug[m] for m in member_slugs if m in by_slug]
|
||||
if not members:
|
||||
await query.answer(text="Group not found.")
|
||||
return
|
||||
|
||||
elif data == "mb":
|
||||
# --- Back to provider list ---
|
||||
buttons = []
|
||||
for p in members:
|
||||
for p in state["providers"]:
|
||||
count = p.get("total_models", len(p.get("models", [])))
|
||||
label = f"{p['name']} ({count})"
|
||||
if p.get("is_current"):
|
||||
@@ -3149,30 +3054,11 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
buttons.append(
|
||||
InlineKeyboardButton(label, callback_data=f"mp:{p['slug']}")
|
||||
)
|
||||
|
||||
rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)]
|
||||
rows.append([
|
||||
InlineKeyboardButton("◀ Back", callback_data="mb"),
|
||||
InlineKeyboardButton("✗ Cancel", callback_data="mx"),
|
||||
])
|
||||
rows.append([InlineKeyboardButton("✗ Cancel", callback_data="mx")])
|
||||
keyboard = InlineKeyboardMarkup(rows)
|
||||
|
||||
await query.edit_message_text(
|
||||
text=self.format_message(
|
||||
(
|
||||
f"⚙ *Model Configuration*\n\n"
|
||||
f"Provider family: *{_label or group_id}*\n\n"
|
||||
f"Select a provider:"
|
||||
)
|
||||
),
|
||||
parse_mode=ParseMode.MARKDOWN_V2,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
await query.answer()
|
||||
|
||||
elif data == "mb":
|
||||
# --- Back to provider list (folds groups) ---
|
||||
keyboard = self._build_provider_keyboard(state["providers"])
|
||||
|
||||
try:
|
||||
provider_label = get_label(state["current_provider"])
|
||||
except Exception:
|
||||
@@ -3221,7 +3107,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
query_user_name = getattr(query.from_user, "first_name", None)
|
||||
|
||||
# --- Model picker callbacks ---
|
||||
if data.startswith(("mp:", "mpg:", "mm:", "mb", "mx", "mg:")):
|
||||
if data.startswith(("mp:", "mm:", "mb", "mx", "mg:")):
|
||||
chat_id = str(query.message.chat_id) if query.message else None
|
||||
if chat_id:
|
||||
await self._handle_model_picker_callback(query, data, chat_id)
|
||||
|
||||
+6
-115
@@ -1180,48 +1180,12 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
default=False,
|
||||
)
|
||||
|
||||
# Text debounce batching (mirrors Telegram adapter pattern).
|
||||
# iLink delivers messages individually, so rapid multi-message
|
||||
# bursts (forwarded batches, paste-splits) each trigger a
|
||||
# separate agent invocation. Default 3s delay / 5s split delay
|
||||
# are tuned for iLink's typical delivery cadence. Tunable via
|
||||
# config.yaml under
|
||||
# ``gateway.platforms.weixin.extra.text_batch_delay_seconds`` /
|
||||
# ``text_batch_split_delay_seconds``.
|
||||
self._text_batch_delay_seconds = self._coerce_float_extra(
|
||||
"text_batch_delay_seconds", 3.0
|
||||
)
|
||||
self._text_batch_split_delay_seconds = self._coerce_float_extra(
|
||||
"text_batch_split_delay_seconds", 5.0
|
||||
)
|
||||
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
||||
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
||||
|
||||
if self._account_id and not self._token:
|
||||
persisted = load_weixin_account(hermes_home, self._account_id)
|
||||
if persisted:
|
||||
self._token = str(persisted.get("token") or "").strip()
|
||||
self._base_url = str(persisted.get("base_url") or self._base_url).strip().rstrip("/")
|
||||
|
||||
def _coerce_float_extra(self, key: str, default: float) -> float:
|
||||
"""Read a float from ``config.extra``, guarding against bad/non-finite values.
|
||||
|
||||
The result is fed directly to ``asyncio.sleep()``, so NaN/Inf and
|
||||
unparseable values fall back to ``default``.
|
||||
"""
|
||||
import math
|
||||
|
||||
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
|
||||
if value is None:
|
||||
return float(default)
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
if not math.isfinite(parsed) or parsed < 0:
|
||||
return float(default)
|
||||
return parsed
|
||||
|
||||
@staticmethod
|
||||
def _coerce_list(value: Any) -> List[str]:
|
||||
if value is None:
|
||||
@@ -1283,11 +1247,6 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
async def disconnect(self) -> None:
|
||||
_LIVE_ADAPTERS.pop(self._token, None)
|
||||
self._running = False
|
||||
for task in self._pending_text_batch_tasks.values():
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
self._pending_text_batches.clear()
|
||||
self._pending_text_batch_tasks.clear()
|
||||
if self._poll_task and not self._poll_task.done():
|
||||
self._poll_task.cancel()
|
||||
try:
|
||||
@@ -1436,10 +1395,12 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
logger.info("[%s] inbound from=%s type=%s media=%d", self.name, _safe_id(sender_id), source.chat_type, len(media_paths))
|
||||
if event.message_type == MessageType.TEXT:
|
||||
self._enqueue_text_event(event)
|
||||
else:
|
||||
await self.handle_message(event)
|
||||
await self.handle_message(event)
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""Weixin gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
def _is_dm_allowed(self, sender_id: str) -> bool:
|
||||
if self._dm_policy == "disabled":
|
||||
@@ -1448,76 +1409,6 @@ class WeixinAdapter(BasePlatformAdapter):
|
||||
return sender_id in self._allow_from
|
||||
return True
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""Weixin gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Text debounce batching
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
_SPLIT_THRESHOLD = 1800 # iLink chunks at ~2048 chars
|
||||
|
||||
def _text_batch_key(self, event: MessageEvent) -> str:
|
||||
"""Session-scoped key for text message batching."""
|
||||
from gateway.session import build_session_key
|
||||
return build_session_key(
|
||||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||||
)
|
||||
|
||||
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||||
"""Buffer a text event and reset the flush timer.
|
||||
|
||||
When users forward multiple messages or send rapid-fire texts
|
||||
via WeChat, each arrives as a separate iLink message. This
|
||||
concatenates them and waits for a short quiet period before
|
||||
dispatching the combined message.
|
||||
"""
|
||||
key = self._text_batch_key(event)
|
||||
existing = self._pending_text_batches.get(key)
|
||||
chunk_len = len(event.text or "")
|
||||
if existing is None:
|
||||
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
self._pending_text_batches[key] = event
|
||||
else:
|
||||
if event.text:
|
||||
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
|
||||
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
if event.media_urls:
|
||||
existing.media_urls.extend(event.media_urls)
|
||||
existing.media_types.extend(event.media_types)
|
||||
|
||||
prior_task = self._pending_text_batch_tasks.get(key)
|
||||
if prior_task and not prior_task.done():
|
||||
prior_task.cancel()
|
||||
self._pending_text_batch_tasks[key] = asyncio.create_task(
|
||||
self._flush_text_batch(key)
|
||||
)
|
||||
|
||||
async def _flush_text_batch(self, key: str) -> None:
|
||||
"""Wait for quiet period then dispatch aggregated text."""
|
||||
current_task = asyncio.current_task()
|
||||
try:
|
||||
pending = self._pending_text_batches.get(key)
|
||||
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
|
||||
if last_len >= self._SPLIT_THRESHOLD:
|
||||
delay = self._text_batch_split_delay_seconds
|
||||
else:
|
||||
delay = self._text_batch_delay_seconds
|
||||
await asyncio.sleep(delay)
|
||||
if self._pending_text_batch_tasks.get(key) is not current_task:
|
||||
return
|
||||
event = self._pending_text_batches.pop(key, None)
|
||||
if not event:
|
||||
return
|
||||
await self.handle_message(event)
|
||||
finally:
|
||||
if self._pending_text_batch_tasks.get(key) is current_task:
|
||||
self._pending_text_batch_tasks.pop(key, None)
|
||||
|
||||
async def _collect_media(self, item: Dict[str, Any], media_paths: List[str], media_types: List[str]) -> None:
|
||||
item_type = item.get("type")
|
||||
if item_type == ITEM_IMAGE:
|
||||
|
||||
@@ -278,43 +278,6 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
# notification before the normal "✓ whatsapp disconnected" fires.
|
||||
self._shutting_down: bool = False
|
||||
|
||||
# Text debounce batching (mirrors Telegram adapter pattern).
|
||||
# WhatsApp often delivers multiple messages in rapid succession
|
||||
# (e.g. forwarded batches, paste-splits) — without debounce each
|
||||
# message triggers a separate agent invocation, wasting tokens and
|
||||
# flooding the user with reply fragments. Default 5s delay /
|
||||
# 10s split delay are conservative for WhatsApp's delivery cadence.
|
||||
# Tunable via config.yaml under
|
||||
# ``gateway.platforms.whatsapp.extra.text_batch_delay_seconds`` /
|
||||
# ``text_batch_split_delay_seconds``.
|
||||
self._text_batch_delay_seconds = self._coerce_float_extra(
|
||||
"text_batch_delay_seconds", 5.0
|
||||
)
|
||||
self._text_batch_split_delay_seconds = self._coerce_float_extra(
|
||||
"text_batch_split_delay_seconds", 10.0
|
||||
)
|
||||
self._pending_text_batches: Dict[str, MessageEvent] = {}
|
||||
self._pending_text_batch_tasks: Dict[str, asyncio.Task] = {}
|
||||
|
||||
def _coerce_float_extra(self, key: str, default: float) -> float:
|
||||
"""Read a float from ``config.extra``, guarding against bad/non-finite values.
|
||||
|
||||
The result is fed directly to ``asyncio.sleep()``, so NaN/Inf and
|
||||
unparseable values fall back to ``default``.
|
||||
"""
|
||||
import math
|
||||
|
||||
value = self.config.extra.get(key) if getattr(self.config, "extra", None) else None
|
||||
if value is None:
|
||||
return float(default)
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return float(default)
|
||||
if not math.isfinite(parsed) or parsed < 0:
|
||||
return float(default)
|
||||
return parsed
|
||||
|
||||
def _effective_reply_prefix(self) -> str:
|
||||
"""Return the prefix the Node bridge will add in self-chat mode."""
|
||||
whatsapp_mode = os.getenv("WHATSAPP_MODE", "self-chat")
|
||||
@@ -1176,10 +1139,7 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
for msg_data in messages:
|
||||
event = await self._build_message_event(msg_data)
|
||||
if event:
|
||||
if event.message_type == MessageType.TEXT:
|
||||
self._enqueue_text_event(event)
|
||||
else:
|
||||
await self.handle_message(event)
|
||||
await self.handle_message(event)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
@@ -1191,67 +1151,7 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
await asyncio.sleep(5)
|
||||
|
||||
await asyncio.sleep(1) # Poll interval
|
||||
|
||||
# ── Text debounce batching ──────────────────────────────────────
|
||||
|
||||
_SPLIT_THRESHOLD = 6000 # WhatsApp supports ~65K chars; generous threshold
|
||||
|
||||
def _text_batch_key(self, event: MessageEvent) -> str:
|
||||
"""Session-scoped key for text message batching."""
|
||||
from gateway.session import build_session_key
|
||||
return build_session_key(
|
||||
event.source,
|
||||
group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True),
|
||||
thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False),
|
||||
)
|
||||
|
||||
def _enqueue_text_event(self, event: MessageEvent) -> None:
|
||||
"""Buffer a text event and reset the flush timer.
|
||||
|
||||
When WhatsApp delivers rapid-fire messages (e.g. forwarded
|
||||
batches), this concatenates them and waits for a short quiet
|
||||
period before dispatching the combined message.
|
||||
"""
|
||||
key = self._text_batch_key(event)
|
||||
existing = self._pending_text_batches.get(key)
|
||||
chunk_len = len(event.text or "")
|
||||
if existing is None:
|
||||
event._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
self._pending_text_batches[key] = event
|
||||
else:
|
||||
if event.text:
|
||||
existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text
|
||||
existing._last_chunk_len = chunk_len # type: ignore[attr-defined]
|
||||
if event.media_urls:
|
||||
existing.media_urls.extend(event.media_urls)
|
||||
existing.media_types.extend(event.media_types)
|
||||
|
||||
prior_task = self._pending_text_batch_tasks.get(key)
|
||||
if prior_task and not prior_task.done():
|
||||
prior_task.cancel()
|
||||
self._pending_text_batch_tasks[key] = asyncio.create_task(
|
||||
self._flush_text_batch(key)
|
||||
)
|
||||
|
||||
async def _flush_text_batch(self, key: str) -> None:
|
||||
"""Wait for quiet period then dispatch aggregated text."""
|
||||
current_task = asyncio.current_task()
|
||||
try:
|
||||
pending = self._pending_text_batches.get(key)
|
||||
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
|
||||
if last_len >= self._SPLIT_THRESHOLD:
|
||||
delay = self._text_batch_split_delay_seconds
|
||||
else:
|
||||
delay = self._text_batch_delay_seconds
|
||||
await asyncio.sleep(delay)
|
||||
event = self._pending_text_batches.pop(key, None)
|
||||
if not event:
|
||||
return
|
||||
await self.handle_message(event)
|
||||
finally:
|
||||
if self._pending_text_batch_tasks.get(key) is current_task:
|
||||
self._pending_text_batch_tasks.pop(key, None)
|
||||
|
||||
|
||||
async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEvent]:
|
||||
"""Build a MessageEvent from bridge message data, downloading images to cache."""
|
||||
try:
|
||||
|
||||
+138
-647
File diff suppressed because it is too large
Load Diff
@@ -1309,58 +1309,6 @@ class SessionStore:
|
||||
logger.debug("Could not load messages from DB: %s", e)
|
||||
return []
|
||||
|
||||
def rewind_session(self, session_id: str, n: int = 1) -> Optional[Dict[str, Any]]:
|
||||
"""Back up ``n`` user turns via soft-delete, keeping rows for audit.
|
||||
|
||||
Unlike :meth:`rewrite_transcript` (a hard replace used by /retry),
|
||||
this flips the truncated rows to ``active=0`` in state.db so they
|
||||
survive for audit and stay hidden from re-prompts and search. Mirrors
|
||||
the CLI/TUI ``/undo [N]`` behavior via ``SessionDB.rewind_to_message``.
|
||||
|
||||
Returns a dict ``{"rewound_count", "turns_undone", "target_text"}`` on
|
||||
success, or ``None`` if there's no DB or no user message to back up to.
|
||||
``n`` clamps to the oldest user turn when it exceeds the turn count.
|
||||
"""
|
||||
if not self._db:
|
||||
return None
|
||||
if n < 1:
|
||||
n = 1
|
||||
try:
|
||||
recents = self._db.list_recent_user_messages(session_id, limit=max(n, 10))
|
||||
except Exception as e:
|
||||
logger.debug("rewind_session: failed to list user messages: %s", e)
|
||||
return None
|
||||
if not recents:
|
||||
return None
|
||||
target_idx = min(n - 1, len(recents) - 1)
|
||||
target_id = recents[target_idx]["id"]
|
||||
try:
|
||||
result = self._db.rewind_to_message(session_id, target_id)
|
||||
except ValueError as e:
|
||||
logger.debug("rewind_session: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug("rewind_session: rewind_to_message failed: %s", e)
|
||||
return None
|
||||
target_msg = result.get("target_message") or {}
|
||||
content = target_msg.get("content") or ""
|
||||
if isinstance(content, list):
|
||||
parts = [
|
||||
p.get("text", "")
|
||||
for p in content
|
||||
if isinstance(p, dict) and p.get("type") == "text"
|
||||
]
|
||||
target_text = "\n".join(t for t in parts if t)
|
||||
elif isinstance(content, str):
|
||||
target_text = content
|
||||
else:
|
||||
target_text = ""
|
||||
return {
|
||||
"rewound_count": result.get("rewound_count", 0),
|
||||
"turns_undone": target_idx + 1,
|
||||
"target_text": target_text,
|
||||
}
|
||||
|
||||
|
||||
def build_session_context(
|
||||
source: SessionSource,
|
||||
|
||||
+31
-32
@@ -6126,56 +6126,55 @@ def _prompt_model_selection(
|
||||
_DIM = "\033[2m"
|
||||
_RESET = "\033[0m"
|
||||
|
||||
# Try arrow-key menu first, fall back to number input.
|
||||
# Uses the shared curses radiolist (ESC/arrow-key handling that works
|
||||
# across terminals, incl. those that emit raw escape sequences) instead
|
||||
# of simple_term_menu, which conflicts with /dev/tty and left ESC/arrow
|
||||
# keys unreliable in the setup model picker.
|
||||
# Try arrow-key menu first, fall back to number input
|
||||
try:
|
||||
from hermes_cli.curses_ui import curses_radiolist
|
||||
from simple_term_menu import TerminalMenu
|
||||
|
||||
choices = [_label(mid) for mid in ordered]
|
||||
choices.append("Enter custom model name")
|
||||
choices.append("Skip (keep current)")
|
||||
choices = [f" {_label(mid)}" for mid in ordered]
|
||||
choices.append(" Enter custom model name")
|
||||
choices.append(" Skip (keep current)")
|
||||
|
||||
_upgrade_url = (portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
unavailable_footer = unavailable_message.strip()
|
||||
if not unavailable_footer and _unavailable:
|
||||
unavailable_footer = f"Upgrade at {_upgrade_url} for paid models"
|
||||
|
||||
# The pricing column header (and any unavailable-models block) is shown
|
||||
# as a multi-line description above the list so it survives the curses
|
||||
# screen clear. menu_title already embeds the aligned price header.
|
||||
desc_lines: list[str] = []
|
||||
if has_pricing:
|
||||
# menu_title is "Select default model:\n<pad><header> /Mtok"
|
||||
# Keep only the header portion for the description.
|
||||
header_part = menu_title.split("\n", 1)
|
||||
if len(header_part) > 1:
|
||||
desc_lines.extend(header_part[1].splitlines())
|
||||
# Print the unavailable block BEFORE the menu via regular print().
|
||||
# simple_term_menu pads title lines to terminal width (causes wrapping),
|
||||
# so we keep the title minimal and use stdout for the static block.
|
||||
# clear_screen=False means our printed output stays visible above.
|
||||
if _unavailable:
|
||||
print(menu_title)
|
||||
print()
|
||||
for mid in _unavailable:
|
||||
desc_lines.append(f" {_label(mid)}")
|
||||
desc_lines.append(f" ── {unavailable_footer} ──")
|
||||
description = "\n".join(desc_lines) if desc_lines else None
|
||||
print(f"{_DIM} {_label(mid)}{_RESET}")
|
||||
print()
|
||||
print(f"{_DIM} ── {unavailable_footer} ──{_RESET}")
|
||||
print()
|
||||
effective_title = "Available free models:"
|
||||
else:
|
||||
effective_title = menu_title
|
||||
|
||||
idx = curses_radiolist(
|
||||
"Select default model:",
|
||||
menu = TerminalMenu(
|
||||
choices,
|
||||
selected=default_idx,
|
||||
cancel_returns=-1,
|
||||
description=description,
|
||||
cursor_index=default_idx,
|
||||
menu_cursor="-> ",
|
||||
menu_cursor_style=("fg_green", "bold"),
|
||||
menu_highlight_style=("fg_green",),
|
||||
cycle_cursor=True,
|
||||
clear_screen=False,
|
||||
title=effective_title,
|
||||
)
|
||||
if idx < 0:
|
||||
idx = menu.show()
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
flush_stdin()
|
||||
if idx is None:
|
||||
return None
|
||||
print()
|
||||
if idx < len(ordered):
|
||||
return ordered[idx]
|
||||
elif idx == len(ordered):
|
||||
try:
|
||||
custom = input("Enter model name: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
custom = input("Enter model name: ").strip()
|
||||
return custom if custom else None
|
||||
return None
|
||||
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
|
||||
|
||||
+8
-14
@@ -12,16 +12,14 @@ import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
# rich and prompt_toolkit are imported lazily (inside the functions that use
|
||||
# them) rather than at module level. Importing this module is on the TUI
|
||||
# gateway's critical startup path purely to reach the lightweight update-check
|
||||
# helpers (``prefetch_update_check``); pulling rich.console + prompt_toolkit
|
||||
# eagerly added ~50ms of wasted imports before ``gateway.ready`` could fire.
|
||||
# Keep the type-only reference available to checkers without the runtime cost.
|
||||
if TYPE_CHECKING:
|
||||
from rich.console import Console
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
|
||||
from prompt_toolkit import print_formatted_text as _pt_print
|
||||
from prompt_toolkit.formatted_text import ANSI as _PT_ANSI
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,8 +36,6 @@ _RST = "\033[0m"
|
||||
|
||||
def cprint(text: str):
|
||||
"""Print ANSI-colored text through prompt_toolkit's renderer."""
|
||||
from prompt_toolkit import print_formatted_text as _pt_print
|
||||
from prompt_toolkit.formatted_text import ANSI as _PT_ANSI
|
||||
_pt_print(_PT_ANSI(text))
|
||||
|
||||
|
||||
@@ -475,7 +471,7 @@ def _display_toolset_name(toolset_name: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def build_welcome_banner(console: "Console", model: str, cwd: str,
|
||||
def build_welcome_banner(console: Console, model: str, cwd: str,
|
||||
tools: List[dict] = None,
|
||||
enabled_toolsets: List[str] = None,
|
||||
session_id: str = None,
|
||||
@@ -494,8 +490,6 @@ def build_welcome_banner(console: "Console", model: str, cwd: str,
|
||||
context_length: Model's context window size in tokens.
|
||||
"""
|
||||
from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
|
||||
from rich.panel import Panel
|
||||
from rich.table import Table
|
||||
if get_toolset_for_tool is None:
|
||||
from model_tools import get_toolset_for_tool
|
||||
|
||||
|
||||
@@ -78,8 +78,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("save", "Save the current conversation", "Session",
|
||||
cli_only=True),
|
||||
CommandDef("retry", "Retry the last message (resend to agent)", "Session"),
|
||||
CommandDef("undo", "Back up N user turns and re-prompt (default 1)", "Session",
|
||||
args_hint="[N]"),
|
||||
CommandDef("undo", "Remove the last user/assistant exchange", "Session"),
|
||||
CommandDef("title", "Set a title for the current session", "Session",
|
||||
args_hint="[name]"),
|
||||
CommandDef("handoff", "Hand off this session to a messaging platform (Telegram, Discord, etc.)", "Session",
|
||||
|
||||
+10
-153
@@ -286,22 +286,9 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
|
||||
Resolution order:
|
||||
1. Stamped ``~/.hermes/.install_method`` file (written by installers)
|
||||
2. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)
|
||||
3. .git directory presence -> 'git'
|
||||
4. Fallback -> 'pip'
|
||||
|
||||
Note: running inside a container is NOT treated as "docker" on its own.
|
||||
The two supported install paths both self-identify via the
|
||||
``.install_method`` stamp (caught by step 1), so neither relies on
|
||||
container detection here:
|
||||
- the curl installer (scripts/install.sh, the README/website install
|
||||
command) git-clones the repo and stamps ``git``;
|
||||
- the published ``nousresearch/hermes-agent`` image stamps ``docker``
|
||||
at boot via ``docker/stage2-hook.sh``.
|
||||
An unsupported manual install dropped into a container (no stamp) was
|
||||
wrongly classified as the published image by bare container detection,
|
||||
so ``hermes update`` bailed with "doesn't apply inside the Docker
|
||||
container". Without that fallback such installs fall through to the
|
||||
``.git``/pip checks and behave like any off-path install. See issue #34397.
|
||||
3. Container detection (/.dockerenv, /run/.containerenv, cgroup)
|
||||
4. .git directory presence -> 'git'
|
||||
5. Fallback -> 'pip'
|
||||
"""
|
||||
stamp = get_hermes_home() / ".install_method"
|
||||
try:
|
||||
@@ -313,6 +300,9 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
|
||||
managed = get_managed_system()
|
||||
if managed:
|
||||
return managed.lower().replace(" ", "-")
|
||||
from hermes_constants import is_container
|
||||
if is_container():
|
||||
return "docker"
|
||||
if project_root is None:
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
if (project_root / ".git").is_dir():
|
||||
@@ -330,34 +320,6 @@ def stamp_install_method(method: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def is_uv_tool_install() -> bool:
|
||||
"""Return True when the *running* Hermes lives in a ``uv tool`` layout.
|
||||
|
||||
``uv tool install hermes-agent`` places the install at
|
||||
``.../uv/tools/hermes-agent/...`` (default ``~/.local/share/uv/tools``,
|
||||
or ``$UV_TOOL_DIR/...``). Such installs live outside any virtualenv, so
|
||||
``uv pip install`` fails with ``No virtual environment found`` and the
|
||||
update path must use ``uv tool upgrade`` instead.
|
||||
|
||||
Detection is intentionally restricted to properties of the running
|
||||
interpreter (``sys.prefix`` / ``sys.executable``). We deliberately do
|
||||
NOT consult ``uv tool list``: it would also return True when
|
||||
``hermes-agent`` happens to be uv-tool-installed on the machine while
|
||||
the *active* Hermes is a regular pip/venv install, causing
|
||||
``hermes update`` to upgrade the wrong copy. It would also block on a
|
||||
subprocess call (~seconds) just to compute a recommendation string.
|
||||
"""
|
||||
def _has_uv_tool_marker(path: str) -> bool:
|
||||
norm = os.path.normpath(path).replace(os.sep, "/").lower()
|
||||
return "/uv/tools/hermes-agent/" in norm + "/"
|
||||
|
||||
if _has_uv_tool_marker(sys.prefix):
|
||||
return True
|
||||
if _has_uv_tool_marker(sys.executable or ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def recommended_update_command_for_method(method: str) -> str:
|
||||
"""Return the update command or guidance for a given install method."""
|
||||
if method == "nixos":
|
||||
@@ -367,10 +329,9 @@ def recommended_update_command_for_method(method: str) -> str:
|
||||
if method == "docker":
|
||||
return "docker pull nousresearch/hermes-agent:latest"
|
||||
if method == "pip":
|
||||
if is_uv_tool_install():
|
||||
return "uv tool upgrade hermes-agent"
|
||||
import shutil
|
||||
if shutil.which("uv"):
|
||||
uv = shutil.which("uv")
|
||||
if uv:
|
||||
return "uv pip install --upgrade hermes-agent"
|
||||
return "pip install --upgrade hermes-agent"
|
||||
return "hermes update"
|
||||
@@ -539,65 +500,6 @@ def get_project_root() -> Path:
|
||||
"""Get the project installation directory."""
|
||||
return Path(__file__).parent.parent.resolve()
|
||||
|
||||
def _resolve_hermes_uid_gid() -> tuple[Optional[int], Optional[int]]:
|
||||
"""Read the HERMES_UID / HERMES_GID env vars set by Docker deployments.
|
||||
|
||||
Docker containers running Hermes commonly set these to map the in-container
|
||||
user to a host user so volume-mounted state files end up with the right
|
||||
ownership. The entrypoint chowns the top-level HERMES_HOME once, but
|
||||
subdirectories created at runtime by ``ensure_hermes_home()`` (especially
|
||||
for profile namespaces under ``profiles/<name>/``) need the same chown
|
||||
or they land as ``root:root`` and block subsequent uid-mapped workers
|
||||
with ``PermissionError [Errno 13]``. See #34107.
|
||||
|
||||
Returns ``(uid, gid)`` parsed from the env vars, or ``(None, None)``
|
||||
when either is missing/invalid. Returns ``(None, None)`` on Windows
|
||||
too (where chown is a no-op anyway).
|
||||
"""
|
||||
if sys.platform == "win32":
|
||||
return None, None
|
||||
uid_str = os.environ.get("HERMES_UID", "").strip()
|
||||
gid_str = os.environ.get("HERMES_GID", "").strip()
|
||||
try:
|
||||
uid = int(uid_str) if uid_str else None
|
||||
except ValueError:
|
||||
uid = None
|
||||
try:
|
||||
gid = int(gid_str) if gid_str else None
|
||||
except ValueError:
|
||||
gid = None
|
||||
return uid, gid
|
||||
|
||||
|
||||
def _chown_to_hermes_uid(path) -> None:
|
||||
"""Chown ``path`` to ``HERMES_UID:HERMES_GID`` if those env vars are set.
|
||||
|
||||
No-op when:
|
||||
- Either env var is unset/invalid
|
||||
- The current process isn't root (chown will EPERM — silently ignored)
|
||||
- On Windows (chown semantics don't apply)
|
||||
|
||||
Used by :func:`_secure_dir` to keep ownership consistent across all
|
||||
directories created by :func:`ensure_hermes_home` on Docker deployments.
|
||||
See #34107.
|
||||
"""
|
||||
uid, gid = _resolve_hermes_uid_gid()
|
||||
if uid is None and gid is None:
|
||||
return
|
||||
try:
|
||||
# os.chown with -1 means "don't change" for that field.
|
||||
os.chown(
|
||||
path,
|
||||
uid if uid is not None else -1,
|
||||
gid if gid is not None else -1,
|
||||
)
|
||||
except (OSError, AttributeError, NotImplementedError):
|
||||
# OSError covers EPERM (not running as root) and ENOENT (race),
|
||||
# both of which are non-fatal — the dir is still created and
|
||||
# the entrypoint's startup chown -R will fix it on next restart.
|
||||
pass
|
||||
|
||||
|
||||
def _secure_dir(path):
|
||||
"""Set directory to owner-only access (0700 by default). No-op on Windows.
|
||||
|
||||
@@ -610,11 +512,6 @@ def _secure_dir(path):
|
||||
caddy, etc.) needs to traverse HERMES_HOME to reach a served subdirectory.
|
||||
The execute-only bit on a directory permits cd-through without exposing
|
||||
directory listings.
|
||||
|
||||
Also applies ``HERMES_UID``/``HERMES_GID``-based ownership when those env
|
||||
vars are set (#34107 — Docker deployments need this so profile subdirs
|
||||
created at runtime by kanban workers don't land as root:root and block
|
||||
subsequent uid-mapped workers).
|
||||
"""
|
||||
if is_managed():
|
||||
return
|
||||
@@ -627,7 +524,6 @@ def _secure_dir(path):
|
||||
os.chmod(path, mode)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
_chown_to_hermes_uid(path)
|
||||
|
||||
|
||||
def _is_container() -> bool:
|
||||
@@ -1288,11 +1184,6 @@ DEFAULT_CONFIG = {
|
||||
# Mirrors `hermes -c` muscle memory. Default off so existing
|
||||
# users aren't surprised. HERMES_TUI_RESUME=<id> always wins.
|
||||
"tui_auto_resume_recent": False,
|
||||
# When true (default), `hermes --tui` drops a one-time hint
|
||||
# ("subagents working · /agents to watch live") the first time a turn
|
||||
# starts delegating, nudging the user toward the live spawn-tree
|
||||
# dashboard. Set false to suppress the hint.
|
||||
"tui_agents_nudge": True,
|
||||
"bell_on_complete": False,
|
||||
"show_reasoning": False,
|
||||
"streaming": False,
|
||||
@@ -1312,13 +1203,6 @@ DEFAULT_CONFIG = {
|
||||
# class of over-claim that otherwise forces users to run
|
||||
# `git status` to verify edits landed. Set false to suppress.
|
||||
"file_mutation_verifier": True,
|
||||
# Turn-completion explainer. When true (default), the agent appends a
|
||||
# one-line explanation to its final response whenever a turn ends
|
||||
# abnormally with no usable reply — empty content after retries, a
|
||||
# partial/truncated stream, a still-pending tool result, or an
|
||||
# iteration/budget limit. Replaces the bare "(empty)" sentinel so the
|
||||
# failure isn't silent from the UI's perspective. Set false to suppress.
|
||||
"turn_completion_explainer": True,
|
||||
"show_cost": False, # Show $ cost in the status bar (off by default)
|
||||
"skin": "default",
|
||||
# UI language for static user-facing messages (approval prompts, a
|
||||
@@ -1648,17 +1532,6 @@ DEFAULT_CONFIG = {
|
||||
# Archive a skill (move to skills/.archive/) after this many days
|
||||
# without use. Archived skills are recoverable — no auto-deletion.
|
||||
"archive_after_days": 90,
|
||||
# Also prune (archive) bundled built-in skills after the inactivity
|
||||
# period, not just agent-created ones. ON by default. Built-ins are
|
||||
# normally restored on every `hermes update`, so pruning them only
|
||||
# sticks because a suppression list tells the re-seeder to leave them
|
||||
# archived. Hub-installed skills are NEVER pruned here — they have an
|
||||
# external upstream owner. Built-ins accrue usage telemetry and their
|
||||
# inactivity clock starts the first time the curator sees them, so a
|
||||
# long-unused built-in is archived only after archive_after_days of
|
||||
# genuine non-use (never a mass-prune on the first run). Set to false
|
||||
# to keep all bundled built-ins permanently.
|
||||
"prune_builtins": True,
|
||||
# Pre-run backup: before every real curator pass (dry-run is
|
||||
# skipped), snapshot ~/.hermes/skills/ into
|
||||
# ~/.hermes/skills/.curator_backups/<utc-iso>/skills.tar.gz so the
|
||||
@@ -1977,7 +1850,7 @@ DEFAULT_CONFIG = {
|
||||
# Disk cache TTL in hours. Beyond this, the CLI refetches on the
|
||||
# next /model or `hermes model` invocation; network failures
|
||||
# silently fall back to the stale cache.
|
||||
"ttl_hours": 1,
|
||||
"ttl_hours": 24,
|
||||
# Optional per-provider override URLs for third parties that want
|
||||
# to self-host their own curation list using the same schema.
|
||||
# Example:
|
||||
@@ -2225,7 +2098,7 @@ DEFAULT_CONFIG = {
|
||||
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 25,
|
||||
"_config_version": 24,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -4419,22 +4292,6 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
|
||||
f"{', '.join(added_aux)}"
|
||||
)
|
||||
|
||||
# ── Version 24 → 25: lower model_catalog TTL 24h → 1h ──
|
||||
# The model picker now refreshes its curated list hourly so freshly
|
||||
# published model-catalog.json deploys reach users without a day-long
|
||||
# stale window. Only rewrite the OLD default (24) — never clobber a
|
||||
# value the user deliberately customized.
|
||||
if current_ver < 25:
|
||||
config = read_raw_config()
|
||||
raw_mc = config.get("model_catalog")
|
||||
if isinstance(raw_mc, dict) and raw_mc.get("ttl_hours") == 24:
|
||||
raw_mc["ttl_hours"] = 1
|
||||
config["model_catalog"] = raw_mc
|
||||
save_config(config)
|
||||
results["config_added"].append("model_catalog.ttl_hours 24→1")
|
||||
if not quiet:
|
||||
print(" ✓ Lowered model_catalog.ttl_hours to 1 (hourly picker refresh)")
|
||||
|
||||
if current_ver < latest_ver and not quiet:
|
||||
print(f"Config version: {current_ver} → {latest_ver}")
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal, Sequence
|
||||
from typing import Literal
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,7 +57,6 @@ def reconcile_profile_gateways(
|
||||
hermes_home: Path,
|
||||
scandir: Path,
|
||||
dry_run: bool = False,
|
||||
container_argv: Sequence[str] | None = None,
|
||||
) -> list[ReconcileAction]:
|
||||
"""Recreate s6 service registrations for every persistent profile.
|
||||
|
||||
@@ -83,8 +82,6 @@ def reconcile_profile_gateways(
|
||||
directories are created at ``<scandir>/gateway-<profile>/``.
|
||||
dry_run: When True, walk and return the action list without
|
||||
touching the filesystem. For tests and `--dry-run` debug.
|
||||
container_argv: Optional container PID 1 argv override. Production
|
||||
reads ``/proc/1/cmdline``; tests inject it directly.
|
||||
|
||||
Returns:
|
||||
One :class:`ReconcileAction` per profile, in this order:
|
||||
@@ -96,15 +93,8 @@ def reconcile_profile_gateways(
|
||||
# populated the root profile dir. The slot exists so
|
||||
# ``hermes gateway start`` (no ``-p``) has somewhere to land;
|
||||
# auto-up only when the prior state was "running" (same rule as
|
||||
# named profiles). If the container was launched with the legacy
|
||||
# `gateway run` command and no state exists yet, seed that intent
|
||||
# as `running` so the s6 reconciler preserves the pre-s6 behavior.
|
||||
legacy_default_state = _maybe_migrate_legacy_gateway_run_state(
|
||||
hermes_home,
|
||||
container_argv=container_argv,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
default_prior_state = legacy_default_state or _read_prior_state(hermes_home)
|
||||
# named profiles).
|
||||
default_prior_state = _read_prior_state(hermes_home)
|
||||
default_should_start = default_prior_state in _AUTOSTART_STATES
|
||||
if not dry_run:
|
||||
_cleanup_stale_runtime_files(hermes_home)
|
||||
@@ -157,66 +147,6 @@ def reconcile_profile_gateways(
|
||||
return actions
|
||||
|
||||
|
||||
def _maybe_migrate_legacy_gateway_run_state(
|
||||
hermes_home: Path,
|
||||
*,
|
||||
container_argv: Sequence[str] | None,
|
||||
dry_run: bool,
|
||||
) -> str | None:
|
||||
"""Seed root gateway_state for pre-s6 `gateway run` containers.
|
||||
|
||||
The tini image let Docker users run the gateway as the container
|
||||
command (`docker run ... gateway run`). After the s6 migration,
|
||||
profile gateways are restored from persisted gateway_state.json; a
|
||||
legacy container with no state file would therefore register the
|
||||
default service down and never start. Only synthesize state when no
|
||||
root gateway_state.json exists so explicit stopped/failed states keep
|
||||
winning across restarts.
|
||||
"""
|
||||
state_file = hermes_home / "gateway_state.json"
|
||||
if state_file.exists():
|
||||
return None
|
||||
|
||||
if os.environ.get("HERMES_GATEWAY_NO_SUPERVISE", "").lower() in ("1", "true", "yes"):
|
||||
return None
|
||||
|
||||
argv = tuple(container_argv) if container_argv is not None else _read_container_argv()
|
||||
if not _is_legacy_gateway_run_request(argv):
|
||||
return None
|
||||
|
||||
if not dry_run:
|
||||
import time
|
||||
state_file.write_text(json.dumps({
|
||||
"gateway_state": "running",
|
||||
"timestamp": int(time.time()),
|
||||
"migrated_from": "legacy-container-cmd",
|
||||
}) + "\n")
|
||||
return "running"
|
||||
|
||||
|
||||
def _read_container_argv() -> tuple[str, ...]:
|
||||
"""Best-effort read of the container PID 1 argv."""
|
||||
try:
|
||||
raw = Path("/proc/1/cmdline").read_bytes()
|
||||
except OSError:
|
||||
return ()
|
||||
return tuple(part.decode("utf-8", "replace") for part in raw.split(b"\0") if part)
|
||||
|
||||
|
||||
def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool:
|
||||
"""Return True for Docker commands equivalent to `gateway run`."""
|
||||
args = list(argv)
|
||||
if args and Path(args[0]).name == "init":
|
||||
args = args[1:]
|
||||
if args and args[0].endswith("main-wrapper.sh"):
|
||||
args = args[1:]
|
||||
if args and Path(args[0]).name == "hermes":
|
||||
args = args[1:]
|
||||
if "--no-supervise" in args:
|
||||
return False
|
||||
return len(args) >= 2 and args[0] == "gateway" and args[1] == "run"
|
||||
|
||||
|
||||
def _read_prior_state(profile_dir: Path) -> str | None:
|
||||
"""Read gateway_state.json's ``gateway_state`` field, or None if
|
||||
missing or unparseable. Unparseable counts as "no prior state" so
|
||||
|
||||
@@ -6,7 +6,6 @@ pause/resume/run/remove, status, and tick.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Optional
|
||||
@@ -16,24 +15,6 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
# Patterns that indicate a cron job targets the gateway lifecycle.
|
||||
# Matches commands that restart/stop the gateway or its service manager.
|
||||
# Deliberately specific — a bare "gateway ... restart" catch-all would block
|
||||
# legitimate prompts that merely mention an unrelated gateway (e.g. "summarize
|
||||
# the API gateway logs and report restart events").
|
||||
_GATEWAY_LIFECYCLE_PATTERNS = re.compile(
|
||||
r"(?i)"
|
||||
r"(hermes\s+gateway\s+(restart|stop|start))"
|
||||
r"|(launchctl\s+(kickstart|unload|load|stop|restart)\s+.*hermes)"
|
||||
r"|(systemctl\s+(restart|stop|start)\s+.*hermes)"
|
||||
r"|(p?kill\s+.*hermes.*gateway)"
|
||||
)
|
||||
|
||||
|
||||
def _contains_gateway_lifecycle_command(text: str) -> bool:
|
||||
"""Return True if *text* contains a gateway lifecycle command pattern."""
|
||||
return bool(_GATEWAY_LIFECYCLE_PATTERNS.search(text))
|
||||
|
||||
|
||||
def _normalize_skills(single_skill=None, skills: Optional[Iterable[str]] = None) -> Optional[List[str]]:
|
||||
if skills is None:
|
||||
@@ -185,28 +166,6 @@ def cron_status():
|
||||
|
||||
|
||||
def cron_create(args):
|
||||
# Defense: reject cron jobs that contain gateway lifecycle commands.
|
||||
# Prevents agents from scheduling their own restart/stop, which creates
|
||||
# SIGTERM-respawn loops under launchd/systemd KeepAlive (#30719).
|
||||
prompt = getattr(args, "prompt", None) or ""
|
||||
script = getattr(args, "script", None)
|
||||
combined = prompt
|
||||
if script:
|
||||
try:
|
||||
script_text = Path(script).read_text(encoding="utf-8")
|
||||
combined = f"{combined}\n{script_text}"
|
||||
except (OSError, UnicodeDecodeError):
|
||||
pass
|
||||
if _contains_gateway_lifecycle_command(combined):
|
||||
print(color(
|
||||
"Blocked: cron job contains a gateway lifecycle command "
|
||||
"(restart/stop/kill).\n"
|
||||
"This is blocked to prevent restart loops (#30719).\n"
|
||||
"Use `hermes gateway restart` from a shell outside the gateway.",
|
||||
Colors.RED,
|
||||
))
|
||||
return 1
|
||||
|
||||
result = _cron_api(
|
||||
action="create",
|
||||
schedule=args.schedule,
|
||||
|
||||
@@ -473,66 +473,6 @@ def _cmd_list_archived(args) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_usage(args) -> int:
|
||||
"""Show usage telemetry for ALL skills, with provenance.
|
||||
|
||||
Unlike `status` (curator-scoped to agent-created candidates), this lists
|
||||
every skill on disk — bundled built-ins and hub-installed included — so you
|
||||
can see how often each is actually used regardless of curation.
|
||||
"""
|
||||
import json as _json
|
||||
from tools import skill_usage
|
||||
|
||||
rows = skill_usage.usage_report()
|
||||
|
||||
prov_filter = getattr(args, "provenance", None)
|
||||
if prov_filter:
|
||||
rows = [r for r in rows if r.get("provenance") == prov_filter]
|
||||
|
||||
sort_key = getattr(args, "sort", "activity")
|
||||
if sort_key == "name":
|
||||
rows.sort(key=lambda r: r["name"])
|
||||
elif sort_key == "recent":
|
||||
# Most-recently-active first; never-active sinks to the bottom.
|
||||
rows.sort(key=lambda r: r.get("last_activity_at") or "", reverse=True)
|
||||
else: # "activity" (default): most-used first
|
||||
rows.sort(key=lambda r: r.get("activity_count", 0), reverse=True)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
print(_json.dumps(rows, indent=2, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
if not rows:
|
||||
print("curator: no skills found")
|
||||
return 0
|
||||
|
||||
# Provenance tallies for a quick header.
|
||||
counts = {"agent": 0, "bundled": 0, "hub": 0}
|
||||
for r in rows:
|
||||
counts[r.get("provenance", "agent")] = counts.get(r.get("provenance", "agent"), 0) + 1
|
||||
print(
|
||||
f"skills: {len(rows)} total "
|
||||
f"(agent={counts['agent']} bundled={counts['bundled']} hub={counts['hub']})"
|
||||
)
|
||||
print()
|
||||
print(
|
||||
f" {'skill':40s} {'origin':8s} "
|
||||
f"{'use':>4s} {'view':>4s} {'patch':>5s} {'act':>4s} last_activity"
|
||||
)
|
||||
for r in rows:
|
||||
last = _fmt_ts(r.get("last_activity_at"))
|
||||
print(
|
||||
f" {r['name'][:40]:40s} "
|
||||
f"{r.get('provenance', 'agent'):8s} "
|
||||
f"{r.get('use_count', 0):>4d} "
|
||||
f"{r.get('view_count', 0):>4d} "
|
||||
f"{r.get('patch_count', 0):>5d} "
|
||||
f"{r.get('activity_count', 0):>4d} "
|
||||
f"{last}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# argparse wiring (called from hermes_cli.main)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -549,25 +489,6 @@ def register_cli(parent: argparse.ArgumentParser) -> None:
|
||||
p_status = subs.add_parser("status", help="Show curator status and skill stats")
|
||||
p_status.set_defaults(func=_cmd_status)
|
||||
|
||||
p_usage = subs.add_parser(
|
||||
"usage",
|
||||
help="Show usage telemetry for ALL skills (built-in, hub, agent) with provenance",
|
||||
)
|
||||
p_usage.add_argument(
|
||||
"--sort", choices=("activity", "recent", "name"), default="activity",
|
||||
help="Sort order: activity (most-used first, default), recent "
|
||||
"(most-recently-active first), or name (alphabetical)",
|
||||
)
|
||||
p_usage.add_argument(
|
||||
"--provenance", choices=("agent", "bundled", "hub"), default=None,
|
||||
help="Only show skills of this origin",
|
||||
)
|
||||
p_usage.add_argument(
|
||||
"--json", action="store_true",
|
||||
help="Emit the full report as JSON instead of a table",
|
||||
)
|
||||
p_usage.set_defaults(func=_cmd_usage)
|
||||
|
||||
p_run = subs.add_parser("run", help="Trigger a curator review now")
|
||||
p_run.add_argument(
|
||||
"--sync", "--synchronous", dest="synchronous", action="store_true",
|
||||
|
||||
+269
-347
@@ -32,196 +32,6 @@ def flush_stdin() -> None:
|
||||
pass
|
||||
|
||||
|
||||
# Normalized menu actions returned by ``read_menu_key``. Using sentinels keeps
|
||||
# every menu's key-handling branch identical and free of raw escape-byte logic.
|
||||
NAV_UP = "up"
|
||||
NAV_DOWN = "down"
|
||||
NAV_SELECT = "select"
|
||||
NAV_TOGGLE = "toggle"
|
||||
NAV_CANCEL = "cancel"
|
||||
NAV_NONE = "none"
|
||||
|
||||
|
||||
def read_menu_key(stdscr) -> str:
|
||||
"""Read one keypress and normalize it to a menu action.
|
||||
|
||||
Decodes raw arrow-key escape sequences in addition to the translated
|
||||
``curses.KEY_*`` values. Even with ``keypad(True)`` (which
|
||||
``curses.wrapper`` sets), some terminals/terminfo entries deliver cursor
|
||||
keys as raw CSI/SS3 byte sequences — ``getch()`` then returns ``27`` (ESC)
|
||||
followed by e.g. ``[`` ``A``. Treating that leading ``27`` as a cancel is
|
||||
what made the setup wizard's provider/model pickers bail to the numbered
|
||||
fallback the moment a user pressed up/down.
|
||||
|
||||
Returns one of the ``NAV_*`` constants. A lone ESC (no continuation byte
|
||||
within a short window) is the only thing that maps to ``NAV_CANCEL`` via
|
||||
the escape path; ``q`` also cancels. Unknown sequences map to
|
||||
``NAV_NONE`` so the caller simply ignores them rather than misfiring.
|
||||
"""
|
||||
import curses
|
||||
|
||||
key = stdscr.getch()
|
||||
|
||||
if key in (curses.KEY_UP, ord("k")):
|
||||
return NAV_UP
|
||||
if key in (curses.KEY_DOWN, ord("j")):
|
||||
return NAV_DOWN
|
||||
if key in (curses.KEY_ENTER, 10, 13):
|
||||
return NAV_SELECT
|
||||
if key == ord(" "):
|
||||
return NAV_TOGGLE
|
||||
if key == ord("q"):
|
||||
return NAV_CANCEL
|
||||
|
||||
if key == 27: # ESC — could be a lone ESC (cancel) or an escape sequence.
|
||||
# Wait briefly for a continuation byte. On slow PTYs (SSH/tmux) the
|
||||
# bytes of an arrow key can arrive across separate reads, so a tiny
|
||||
# timeout avoids misreading a split sequence as a bare ESC.
|
||||
try:
|
||||
stdscr.timeout(60)
|
||||
nxt = stdscr.getch()
|
||||
finally:
|
||||
stdscr.timeout(-1) # restore blocking mode
|
||||
|
||||
if nxt == -1:
|
||||
return NAV_CANCEL # genuine lone ESC
|
||||
|
||||
if nxt in (ord("["), ord("O")): # CSI / SS3 introducer
|
||||
final = stdscr.getch()
|
||||
if final in (ord("A"), ord("k")):
|
||||
return NAV_UP
|
||||
if final in (ord("B"), ord("j")):
|
||||
return NAV_DOWN
|
||||
# Consume the tail of any other CSI sequence (e.g. ``[3~`` Delete,
|
||||
# ``[H`` Home) up to its terminator so stray bytes don't leak into
|
||||
# the next input() and corrupt it.
|
||||
while 0x20 <= final <= 0x3F: # CSI parameter/intermediate bytes
|
||||
final = stdscr.getch()
|
||||
return NAV_NONE
|
||||
# ESC followed by some other byte we don't handle — swallow it.
|
||||
return NAV_NONE
|
||||
|
||||
return NAV_NONE
|
||||
|
||||
|
||||
# Sentinel: an on_action reducer returns this to mean "keep looping" (the
|
||||
# keypress changed cursor/selection state but didn't resolve the menu).
|
||||
_KEEP = object()
|
||||
|
||||
|
||||
def _run_curses_menu(
|
||||
*,
|
||||
initial_cursor,
|
||||
item_count,
|
||||
draw_header,
|
||||
draw_row,
|
||||
on_action,
|
||||
reserve_bottom=1,
|
||||
draw_footer=None,
|
||||
extra_color_pairs=False,
|
||||
fallback,
|
||||
cancel_value,
|
||||
):
|
||||
"""Shared curses single-/multi-select event loop.
|
||||
|
||||
Owns every piece the three public menus used to duplicate verbatim:
|
||||
the non-TTY guard, ``curses.wrapper`` setup (cursor hide + color pairs),
|
||||
the per-frame ``clear``/``getmaxyx``/``refresh`` cycle, scroll-offset math,
|
||||
row iteration, the ``read_menu_key`` dispatch with ``NAV_UP``/``NAV_DOWN``
|
||||
cursor wrap, ``flush_stdin``, and the ``KeyboardInterrupt`` / curses-
|
||||
unavailable fallback. Per-menu behavior is supplied as callbacks so the
|
||||
rendered output stays byte-identical to the old hand-rolled loops.
|
||||
|
||||
Callbacks / params:
|
||||
draw_header(stdscr, max_y, max_x) -> int
|
||||
Draw the title/hint/description rows. Returns the first screen row
|
||||
index where the scrollable item list should start.
|
||||
draw_row(stdscr, y, idx, is_cursor, max_x) -> None
|
||||
Draw one item row.
|
||||
on_action(action, cursor) -> value
|
||||
Reducer for SELECT/TOGGLE/CANCEL. Return ``_KEEP`` to continue the
|
||||
loop; return anything else to resolve the menu with that value.
|
||||
(UP/DOWN cursor movement is handled by the driver itself.)
|
||||
reserve_bottom: number of bottom screen rows kept clear of items
|
||||
(1 = leave the final row blank, matching the old loops).
|
||||
draw_footer(stdscr, max_y, max_x) -> None
|
||||
Optional bottom-row painter (e.g. a status bar). Drawn after the
|
||||
item rows; its row budget must be included in ``reserve_bottom``.
|
||||
extra_color_pairs: also init pair 3 (dim gray) for status bars.
|
||||
fallback() -> value
|
||||
Called when curses errors out on a real TTY (curses unavailable).
|
||||
cancel_value: returned on non-TTY stdin, ESC/cancel, or KeyboardInterrupt.
|
||||
"""
|
||||
# Non-TTY (piped/redirected stdin): curses and input() both hang or spin,
|
||||
# so return the cancel value directly — matching the pre-refactor guard in
|
||||
# each menu (the numbered fallback is only for curses errors on a real TTY).
|
||||
if not sys.stdin.isatty():
|
||||
return cancel_value
|
||||
|
||||
try:
|
||||
import curses
|
||||
result_holder = [_KEEP]
|
||||
|
||||
def _draw(stdscr):
|
||||
curses.curs_set(0)
|
||||
if curses.has_colors():
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||
if extra_color_pairs:
|
||||
curses.init_pair(
|
||||
3, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1
|
||||
)
|
||||
cursor = initial_cursor
|
||||
scroll_offset = 0
|
||||
|
||||
while True:
|
||||
stdscr.clear()
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
|
||||
items_start = draw_header(stdscr, max_y, max_x)
|
||||
|
||||
visible_rows = max_y - items_start - reserve_bottom
|
||||
if cursor < scroll_offset:
|
||||
scroll_offset = cursor
|
||||
elif cursor >= scroll_offset + visible_rows:
|
||||
scroll_offset = cursor - visible_rows + 1
|
||||
|
||||
for draw_i, i in enumerate(
|
||||
range(scroll_offset, min(item_count, scroll_offset + visible_rows))
|
||||
):
|
||||
y = draw_i + items_start
|
||||
if y >= max_y - reserve_bottom:
|
||||
break
|
||||
draw_row(stdscr, y, i, i == cursor, max_x)
|
||||
|
||||
if draw_footer is not None:
|
||||
draw_footer(stdscr, max_y, max_x)
|
||||
|
||||
stdscr.refresh()
|
||||
action = read_menu_key(stdscr)
|
||||
|
||||
if action == NAV_UP:
|
||||
cursor = (cursor - 1) % item_count
|
||||
elif action == NAV_DOWN:
|
||||
cursor = (cursor + 1) % item_count
|
||||
elif action in (NAV_SELECT, NAV_TOGGLE, NAV_CANCEL):
|
||||
outcome = on_action(action, cursor)
|
||||
if outcome is not _KEEP:
|
||||
result_holder[0] = outcome
|
||||
return
|
||||
|
||||
curses.wrapper(_draw)
|
||||
flush_stdin()
|
||||
return result_holder[0] if result_holder[0] is not _KEEP else cancel_value
|
||||
|
||||
except KeyboardInterrupt:
|
||||
return cancel_value
|
||||
except Exception:
|
||||
return fallback()
|
||||
|
||||
|
||||
def curses_checklist(
|
||||
title: str,
|
||||
items: List[str],
|
||||
@@ -244,73 +54,112 @@ def curses_checklist(
|
||||
if cancel_returns is None:
|
||||
cancel_returns = set(selected)
|
||||
|
||||
chosen = set(selected)
|
||||
# Safety: curses and input() both hang or spin when stdin is not a
|
||||
# terminal (e.g. subprocess pipe). Return defaults immediately.
|
||||
if not sys.stdin.isatty():
|
||||
return cancel_returns
|
||||
|
||||
def _draw_header(stdscr, max_y, max_x):
|
||||
try:
|
||||
import curses
|
||||
try:
|
||||
hattr = curses.A_BOLD
|
||||
chosen = set(selected)
|
||||
result_holder: list = [None]
|
||||
|
||||
def _draw(stdscr):
|
||||
curses.curs_set(0)
|
||||
if curses.has_colors():
|
||||
hattr |= curses.color_pair(2)
|
||||
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
|
||||
stdscr.addnstr(
|
||||
1, 0,
|
||||
" ↑↓ navigate SPACE toggle ENTER confirm ESC cancel",
|
||||
max_x - 1, curses.A_DIM,
|
||||
)
|
||||
except curses.error:
|
||||
pass
|
||||
return 3
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||
curses.init_pair(3, 8 if curses.COLORS > 8 else curses.COLOR_WHITE, -1) # dim gray
|
||||
cursor = 0
|
||||
scroll_offset = 0
|
||||
|
||||
def _draw_row(stdscr, y, i, is_cursor, max_x):
|
||||
import curses
|
||||
check = "✓" if i in chosen else " "
|
||||
arrow = "→" if is_cursor else " "
|
||||
line = f" {arrow} [{check}] {items[i]}"
|
||||
attr = curses.A_NORMAL
|
||||
if is_cursor:
|
||||
attr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
attr |= curses.color_pair(1)
|
||||
try:
|
||||
stdscr.addnstr(y, 0, line, max_x - 1, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
while True:
|
||||
stdscr.clear()
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
|
||||
def _draw_footer(stdscr, max_y, max_x):
|
||||
import curses
|
||||
try:
|
||||
status_text = status_fn(chosen)
|
||||
if status_text:
|
||||
# Right-align on the bottom row
|
||||
sx = max(0, max_x - len(status_text) - 1)
|
||||
sattr = curses.A_DIM
|
||||
if curses.has_colors():
|
||||
sattr |= curses.color_pair(3)
|
||||
stdscr.addnstr(max_y - 1, sx, status_text, max_x - sx - 1, sattr)
|
||||
except curses.error:
|
||||
pass
|
||||
# Reserve bottom row for status bar when status_fn provided
|
||||
footer_rows = 1 if status_fn else 0
|
||||
|
||||
def _on_action(action, cursor):
|
||||
if action == NAV_TOGGLE:
|
||||
chosen.symmetric_difference_update({cursor})
|
||||
return _KEEP
|
||||
if action == NAV_SELECT:
|
||||
return set(chosen)
|
||||
return cancel_returns # NAV_CANCEL
|
||||
# Header
|
||||
try:
|
||||
hattr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
hattr |= curses.color_pair(2)
|
||||
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
|
||||
stdscr.addnstr(
|
||||
1, 0,
|
||||
" ↑↓ navigate SPACE toggle ENTER confirm ESC cancel",
|
||||
max_x - 1, curses.A_DIM,
|
||||
)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
return _run_curses_menu(
|
||||
initial_cursor=0,
|
||||
item_count=len(items),
|
||||
draw_header=_draw_header,
|
||||
draw_row=_draw_row,
|
||||
on_action=_on_action,
|
||||
reserve_bottom=(2 if status_fn else 1),
|
||||
draw_footer=_draw_footer if status_fn else None,
|
||||
extra_color_pairs=bool(status_fn),
|
||||
fallback=lambda: _numbered_fallback(title, items, selected, cancel_returns, status_fn),
|
||||
cancel_value=cancel_returns,
|
||||
)
|
||||
# Scrollable item list
|
||||
visible_rows = max_y - 3 - footer_rows
|
||||
if cursor < scroll_offset:
|
||||
scroll_offset = cursor
|
||||
elif cursor >= scroll_offset + visible_rows:
|
||||
scroll_offset = cursor - visible_rows + 1
|
||||
|
||||
for draw_i, i in enumerate(
|
||||
range(scroll_offset, min(len(items), scroll_offset + visible_rows))
|
||||
):
|
||||
y = draw_i + 3
|
||||
if y >= max_y - 1 - footer_rows:
|
||||
break
|
||||
check = "✓" if i in chosen else " "
|
||||
arrow = "→" if i == cursor else " "
|
||||
line = f" {arrow} [{check}] {items[i]}"
|
||||
attr = curses.A_NORMAL
|
||||
if i == cursor:
|
||||
attr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
attr |= curses.color_pair(1)
|
||||
try:
|
||||
stdscr.addnstr(y, 0, line, max_x - 1, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Status bar (bottom row, right-aligned)
|
||||
if status_fn:
|
||||
try:
|
||||
status_text = status_fn(chosen)
|
||||
if status_text:
|
||||
# Right-align on the bottom row
|
||||
sx = max(0, max_x - len(status_text) - 1)
|
||||
sattr = curses.A_DIM
|
||||
if curses.has_colors():
|
||||
sattr |= curses.color_pair(3)
|
||||
stdscr.addnstr(max_y - 1, sx, status_text, max_x - sx - 1, sattr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
stdscr.refresh()
|
||||
key = stdscr.getch()
|
||||
|
||||
if key in {curses.KEY_UP, ord("k")}:
|
||||
cursor = (cursor - 1) % len(items)
|
||||
elif key in {curses.KEY_DOWN, ord("j")}:
|
||||
cursor = (cursor + 1) % len(items)
|
||||
elif key == ord(" "):
|
||||
chosen.symmetric_difference_update({cursor})
|
||||
elif key in {curses.KEY_ENTER, 10, 13}:
|
||||
result_holder[0] = set(chosen)
|
||||
return
|
||||
elif key in {27, ord("q")}:
|
||||
result_holder[0] = cancel_returns
|
||||
return
|
||||
|
||||
curses.wrapper(_draw)
|
||||
flush_stdin()
|
||||
return result_holder[0] if result_holder[0] is not None else cancel_returns
|
||||
|
||||
except KeyboardInterrupt:
|
||||
return cancel_returns
|
||||
except Exception:
|
||||
return _numbered_fallback(title, items, selected, cancel_returns, status_fn)
|
||||
|
||||
|
||||
def curses_radiolist(
|
||||
@@ -335,68 +184,106 @@ def curses_radiolist(
|
||||
if cancel_returns is None:
|
||||
cancel_returns = selected
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
return cancel_returns
|
||||
|
||||
desc_lines: list[str] = []
|
||||
if description:
|
||||
desc_lines = description.splitlines()
|
||||
|
||||
def _draw_header(stdscr, max_y, max_x):
|
||||
try:
|
||||
import curses
|
||||
row = 0
|
||||
try:
|
||||
hattr = curses.A_BOLD
|
||||
result_holder: list = [None]
|
||||
|
||||
def _draw(stdscr):
|
||||
curses.curs_set(0)
|
||||
if curses.has_colors():
|
||||
hattr |= curses.color_pair(2)
|
||||
stdscr.addnstr(row, 0, title, max_x - 1, hattr)
|
||||
row += 1
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||
cursor = selected
|
||||
scroll_offset = 0
|
||||
|
||||
# Description lines
|
||||
for dline in desc_lines:
|
||||
if row >= max_y - 1:
|
||||
break
|
||||
stdscr.addnstr(row, 0, dline, max_x - 1, curses.A_NORMAL)
|
||||
row += 1
|
||||
while True:
|
||||
stdscr.clear()
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
|
||||
stdscr.addnstr(
|
||||
row, 0,
|
||||
" \u2191\u2193 navigate ENTER/SPACE select ESC cancel",
|
||||
max_x - 1, curses.A_DIM,
|
||||
)
|
||||
row += 1
|
||||
except curses.error:
|
||||
pass
|
||||
# One blank row between the hint and the item list.
|
||||
return row + 1
|
||||
row = 0
|
||||
|
||||
def _draw_row(stdscr, y, i, is_cursor, max_x):
|
||||
import curses
|
||||
radio = "\u25cf" if i == selected else "\u25cb"
|
||||
arrow = "\u2192" if is_cursor else " "
|
||||
line = f" {arrow} ({radio}) {items[i]}"
|
||||
attr = curses.A_NORMAL
|
||||
if is_cursor:
|
||||
attr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
attr |= curses.color_pair(1)
|
||||
try:
|
||||
stdscr.addnstr(y, 0, line, max_x - 1, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
# Header
|
||||
try:
|
||||
hattr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
hattr |= curses.color_pair(2)
|
||||
stdscr.addnstr(row, 0, title, max_x - 1, hattr)
|
||||
row += 1
|
||||
|
||||
def _on_action(action, cursor):
|
||||
if action in (NAV_SELECT, NAV_TOGGLE):
|
||||
return cursor
|
||||
return cancel_returns # NAV_CANCEL
|
||||
# Description lines
|
||||
for dline in desc_lines:
|
||||
if row >= max_y - 1:
|
||||
break
|
||||
stdscr.addnstr(row, 0, dline, max_x - 1, curses.A_NORMAL)
|
||||
row += 1
|
||||
|
||||
return _run_curses_menu(
|
||||
initial_cursor=selected,
|
||||
item_count=len(items),
|
||||
draw_header=_draw_header,
|
||||
draw_row=_draw_row,
|
||||
on_action=_on_action,
|
||||
reserve_bottom=1,
|
||||
fallback=lambda: _radio_numbered_fallback(title, items, selected, cancel_returns),
|
||||
cancel_value=cancel_returns,
|
||||
)
|
||||
stdscr.addnstr(
|
||||
row, 0,
|
||||
" \u2191\u2193 navigate ENTER/SPACE select ESC cancel",
|
||||
max_x - 1, curses.A_DIM,
|
||||
)
|
||||
row += 1
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
# Scrollable item list
|
||||
items_start = row + 1
|
||||
visible_rows = max_y - items_start - 1
|
||||
if cursor < scroll_offset:
|
||||
scroll_offset = cursor
|
||||
elif cursor >= scroll_offset + visible_rows:
|
||||
scroll_offset = cursor - visible_rows + 1
|
||||
|
||||
for draw_i, i in enumerate(
|
||||
range(scroll_offset, min(len(items), scroll_offset + visible_rows))
|
||||
):
|
||||
y = draw_i + items_start
|
||||
if y >= max_y - 1:
|
||||
break
|
||||
radio = "\u25cf" if i == selected else "\u25cb"
|
||||
arrow = "\u2192" if i == cursor else " "
|
||||
line = f" {arrow} ({radio}) {items[i]}"
|
||||
attr = curses.A_NORMAL
|
||||
if i == cursor:
|
||||
attr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
attr |= curses.color_pair(1)
|
||||
try:
|
||||
stdscr.addnstr(y, 0, line, max_x - 1, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
stdscr.refresh()
|
||||
key = stdscr.getch()
|
||||
|
||||
if key in {curses.KEY_UP, ord("k")}:
|
||||
cursor = (cursor - 1) % len(items)
|
||||
elif key in {curses.KEY_DOWN, ord("j")}:
|
||||
cursor = (cursor + 1) % len(items)
|
||||
elif key in {ord(" "), curses.KEY_ENTER, 10, 13}:
|
||||
result_holder[0] = cursor
|
||||
return
|
||||
elif key in {27, ord("q")}:
|
||||
result_holder[0] = cancel_returns
|
||||
return
|
||||
|
||||
curses.wrapper(_draw)
|
||||
flush_stdin()
|
||||
return result_holder[0] if result_holder[0] is not None else cancel_returns
|
||||
|
||||
except KeyboardInterrupt:
|
||||
return cancel_returns
|
||||
except Exception:
|
||||
return _radio_numbered_fallback(title, items, selected, cancel_returns)
|
||||
|
||||
|
||||
def _radio_numbered_fallback(
|
||||
@@ -437,58 +324,93 @@ def curses_single_select(
|
||||
Works inside prompt_toolkit because curses.wrapper() restores the terminal
|
||||
safely, unlike simple_term_menu which conflicts with /dev/tty.
|
||||
"""
|
||||
all_items = list(items) + [cancel_label]
|
||||
cancel_idx = len(items)
|
||||
if not sys.stdin.isatty():
|
||||
return None
|
||||
|
||||
def _draw_header(stdscr, max_y, max_x):
|
||||
try:
|
||||
import curses
|
||||
try:
|
||||
hattr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
hattr |= curses.color_pair(2)
|
||||
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
|
||||
stdscr.addnstr(
|
||||
1, 0,
|
||||
" ↑↓ navigate ENTER confirm ESC/q cancel",
|
||||
max_x - 1, curses.A_DIM,
|
||||
)
|
||||
except curses.error:
|
||||
pass
|
||||
return 3
|
||||
result_holder: list = [None]
|
||||
|
||||
def _draw_row(stdscr, y, i, is_cursor, max_x):
|
||||
import curses
|
||||
arrow = "→" if is_cursor else " "
|
||||
line = f" {arrow} {all_items[i]}"
|
||||
attr = curses.A_NORMAL
|
||||
if is_cursor:
|
||||
attr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
attr |= curses.color_pair(1)
|
||||
try:
|
||||
stdscr.addnstr(y, 0, line, max_x - 1, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
all_items = list(items) + [cancel_label]
|
||||
cancel_idx = len(items)
|
||||
|
||||
def _on_action(action, cursor):
|
||||
if action == NAV_SELECT:
|
||||
# Selecting the synthetic cancel row resolves to None, mirroring
|
||||
# the old post-loop ``>= cancel_idx`` guard.
|
||||
return None if cursor >= cancel_idx else cursor
|
||||
if action == NAV_CANCEL:
|
||||
def _draw(stdscr):
|
||||
curses.curs_set(0)
|
||||
if curses.has_colors():
|
||||
curses.start_color()
|
||||
curses.use_default_colors()
|
||||
curses.init_pair(1, curses.COLOR_GREEN, -1)
|
||||
curses.init_pair(2, curses.COLOR_YELLOW, -1)
|
||||
cursor = min(default_index, len(all_items) - 1)
|
||||
scroll_offset = 0
|
||||
|
||||
while True:
|
||||
stdscr.clear()
|
||||
max_y, max_x = stdscr.getmaxyx()
|
||||
|
||||
try:
|
||||
hattr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
hattr |= curses.color_pair(2)
|
||||
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
|
||||
stdscr.addnstr(
|
||||
1, 0,
|
||||
" ↑↓ navigate ENTER confirm ESC/q cancel",
|
||||
max_x - 1, curses.A_DIM,
|
||||
)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
visible_rows = max_y - 3
|
||||
if cursor < scroll_offset:
|
||||
scroll_offset = cursor
|
||||
elif cursor >= scroll_offset + visible_rows:
|
||||
scroll_offset = cursor - visible_rows + 1
|
||||
|
||||
for draw_i, i in enumerate(
|
||||
range(scroll_offset, min(len(all_items), scroll_offset + visible_rows))
|
||||
):
|
||||
y = draw_i + 3
|
||||
if y >= max_y - 1:
|
||||
break
|
||||
arrow = "→" if i == cursor else " "
|
||||
line = f" {arrow} {all_items[i]}"
|
||||
attr = curses.A_NORMAL
|
||||
if i == cursor:
|
||||
attr = curses.A_BOLD
|
||||
if curses.has_colors():
|
||||
attr |= curses.color_pair(1)
|
||||
try:
|
||||
stdscr.addnstr(y, 0, line, max_x - 1, attr)
|
||||
except curses.error:
|
||||
pass
|
||||
|
||||
stdscr.refresh()
|
||||
key = stdscr.getch()
|
||||
|
||||
if key in {curses.KEY_UP, ord("k")}:
|
||||
cursor = (cursor - 1) % len(all_items)
|
||||
elif key in {curses.KEY_DOWN, ord("j")}:
|
||||
cursor = (cursor + 1) % len(all_items)
|
||||
elif key in {curses.KEY_ENTER, 10, 13}:
|
||||
result_holder[0] = cursor
|
||||
return
|
||||
elif key in {27, ord("q")}:
|
||||
result_holder[0] = None
|
||||
return
|
||||
|
||||
curses.wrapper(_draw)
|
||||
flush_stdin()
|
||||
if result_holder[0] is not None and result_holder[0] >= cancel_idx:
|
||||
return None
|
||||
return _KEEP # NAV_TOGGLE — no-op for this menu
|
||||
return result_holder[0]
|
||||
|
||||
return _run_curses_menu(
|
||||
initial_cursor=min(default_index, len(all_items) - 1),
|
||||
item_count=len(all_items),
|
||||
draw_header=_draw_header,
|
||||
draw_row=_draw_row,
|
||||
on_action=_on_action,
|
||||
reserve_bottom=1,
|
||||
fallback=lambda: _numbered_single_fallback(title, all_items, cancel_idx),
|
||||
cancel_value=None,
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
return None
|
||||
except Exception:
|
||||
all_items = list(items) + [cancel_label]
|
||||
cancel_idx = len(items)
|
||||
return _numbered_single_fallback(title, all_items, cancel_idx)
|
||||
|
||||
|
||||
def _numbered_single_fallback(
|
||||
|
||||
@@ -150,16 +150,6 @@ def _safe_next_target(request: Request) -> str:
|
||||
for p in ("/login", "/auth/", "/api/auth/")
|
||||
):
|
||||
return ""
|
||||
# Reject ALL ``/api/*`` paths. The 401-envelope code path fires for
|
||||
# any unauthenticated SPA fetch (e.g. ``GET /api/analytics/models``
|
||||
# from ModelsPage), and the SPA's global 401 handler full-page
|
||||
# navigates to ``login_url``. After the OAuth round trip the user
|
||||
# would land on the API URL and see raw JSON instead of the
|
||||
# dashboard. SPA routes survive (they don't start with ``/api/``);
|
||||
# the SPA's own ``sessionStorage["hermes.lastLocation"]`` fallback
|
||||
# in ``web/src/lib/api.ts`` covers the deep-link case.
|
||||
if path == "/api" or path.startswith("/api/"):
|
||||
return ""
|
||||
# Preserve query string if present (e.g. /sessions?page=2).
|
||||
query = request.url.query
|
||||
target = f"{path}?{query}" if query else path
|
||||
|
||||
@@ -365,15 +365,6 @@ def _validate_post_login_target(raw: str) -> str:
|
||||
for p in ("/login", "/auth/", "/api/auth/")
|
||||
):
|
||||
return ""
|
||||
# Reject any ``/api/*`` target. The gate's ``_safe_next_target``
|
||||
# already filters these out before they reach the cookie, but a
|
||||
# malicious or stale ``next=`` value that re-enters via the
|
||||
# callback URL must not be honoured: a successful redirect to an
|
||||
# API endpoint renders raw JSON in the browser address bar — never
|
||||
# a useful post-login destination, and indistinguishable from an
|
||||
# attacker trying to weaponise the redirect.
|
||||
if decoded == "/api" or decoded.startswith("/api/"):
|
||||
return ""
|
||||
return decoded
|
||||
|
||||
|
||||
|
||||
@@ -204,60 +204,6 @@ def _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None
|
||||
issues.append(fix)
|
||||
|
||||
|
||||
def _read_pyproject_version() -> str | None:
|
||||
"""Read the ``version = "..."`` from ``pyproject.toml`` at the project root.
|
||||
|
||||
Returns None when running from an installed wheel (no pyproject.toml ships
|
||||
with the package) or when the file can't be parsed. Reads only the
|
||||
``[project]`` version, ignoring any version strings that appear in other
|
||||
tables.
|
||||
"""
|
||||
pyproject = PROJECT_ROOT / "pyproject.toml"
|
||||
try:
|
||||
text = pyproject.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
in_project = False
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
in_project = line == "[project]"
|
||||
continue
|
||||
if in_project and line.startswith("version") and "=" in line:
|
||||
value = line.split("=", 1)[1]
|
||||
value = value.split("#", 1)[0].strip().strip("\"'")
|
||||
return value or None
|
||||
return None
|
||||
|
||||
|
||||
def _check_version_consistency(issues: list[str]) -> None:
|
||||
"""Verify pyproject.toml version matches hermes_cli.__version__.
|
||||
|
||||
A git conflict resolution (reset/merge) can revert one file without the
|
||||
other, leaving ``hermes --version`` reporting a stale version while
|
||||
``pyproject.toml`` is current. Detect that drift so users can re-sync.
|
||||
Silent no-op for installed wheels where pyproject.toml isn't present.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import __version__ as init_version
|
||||
except Exception:
|
||||
return
|
||||
pyproject_version = _read_pyproject_version()
|
||||
if pyproject_version is None:
|
||||
# Installed wheel or unreadable pyproject — nothing to cross-check.
|
||||
return
|
||||
if pyproject_version == init_version:
|
||||
check_ok("Version files consistent", f"({init_version})")
|
||||
else:
|
||||
_fail_and_issue(
|
||||
"Version mismatch between source files",
|
||||
f"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})",
|
||||
"Re-sync version files (e.g. run 'hermes update', or set "
|
||||
"hermes_cli/__init__.py __version__ to match pyproject.toml)",
|
||||
issues,
|
||||
)
|
||||
|
||||
|
||||
def _check_s6_supervision(issues: list[str]) -> None:
|
||||
"""Inside a container under our s6 /init, surface what s6 sees.
|
||||
|
||||
@@ -563,10 +509,6 @@ def run_doctor(args):
|
||||
check_ok("Virtual environment active")
|
||||
else:
|
||||
check_warn("Not in virtual environment", "(recommended)")
|
||||
|
||||
# Detect drift between pyproject.toml and hermes_cli/__init__.py versions
|
||||
# (a git conflict resolution can silently revert one but not the other).
|
||||
_check_version_consistency(issues)
|
||||
|
||||
_section("Required Packages")
|
||||
required_packages = [
|
||||
|
||||
+3
-23
@@ -227,9 +227,9 @@ def _graceful_restart_via_sigusr1(pid: int, drain_timeout: float) -> bool:
|
||||
|
||||
SIGUSR1 is wired in gateway/run.py to ``request_restart(via_service=True)``
|
||||
which drains in-flight agent runs (up to ``agent.restart_drain_timeout``
|
||||
seconds), then exits. systemd relaunches clean exits via
|
||||
``Restart=always``; launchd still uses a non-zero planned-restart exit
|
||||
because its plist has ``KeepAlive.SuccessfulExit = false``.
|
||||
seconds), then exits with code 75. Both systemd (``Restart=always``
|
||||
+ ``RestartForceExitStatus=75``) and launchd (``KeepAlive.SuccessfulExit
|
||||
= false``) relaunch the process after the graceful exit.
|
||||
|
||||
This is the drain-aware alternative to ``systemctl restart`` / ``SIGTERM``,
|
||||
which SIGKILL in-flight agents after a short timeout.
|
||||
@@ -6109,16 +6109,6 @@ def _gateway_command_inner(args):
|
||||
sys.exit(1)
|
||||
|
||||
elif subcmd == "stop":
|
||||
# Defense: refuse self-targeting gateway stop from inside the gateway.
|
||||
# Prevents agent-initiated kill loops when combined with supervisor KeepAlive.
|
||||
if os.getenv("_HERMES_GATEWAY") == "1":
|
||||
print_error(
|
||||
"Refusing to stop the gateway from inside the gateway process.\n"
|
||||
"This command was blocked to prevent restart loops.\n"
|
||||
"Use `hermes gateway stop` from a shell outside the running gateway."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
stop_all = getattr(args, "all", False)
|
||||
system = getattr(args, "system", False)
|
||||
|
||||
@@ -6202,16 +6192,6 @@ def _gateway_command_inner(args):
|
||||
print(f"✓ Stopped {get_service_name()} service")
|
||||
|
||||
elif subcmd == "restart":
|
||||
# Defense: refuse self-targeting gateway restart from inside the gateway.
|
||||
# Prevents agent-initiated kill loops when combined with supervisor KeepAlive.
|
||||
if os.getenv("_HERMES_GATEWAY") == "1":
|
||||
print_error(
|
||||
"Refusing to restart the gateway from inside the gateway process.\n"
|
||||
"This command was blocked to prevent restart loops.\n"
|
||||
"Use `hermes gateway restart` from a shell outside the running gateway."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Try service first, fall back to killing and restarting
|
||||
service_available = False
|
||||
system = getattr(args, "system", False)
|
||||
|
||||
@@ -747,153 +747,6 @@ class GoalManager:
|
||||
return CONTINUATION_PROMPT_TEMPLATE.format(goal=self._state.goal)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Kanban worker goal loop
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Continuation prompt fed back to a kanban goal-mode worker that has not
|
||||
# yet completed/blocked its task. The card's own acceptance criteria are
|
||||
# the goal — the worker already has the full task body in its first turn,
|
||||
# so we keep this short and point it back at the lifecycle contract.
|
||||
KANBAN_GOAL_CONTINUATION_TEMPLATE = (
|
||||
"[Continuing toward this kanban task — judge says it is not done yet]\n"
|
||||
"Reason: {reason}\n\n"
|
||||
"Take the next concrete step toward completing the task. When the work "
|
||||
"is genuinely finished, call kanban_complete with a summary. If you are "
|
||||
"blocked and need human input, call kanban_block with a reason. Do not "
|
||||
"stop without calling one of them."
|
||||
)
|
||||
|
||||
# Fed when the judge believes the work is done but the worker never called
|
||||
# kanban_complete / kanban_block. One explicit nudge to terminate the task
|
||||
# the right way before the loop gives up.
|
||||
KANBAN_GOAL_FINALIZE_TEMPLATE = (
|
||||
"[The work looks complete, but the task is still open]\n"
|
||||
"Reason: {reason}\n\n"
|
||||
"If the task is genuinely done, call kanban_complete now with a short "
|
||||
"summary of what you did. If something still blocks completion, call "
|
||||
"kanban_block with the reason instead."
|
||||
)
|
||||
|
||||
|
||||
def run_kanban_goal_loop(
|
||||
*,
|
||||
task_id: str,
|
||||
goal_text: str,
|
||||
run_turn,
|
||||
task_status_fn,
|
||||
block_fn,
|
||||
max_turns: int = DEFAULT_MAX_TURNS,
|
||||
first_response: str = "",
|
||||
log=None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Drive a kanban worker through a Ralph-style goal loop.
|
||||
|
||||
The dispatcher spawns a goal-mode worker exactly like a normal worker
|
||||
(``hermes -p <profile> chat -q "work kanban task <id>"``). The worker's
|
||||
first turn has already run by the time this is called; ``first_response``
|
||||
is that turn's reply. From here we:
|
||||
|
||||
1. Check whether the worker already terminated the task (called
|
||||
``kanban_complete`` / ``kanban_block``). If so, stop — nothing to do.
|
||||
2. Otherwise judge the latest response against ``goal_text`` (the card's
|
||||
title + body). ``continue`` → feed a continuation prompt and run
|
||||
another turn IN THE SAME SESSION via ``run_turn``. ``done`` but the
|
||||
task is still open → one explicit "call kanban_complete" nudge.
|
||||
3. When the turn budget is exhausted and the worker still hasn't
|
||||
terminated the task, ``block_fn`` is invoked so the card lands in a
|
||||
sticky ``blocked`` state for human review (NOT a silent exit).
|
||||
|
||||
This function performs NO SessionDB persistence — a worker process is
|
||||
ephemeral, so the turn budget lives in a local counter. It is fully
|
||||
decoupled from the CLI for testability: callers inject ``run_turn``
|
||||
(str -> str), ``task_status_fn`` (() -> str|None), and ``block_fn``
|
||||
(reason: str -> None).
|
||||
|
||||
Returns a decision dict: ``{"outcome", "turns_used", "reason"}`` where
|
||||
outcome is one of ``"completed_by_worker"``, ``"blocked_budget"``,
|
||||
``"blocked_by_worker"``, or ``"stopped"``.
|
||||
"""
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
if log is not None:
|
||||
try:
|
||||
log(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
max_turns = int(max_turns or DEFAULT_MAX_TURNS)
|
||||
if max_turns < 1:
|
||||
max_turns = DEFAULT_MAX_TURNS
|
||||
|
||||
last_response = first_response or ""
|
||||
# The first turn already consumed one unit of budget.
|
||||
turns_used = 1
|
||||
nudged_to_finalize = False
|
||||
|
||||
while True:
|
||||
# Did the worker terminate the task itself this turn?
|
||||
try:
|
||||
status = task_status_fn()
|
||||
except Exception as exc:
|
||||
_log(f"kanban goal loop: status check failed ({exc}); stopping")
|
||||
return {"outcome": "stopped", "turns_used": turns_used, "reason": "status check failed"}
|
||||
|
||||
if status == "done":
|
||||
_log(f"kanban goal loop: task {task_id} completed by worker after {turns_used} turn(s)")
|
||||
return {"outcome": "completed_by_worker", "turns_used": turns_used, "reason": "worker completed the task"}
|
||||
if status == "blocked":
|
||||
_log(f"kanban goal loop: task {task_id} blocked by worker after {turns_used} turn(s)")
|
||||
return {"outcome": "blocked_by_worker", "turns_used": turns_used, "reason": "worker blocked the task"}
|
||||
if status not in ("running", "ready"):
|
||||
# Reclaimed / archived / unexpected — let the dispatcher own it.
|
||||
_log(f"kanban goal loop: task {task_id} status={status!r}; stopping")
|
||||
return {"outcome": "stopped", "turns_used": turns_used, "reason": f"status={status}"}
|
||||
|
||||
# Still open — judge whether the latest response satisfies the card.
|
||||
verdict, reason, _parse_failed = judge_goal(goal_text, last_response)
|
||||
_log(f"kanban goal loop: turn {turns_used}/{max_turns} verdict={verdict} reason={_truncate(reason, 120)}")
|
||||
|
||||
if verdict == "done":
|
||||
if nudged_to_finalize:
|
||||
# Already asked once to call kanban_complete and it still
|
||||
# didn't — block for review rather than spin.
|
||||
_log(f"kanban goal loop: task {task_id} judged done but worker won't finalize; blocking")
|
||||
try:
|
||||
block_fn(
|
||||
f"Goal-mode worker's output looked complete but it never "
|
||||
f"called kanban_complete after a finalize nudge ({reason})."
|
||||
)
|
||||
except Exception as exc:
|
||||
_log(f"kanban goal loop: block_fn failed ({exc})")
|
||||
return {"outcome": "blocked_budget", "turns_used": turns_used, "reason": "judged done, never finalized"}
|
||||
prompt = KANBAN_GOAL_FINALIZE_TEMPLATE.format(reason=_truncate(reason, 400))
|
||||
nudged_to_finalize = True
|
||||
else:
|
||||
prompt = KANBAN_GOAL_CONTINUATION_TEMPLATE.format(reason=_truncate(reason, 400))
|
||||
|
||||
# Budget check BEFORE spending another turn.
|
||||
if turns_used >= max_turns:
|
||||
_log(f"kanban goal loop: task {task_id} exhausted {turns_used}/{max_turns} turns; blocking")
|
||||
try:
|
||||
block_fn(
|
||||
f"Goal-mode worker exhausted its turn budget "
|
||||
f"({turns_used}/{max_turns}) without completing the task. "
|
||||
f"Last judge verdict: {_truncate(reason, 300)}"
|
||||
)
|
||||
except Exception as exc:
|
||||
_log(f"kanban goal loop: block_fn failed ({exc})")
|
||||
return {"outcome": "blocked_budget", "turns_used": turns_used, "reason": "turn budget exhausted"}
|
||||
|
||||
# Run another turn in the same session.
|
||||
try:
|
||||
last_response = run_turn(prompt) or ""
|
||||
except Exception as exc:
|
||||
_log(f"kanban goal loop: run_turn failed ({exc}); stopping")
|
||||
return {"outcome": "stopped", "turns_used": turns_used, "reason": f"run_turn error: {type(exc).__name__}"}
|
||||
turns_used += 1
|
||||
|
||||
|
||||
__all__ = [
|
||||
"GoalState",
|
||||
"GoalManager",
|
||||
@@ -901,12 +754,9 @@ __all__ = [
|
||||
"CONTINUATION_PROMPT_WITH_SUBGOALS_TEMPLATE",
|
||||
"JUDGE_USER_PROMPT_TEMPLATE",
|
||||
"JUDGE_USER_PROMPT_WITH_SUBGOALS_TEMPLATE",
|
||||
"KANBAN_GOAL_CONTINUATION_TEMPLATE",
|
||||
"KANBAN_GOAL_FINALIZE_TEMPLATE",
|
||||
"DEFAULT_MAX_TURNS",
|
||||
"load_goal",
|
||||
"save_goal",
|
||||
"clear_goal",
|
||||
"judge_goal",
|
||||
"run_kanban_goal_loop",
|
||||
]
|
||||
|
||||
@@ -114,7 +114,6 @@ def build_models_payload(
|
||||
include_unconfigured: bool = False,
|
||||
picker_hints: bool = False,
|
||||
canonical_order: bool = False,
|
||||
pricing: bool = False,
|
||||
max_models: int = 50,
|
||||
) -> dict:
|
||||
"""Build the ``{providers, model, provider}`` shape every consumer
|
||||
@@ -129,11 +128,6 @@ def build_models_payload(
|
||||
- ``canonical_order``: reorder canonical-slug rows to
|
||||
``CANONICAL_PROVIDERS`` declaration order; truly-custom rows go
|
||||
last (TUI display order).
|
||||
- ``pricing``: enrich each row with formatted per-model pricing and,
|
||||
for Nous, ``free_tier``/``unavailable_models`` so the GUI picker can
|
||||
show $/Mtok columns and gate paid models on free accounts —
|
||||
mirroring the ``hermes model`` CLI picker. Adds network calls
|
||||
(pricing fetch + Nous tier check); only set for interactive pickers.
|
||||
"""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
|
||||
@@ -152,8 +146,6 @@ def build_models_payload(
|
||||
_apply_picker_hints(rows)
|
||||
if canonical_order:
|
||||
rows = _reorder_canonical(rows)
|
||||
if pricing:
|
||||
_apply_pricing(rows)
|
||||
|
||||
return {
|
||||
"providers": rows,
|
||||
@@ -246,85 +238,3 @@ def _reorder_canonical(rows: list[dict]) -> list[dict]:
|
||||
)
|
||||
extras = [r for r in rows if r["slug"] not in order]
|
||||
return canon + extras
|
||||
|
||||
|
||||
def _apply_pricing(rows: list[dict]) -> None:
|
||||
"""Enrich each provider row with per-model pricing + Nous tier gating.
|
||||
|
||||
Mutates ``rows`` in-place. For every row whose provider supports live
|
||||
pricing (openrouter / nous / novita) adds::
|
||||
|
||||
row["pricing"] = {model_id: {"input": "$3.00", "output": "$15.00",
|
||||
"cache": "$0.30" | None, "free": bool}}
|
||||
|
||||
For Nous additionally adds::
|
||||
|
||||
row["free_tier"] = bool # current account is free-tier
|
||||
row["unavailable_models"] = [...] # paid models a free user can't pick
|
||||
|
||||
Prices are pre-formatted via ``_format_price_per_mtok`` so the GUI just
|
||||
renders strings — identical formatting to the CLI picker. All failures
|
||||
are swallowed (best-effort): a row simply gets no ``pricing`` key.
|
||||
"""
|
||||
from hermes_cli.models import (
|
||||
_format_price_per_mtok,
|
||||
check_nous_free_tier,
|
||||
get_pricing_for_provider,
|
||||
partition_nous_models_by_tier,
|
||||
)
|
||||
|
||||
# Resolve Nous free-tier once (cached in models.py for the TTL window).
|
||||
nous_free_tier: Optional[bool] = None
|
||||
|
||||
for row in rows:
|
||||
slug = str(row.get("slug", "")).lower()
|
||||
models = row.get("models") or []
|
||||
if not models:
|
||||
continue
|
||||
try:
|
||||
raw_pricing = get_pricing_for_provider(slug) or {}
|
||||
except Exception:
|
||||
raw_pricing = {}
|
||||
if not raw_pricing:
|
||||
continue
|
||||
|
||||
formatted: dict[str, dict] = {}
|
||||
for mid in models:
|
||||
p = raw_pricing.get(mid)
|
||||
if not p:
|
||||
continue
|
||||
inp_raw = p.get("prompt", "")
|
||||
out_raw = p.get("completion", "")
|
||||
cache_raw = p.get("input_cache_read", "")
|
||||
inp = _format_price_per_mtok(inp_raw) if inp_raw != "" else ""
|
||||
out = _format_price_per_mtok(out_raw) if out_raw != "" else ""
|
||||
cache = _format_price_per_mtok(cache_raw) if cache_raw else None
|
||||
# A model is "free" when both input and output cost nothing.
|
||||
is_free = inp == "free" and (out == "free" or out == "")
|
||||
formatted[mid] = {
|
||||
"input": inp,
|
||||
"output": out,
|
||||
"cache": cache,
|
||||
"free": is_free,
|
||||
}
|
||||
|
||||
if formatted:
|
||||
row["pricing"] = formatted
|
||||
|
||||
if slug == "nous":
|
||||
try:
|
||||
if nous_free_tier is None:
|
||||
nous_free_tier = check_nous_free_tier(force_fresh=True)
|
||||
row["free_tier"] = bool(nous_free_tier)
|
||||
if nous_free_tier:
|
||||
_selectable, unavailable = partition_nous_models_by_tier(
|
||||
list(models), raw_pricing, free_tier=True
|
||||
)
|
||||
row["unavailable_models"] = unavailable
|
||||
else:
|
||||
row["unavailable_models"] = []
|
||||
except Exception:
|
||||
# Tier detection failed — fail open (no gating) so the user
|
||||
# is never blocked from picking a model.
|
||||
row["free_tier"] = False
|
||||
row["unavailable_models"] = []
|
||||
|
||||
@@ -341,19 +341,6 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
||||
"two retries. Omit to use the dispatcher's "
|
||||
"kanban.failure_limit config "
|
||||
f"(default {kb.DEFAULT_FAILURE_LIMIT}).")
|
||||
p_create.add_argument("--goal", action="store_true", dest="goal_mode",
|
||||
help="Run the worker in a goal loop: after each "
|
||||
"turn a judge checks the response against the "
|
||||
"card title/body and, if not done, the worker "
|
||||
"keeps going in the same session until the "
|
||||
"judge agrees it's complete (or the turn "
|
||||
"budget runs out, which blocks the card for "
|
||||
"review). Best for open-ended cards one shot "
|
||||
"rarely finishes.")
|
||||
p_create.add_argument("--goal-max-turns", type=int, default=None,
|
||||
metavar="N", dest="goal_max_turns",
|
||||
help="Turn budget for --goal workers (default 20). "
|
||||
"Ignored without --goal.")
|
||||
p_create.add_argument("--initial-status",
|
||||
choices=sorted(kb.VALID_INITIAL_STATUSES),
|
||||
default="running",
|
||||
@@ -1356,8 +1343,6 @@ def _cmd_create(args: argparse.Namespace) -> int:
|
||||
max_runtime_seconds=max_runtime,
|
||||
skills=getattr(args, "skills", None) or None,
|
||||
max_retries=max_retries,
|
||||
goal_mode=bool(getattr(args, "goal_mode", False)),
|
||||
goal_max_turns=getattr(args, "goal_max_turns", None),
|
||||
initial_status=getattr(args, "initial_status", "running"),
|
||||
)
|
||||
task = kb.get_task(conn, task_id)
|
||||
|
||||
+16
-437
@@ -396,41 +396,6 @@ def workspaces_root(board: Optional[str] = None) -> Path:
|
||||
return board_dir(slug) / "workspaces"
|
||||
|
||||
|
||||
def attachments_root(board: Optional[str] = None) -> Path:
|
||||
"""Return the directory under which task file attachments are stored.
|
||||
|
||||
Mirrors :func:`worker_logs_dir` / :func:`workspaces_root`: anchored
|
||||
per-board so attachments don't leak between projects. Each task gets
|
||||
its own ``<root>/.../attachments/<task_id>/`` subdirectory.
|
||||
|
||||
``HERMES_KANBAN_ATTACHMENTS_ROOT`` pins the path directly (highest
|
||||
precedence) for tests and unusual deployments.
|
||||
|
||||
``default`` uses ``<root>/kanban/attachments/``; other boards use
|
||||
``<root>/kanban/boards/<slug>/attachments/``.
|
||||
|
||||
Workers (which run with full file-tool access) read attached files
|
||||
by the absolute path surfaced in :func:`build_worker_context`. On the
|
||||
local terminal backend — the default for kanban — that path resolves
|
||||
directly. Remote backends (Docker/Modal) need this directory mounted;
|
||||
see the kanban docs.
|
||||
"""
|
||||
override = os.environ.get("HERMES_KANBAN_ATTACHMENTS_ROOT", "").strip()
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
slug = _normalize_board_slug(board)
|
||||
if slug is None:
|
||||
slug = get_current_board()
|
||||
if slug == DEFAULT_BOARD:
|
||||
return kanban_home() / "kanban" / "attachments"
|
||||
return board_dir(slug) / "attachments"
|
||||
|
||||
|
||||
def task_attachments_dir(task_id: str, board: Optional[str] = None) -> Path:
|
||||
"""Return the per-task attachment directory ``<root>/<task_id>/``."""
|
||||
return attachments_root(board=board) / task_id
|
||||
|
||||
|
||||
def worker_logs_dir(board: Optional[str] = None) -> Path:
|
||||
"""Return the directory under which per-task worker logs are written.
|
||||
|
||||
@@ -725,19 +690,6 @@ class Task:
|
||||
# ``kanban.failure_limit`` config, and then to ``DEFAULT_FAILURE_LIMIT``.
|
||||
# Name matches the ``--max-retries`` CLI flag on ``kanban create``.
|
||||
max_retries: Optional[int] = None
|
||||
# When True, the dispatched worker runs in a Ralph-style goal loop
|
||||
# (the same engine behind the ``/goal`` slash command): after each
|
||||
# turn an auxiliary judge model evaluates the worker's response
|
||||
# against this card's title/body (treated as the goal). If the judge
|
||||
# says "not done" and budget remains, the worker is fed a
|
||||
# continuation prompt IN THE SAME SESSION and keeps working until the
|
||||
# judge agrees, the goal-turn budget is exhausted (→ kanban_block),
|
||||
# or the worker explicitly blocks/completes. ``False`` (default) =
|
||||
# the classic single-shot worker. ``goal_max_turns`` bounds the loop.
|
||||
goal_mode: bool = False
|
||||
# Goal-loop turn budget for ``goal_mode`` workers. ``None`` falls
|
||||
# through to the goals engine default (``goals.DEFAULT_MAX_TURNS``).
|
||||
goal_max_turns: Optional[int] = None
|
||||
# Originating chat/agent session id, when the task was created from
|
||||
# within an agent loop that propagated ``HERMES_SESSION_ID``. NULL for
|
||||
# tasks created from the CLI, the dashboard, or any path that doesn't
|
||||
@@ -810,12 +762,6 @@ class Task:
|
||||
max_retries=(
|
||||
row["max_retries"] if "max_retries" in keys else None
|
||||
),
|
||||
goal_mode=(
|
||||
bool(row["goal_mode"]) if "goal_mode" in keys and row["goal_mode"] else False
|
||||
),
|
||||
goal_max_turns=(
|
||||
row["goal_max_turns"] if "goal_max_turns" in keys and row["goal_max_turns"] else None
|
||||
),
|
||||
session_id=(
|
||||
row["session_id"] if "session_id" in keys else None
|
||||
),
|
||||
@@ -885,20 +831,6 @@ class Comment:
|
||||
created_at: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Attachment:
|
||||
"""In-memory view of a row from the ``task_attachments`` table."""
|
||||
|
||||
id: int
|
||||
task_id: str
|
||||
filename: str
|
||||
stored_path: str
|
||||
content_type: Optional[str]
|
||||
size: int
|
||||
uploaded_by: Optional[str]
|
||||
created_at: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
id: int
|
||||
@@ -965,16 +897,6 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
-- case) falls through to the dispatcher-level ``kanban.failure_limit``
|
||||
-- config and then ``DEFAULT_FAILURE_LIMIT``.
|
||||
max_retries INTEGER,
|
||||
-- When 1, the dispatched worker runs in a Ralph-style goal loop: an
|
||||
-- auxiliary judge re-evaluates the worker's response against the
|
||||
-- card title/body after each turn and feeds a continuation prompt
|
||||
-- back into the SAME session until the judge agrees the work is done
|
||||
-- or ``goal_max_turns`` is exhausted. NULL/0 = classic single-shot
|
||||
-- worker (the default).
|
||||
goal_mode INTEGER NOT NULL DEFAULT 0,
|
||||
-- Goal-loop turn budget for ``goal_mode`` workers. NULL = use the
|
||||
-- goals-engine default.
|
||||
goal_max_turns INTEGER,
|
||||
-- Originating chat/agent session id when the task was created from
|
||||
-- inside an agent loop that propagated ``HERMES_SESSION_ID``. NULL
|
||||
-- for tasks created from the CLI, dashboard, or any path that doesn't
|
||||
@@ -1035,23 +957,6 @@ CREATE TABLE IF NOT EXISTS task_runs (
|
||||
error TEXT
|
||||
);
|
||||
|
||||
-- Files attached to a task (PDFs, images, source documents). The blob
|
||||
-- lives on disk under ``attachments_root(board)/<task_id>/<stored_name>``;
|
||||
-- this row carries metadata + the absolute ``stored_path`` so the
|
||||
-- dashboard can list/download and ``build_worker_context`` can surface
|
||||
-- the absolute path to the worker (which has full file-tool access). See
|
||||
-- #35338.
|
||||
CREATE TABLE IF NOT EXISTS task_attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
stored_path TEXT NOT NULL,
|
||||
content_type TEXT,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
uploaded_by TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Subscription from a gateway source (platform + chat + thread) to a
|
||||
-- task. The gateway's kanban-notifier watcher tails task_events and
|
||||
-- pushes ``completed`` / ``blocked`` / ``spawn_auto_blocked`` events to
|
||||
@@ -1076,7 +981,6 @@ CREATE INDEX IF NOT EXISTS idx_comments_task ON task_comments(task_id, c
|
||||
CREATE INDEX IF NOT EXISTS idx_events_task ON task_events(task_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_task ON task_runs(task_id, started_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_status ON task_runs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_task ON task_attachments(task_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_notify_task ON kanban_notify_subs(task_id);
|
||||
"""
|
||||
|
||||
@@ -1613,20 +1517,6 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
|
||||
if "model_override" not in cols:
|
||||
conn.execute("ALTER TABLE tasks ADD COLUMN model_override TEXT")
|
||||
|
||||
if "goal_mode" not in cols:
|
||||
# Ralph-style goal loop toggle for the dispatched worker. 0 (the
|
||||
# default) = classic single-shot worker, preserving the behaviour
|
||||
# existing rows had before the column existed.
|
||||
_add_column_if_missing(
|
||||
conn, "tasks", "goal_mode", "goal_mode INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
|
||||
if "goal_max_turns" not in cols:
|
||||
# Per-task goal-loop turn budget. NULL = goals-engine default.
|
||||
_add_column_if_missing(
|
||||
conn, "tasks", "goal_max_turns", "goal_max_turns INTEGER"
|
||||
)
|
||||
|
||||
if "session_id" not in cols:
|
||||
# Originating agent/chat session id, populated when the task is
|
||||
# created from within an agent loop that propagated
|
||||
@@ -1747,140 +1637,6 @@ def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None:
|
||||
(new, old),
|
||||
)
|
||||
|
||||
_rebuild_drifted_tables(conn)
|
||||
|
||||
|
||||
# Legacy DBs defined these tables with a ``TEXT PRIMARY KEY`` id (or, for
|
||||
# ``kanban_notify_subs``, a nullable ``TEXT last_event_id``). The current
|
||||
# schema uses ``INTEGER PRIMARY KEY AUTOINCREMENT`` / ``INTEGER NOT NULL
|
||||
# DEFAULT 0``. ``CREATE TABLE IF NOT EXISTS`` skips existing tables
|
||||
# regardless of schema and ``_add_column_if_missing`` only adds columns, so
|
||||
# neither can fix a drifted column type — the table must be rebuilt. See
|
||||
# #35096.
|
||||
#
|
||||
# Each entry pairs the canonical CREATE TABLE with the CREATE INDEX
|
||||
# statements that DROP TABLE would otherwise take down with it (including
|
||||
# ``idx_events_run``, added by the additive pass above). To guard against
|
||||
# this list drifting from SCHEMA_SQL, ``test_rebuilt_schema_matches_fresh``
|
||||
# asserts a rebuilt legacy DB is byte-identical to a fresh one.
|
||||
_REBUILD_SPECS = {
|
||||
"task_events": (
|
||||
"CREATE TABLE task_events ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" task_id TEXT NOT NULL, run_id INTEGER, kind TEXT NOT NULL,"
|
||||
" payload TEXT, created_at INTEGER NOT NULL)",
|
||||
(
|
||||
"CREATE INDEX idx_events_task ON task_events(task_id, created_at)",
|
||||
"CREATE INDEX idx_events_run ON task_events(run_id, id)",
|
||||
),
|
||||
),
|
||||
"task_comments": (
|
||||
"CREATE TABLE task_comments ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" task_id TEXT NOT NULL, author TEXT NOT NULL, body TEXT NOT NULL,"
|
||||
" created_at INTEGER NOT NULL)",
|
||||
("CREATE INDEX idx_comments_task ON task_comments(task_id, created_at)",),
|
||||
),
|
||||
"task_runs": (
|
||||
"CREATE TABLE task_runs ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" task_id TEXT NOT NULL, profile TEXT, step_key TEXT,"
|
||||
" status TEXT NOT NULL, claim_lock TEXT, claim_expires INTEGER,"
|
||||
" worker_pid INTEGER, max_runtime_seconds INTEGER,"
|
||||
" last_heartbeat_at INTEGER, started_at INTEGER NOT NULL,"
|
||||
" ended_at INTEGER, outcome TEXT, summary TEXT, metadata TEXT,"
|
||||
" error TEXT)",
|
||||
(
|
||||
"CREATE INDEX idx_runs_task ON task_runs(task_id, started_at)",
|
||||
"CREATE INDEX idx_runs_status ON task_runs(status)",
|
||||
),
|
||||
),
|
||||
"kanban_notify_subs": (
|
||||
"CREATE TABLE kanban_notify_subs ("
|
||||
" task_id TEXT NOT NULL, platform TEXT NOT NULL, chat_id TEXT NOT NULL,"
|
||||
" thread_id TEXT NOT NULL DEFAULT '', user_id TEXT,"
|
||||
" notifier_profile TEXT, created_at INTEGER NOT NULL,"
|
||||
" last_event_id INTEGER NOT NULL DEFAULT 0,"
|
||||
" PRIMARY KEY (task_id, platform, chat_id, thread_id))",
|
||||
("CREATE INDEX idx_notify_task ON kanban_notify_subs(task_id)",),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _table_has_drifted(conn: sqlite3.Connection, table: str) -> bool:
|
||||
"""True when ``table`` still carries the legacy (pre-AUTOINCREMENT) shape."""
|
||||
info = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
if not info:
|
||||
return False # table absent — nothing to rebuild
|
||||
if table == "kanban_notify_subs":
|
||||
lei = next((c for c in info if c["name"] == "last_event_id"), None)
|
||||
return lei is not None and (lei["type"] or "").upper() != "INTEGER"
|
||||
# task_events / task_comments / task_runs: id must be INTEGER and a PK.
|
||||
id_col = next((c for c in info if c["name"] == "id"), None)
|
||||
if id_col is None:
|
||||
return False
|
||||
return not ((id_col["type"] or "").upper() == "INTEGER" and id_col["pk"])
|
||||
|
||||
|
||||
def _rebuild_drifted_tables(conn: sqlite3.Connection) -> None:
|
||||
"""Rebuild any kanban table whose column types drifted from SCHEMA_SQL.
|
||||
|
||||
Old boards crash the gateway notifier (``int(None)`` on a NULL id in
|
||||
``unseen_events_for_sub``) and never match the ``id > cursor`` filter, so
|
||||
every kanban notification is silently lost (#35096). Each affected table is
|
||||
rebuilt with the standard SQLite pattern — CREATE new → INSERT shared
|
||||
columns → DROP old → RENAME — recreating its indexes too (DROP TABLE takes
|
||||
them down). The legacy TEXT ids are dropped (they aren't valid integers);
|
||||
AUTOINCREMENT assigns fresh ones and ``last_event_id`` cursors reset to 0,
|
||||
so the first post-migration tick replays a task's event history once —
|
||||
the safe failure mode for a feature that was already fully broken.
|
||||
|
||||
The whole pass runs in one transaction so an interruption can't leave a
|
||||
table half-renamed, and under ``connect()``'s init locks so nothing races
|
||||
it. Idempotent: a correctly-typed DB skips every table and returns without
|
||||
opening a transaction.
|
||||
"""
|
||||
drifted = [t for t in _REBUILD_SPECS if _table_has_drifted(conn, t)]
|
||||
if not drifted:
|
||||
return
|
||||
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
for table in drifted:
|
||||
create_sql, index_sqls = _REBUILD_SPECS[table]
|
||||
old_cols = [c["name"] for c in conn.execute(f"PRAGMA table_info({table})")]
|
||||
_log.info("kanban migration: rebuilding %s to match current schema", table)
|
||||
conn.execute(f"ALTER TABLE {table} RENAME TO {table}_legacy")
|
||||
conn.execute(create_sql)
|
||||
new_cols = {c["name"] for c in conn.execute(f"PRAGMA table_info({table})")}
|
||||
if table == "kanban_notify_subs":
|
||||
# Cast the legacy TEXT cursor to INTEGER; NULL / non-numeric → 0.
|
||||
shared = [c for c in old_cols if c in new_cols and c != "last_event_id"]
|
||||
cols_csv = ", ".join(shared)
|
||||
conn.execute(
|
||||
f"INSERT INTO {table} ({cols_csv}, last_event_id) "
|
||||
f"SELECT {cols_csv}, COALESCE(CAST(last_event_id AS INTEGER), 0) "
|
||||
f"FROM {table}_legacy"
|
||||
)
|
||||
else:
|
||||
# Drop the legacy TEXT id; AUTOINCREMENT reassigns it.
|
||||
shared = [c for c in old_cols if c in new_cols and c != "id"]
|
||||
cols_csv = ", ".join(shared)
|
||||
conn.execute(
|
||||
f"INSERT INTO {table} ({cols_csv}) "
|
||||
f"SELECT {cols_csv} FROM {table}_legacy"
|
||||
)
|
||||
conn.execute(f"DROP TABLE {table}_legacy")
|
||||
for index_sql in index_sqls:
|
||||
conn.execute(index_sql)
|
||||
conn.execute("COMMIT")
|
||||
except Exception:
|
||||
try:
|
||||
conn.execute("ROLLBACK")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _check_file_length_invariant(conn: sqlite3.Connection) -> None:
|
||||
"""Read the SQLite header page_count and compare against actual file size.
|
||||
@@ -2010,8 +1766,6 @@ def create_task(
|
||||
max_runtime_seconds: Optional[int] = None,
|
||||
skills: Optional[Iterable[str]] = None,
|
||||
max_retries: Optional[int] = None,
|
||||
goal_mode: bool = False,
|
||||
goal_max_turns: Optional[int] = None,
|
||||
initial_status: str = "running",
|
||||
session_id: Optional[str] = None,
|
||||
board: Optional[str] = None,
|
||||
@@ -2179,8 +1933,8 @@ def create_task(
|
||||
id, title, body, assignee, status, priority,
|
||||
created_by, created_at, workspace_kind, workspace_path,
|
||||
branch_name, tenant, idempotency_key, max_runtime_seconds,
|
||||
skills, max_retries, goal_mode, goal_max_turns, session_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
skills, max_retries, session_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
@@ -2199,8 +1953,6 @@ def create_task(
|
||||
int(max_runtime_seconds) if max_runtime_seconds is not None else None,
|
||||
json.dumps(skills_list) if skills_list is not None else None,
|
||||
int(max_retries) if max_retries is not None else None,
|
||||
1 if goal_mode else 0,
|
||||
int(goal_max_turns) if goal_max_turns is not None else None,
|
||||
session_id,
|
||||
),
|
||||
)
|
||||
@@ -2220,7 +1972,6 @@ def create_task(
|
||||
"tenant": tenant,
|
||||
"branch_name": branch_name,
|
||||
"skills": list(skills_list) if skills_list else None,
|
||||
"goal_mode": bool(goal_mode) or None,
|
||||
},
|
||||
)
|
||||
return task_id
|
||||
@@ -2501,121 +2252,6 @@ def list_comments(conn: sqlite3.Connection, task_id: str) -> list[Comment]:
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attachments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def add_attachment(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
*,
|
||||
filename: str,
|
||||
stored_path: str,
|
||||
content_type: Optional[str] = None,
|
||||
size: int = 0,
|
||||
uploaded_by: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Record a file attachment for a task. Returns the new attachment id.
|
||||
|
||||
The caller is responsible for writing the blob to ``stored_path``
|
||||
first (under :func:`task_attachments_dir`); this only persists the
|
||||
metadata row and appends an ``attached`` event.
|
||||
"""
|
||||
if not filename or not filename.strip():
|
||||
raise ValueError("attachment filename is required")
|
||||
if not stored_path or not stored_path.strip():
|
||||
raise ValueError("attachment stored_path is required")
|
||||
now = int(time.time())
|
||||
with write_txn(conn):
|
||||
if not conn.execute(
|
||||
"SELECT 1 FROM tasks WHERE id = ?", (task_id,)
|
||||
).fetchone():
|
||||
raise ValueError(f"unknown task {task_id}")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO task_attachments "
|
||||
"(task_id, filename, stored_path, content_type, size, uploaded_by, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
task_id,
|
||||
filename.strip(),
|
||||
stored_path,
|
||||
content_type,
|
||||
int(size),
|
||||
uploaded_by,
|
||||
now,
|
||||
),
|
||||
)
|
||||
_append_event(
|
||||
conn,
|
||||
task_id,
|
||||
"attached",
|
||||
{"filename": filename.strip(), "size": int(size), "by": uploaded_by},
|
||||
)
|
||||
return int(cur.lastrowid or 0)
|
||||
|
||||
|
||||
def list_attachments(conn: sqlite3.Connection, task_id: str) -> list[Attachment]:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM task_attachments WHERE task_id = ? ORDER BY created_at ASC, id ASC",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
Attachment(
|
||||
id=r["id"],
|
||||
task_id=r["task_id"],
|
||||
filename=r["filename"],
|
||||
stored_path=r["stored_path"],
|
||||
content_type=r["content_type"],
|
||||
size=r["size"] or 0,
|
||||
uploaded_by=r["uploaded_by"],
|
||||
created_at=r["created_at"],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def get_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[Attachment]:
|
||||
r = conn.execute(
|
||||
"SELECT * FROM task_attachments WHERE id = ?", (attachment_id,)
|
||||
).fetchone()
|
||||
if r is None:
|
||||
return None
|
||||
return Attachment(
|
||||
id=r["id"],
|
||||
task_id=r["task_id"],
|
||||
filename=r["filename"],
|
||||
stored_path=r["stored_path"],
|
||||
content_type=r["content_type"],
|
||||
size=r["size"] or 0,
|
||||
uploaded_by=r["uploaded_by"],
|
||||
created_at=r["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def delete_attachment(conn: sqlite3.Connection, attachment_id: int) -> Optional[Attachment]:
|
||||
"""Delete an attachment row and its on-disk blob. Returns the removed row.
|
||||
|
||||
Returns ``None`` when no row matched. The blob is removed best-effort
|
||||
(a missing file is not an error); the metadata row is the source of
|
||||
truth for whether an attachment "exists".
|
||||
"""
|
||||
with write_txn(conn):
|
||||
att = get_attachment(conn, attachment_id)
|
||||
if att is None:
|
||||
return None
|
||||
conn.execute("DELETE FROM task_attachments WHERE id = ?", (attachment_id,))
|
||||
_append_event(
|
||||
conn, att.task_id, "attachment_removed", {"filename": att.filename}
|
||||
)
|
||||
try:
|
||||
p = Path(att.stored_path)
|
||||
if p.is_file():
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return att
|
||||
|
||||
|
||||
def list_events(conn: sqlite3.Connection, task_id: str) -> list[Event]:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at ASC, id ASC",
|
||||
@@ -2821,9 +2457,7 @@ def _has_sticky_block(conn: sqlite3.Connection, task_id: str) -> bool:
|
||||
return bool(row) and row["kind"] == "blocked"
|
||||
|
||||
|
||||
def recompute_ready(
|
||||
conn: sqlite3.Connection, failure_limit: int = None,
|
||||
) -> int:
|
||||
def recompute_ready(conn: sqlite3.Connection) -> int:
|
||||
"""Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``.
|
||||
|
||||
Returns the number of tasks promoted. Safe to call inside or outside
|
||||
@@ -2831,34 +2465,17 @@ def recompute_ready(
|
||||
|
||||
``blocked`` tasks are also considered for promotion (so a task
|
||||
blocked purely by a parent dependency unblocks itself when the
|
||||
parent completes), *except* in two cases:
|
||||
|
||||
1. The most recent block event was a worker-initiated
|
||||
``kanban_block`` — those stay blocked until an explicit
|
||||
``kanban_unblock`` (#28712).
|
||||
|
||||
2. The task's ``consecutive_failures`` has reached the effective
|
||||
failure limit. This prevents infinite retry loops when a task
|
||||
repeatedly exhausts its iteration budget: without this guard the
|
||||
counter would reset on every recovery cycle and the circuit
|
||||
breaker could never trip (#35072).
|
||||
|
||||
The effective failure limit resolves in the same order as the
|
||||
circuit breaker in ``_record_task_failure`` so the two never
|
||||
disagree about when a task is permanently blocked:
|
||||
|
||||
1. per-task ``max_retries`` if set
|
||||
2. caller-supplied ``failure_limit`` (the dispatcher passes the
|
||||
``kanban.failure_limit`` config value through ``dispatch_once``)
|
||||
3. ``DEFAULT_FAILURE_LIMIT``
|
||||
parent completes), *except* when the most recent block event was a
|
||||
worker-initiated ``kanban_block`` — those stay blocked until an
|
||||
explicit ``kanban_unblock`` (#28712). Without that guard, a
|
||||
``review-required`` handoff would auto-respawn, the fresh worker
|
||||
would find nothing to do, exit cleanly, get recorded as a protocol
|
||||
violation, and the cycle would repeat indefinitely.
|
||||
"""
|
||||
if failure_limit is None:
|
||||
failure_limit = DEFAULT_FAILURE_LIMIT
|
||||
promoted = 0
|
||||
with write_txn(conn):
|
||||
todo_rows = conn.execute(
|
||||
"SELECT id, status, consecutive_failures, max_retries "
|
||||
"FROM tasks WHERE status IN ('todo', 'blocked')"
|
||||
"SELECT id, status FROM tasks WHERE status IN ('todo', 'blocked')"
|
||||
).fetchall()
|
||||
for row in todo_rows:
|
||||
task_id = row["id"]
|
||||
@@ -2876,25 +2493,13 @@ def recompute_ready(
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
if all(p["status"] in ("done", "archived") for p in parents):
|
||||
# Blocked tasks also get their failure counters reset —
|
||||
# this is effectively an auto-unblock (circuit-breaker
|
||||
# recovery; worker-initiated blocks are skipped above).
|
||||
if cur_status == "blocked":
|
||||
# Don't auto-recover tasks that have hit the
|
||||
# circuit-breaker failure limit. Without this
|
||||
# guard, a task that repeatedly exhausts its
|
||||
# iteration budget would cycle forever:
|
||||
# block → auto-recover → respawn → budget
|
||||
# exhausted → block → … The counter must also
|
||||
# be preserved so the breaker can accumulate
|
||||
# across recovery cycles.
|
||||
failures = int(row["consecutive_failures"] or 0)
|
||||
task_limit = row["max_retries"]
|
||||
effective_limit = (
|
||||
int(task_limit) if task_limit is not None
|
||||
else int(failure_limit)
|
||||
)
|
||||
if failures >= effective_limit:
|
||||
continue
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status = 'ready' "
|
||||
"UPDATE tasks SET status = 'ready', "
|
||||
"consecutive_failures = 0, last_failure_error = NULL "
|
||||
"WHERE id = ? AND status = 'blocked'",
|
||||
(task_id,),
|
||||
)
|
||||
@@ -5819,7 +5424,7 @@ def dispatch_once(
|
||||
if _crash_auto_blocked:
|
||||
result.auto_blocked.extend(_crash_auto_blocked)
|
||||
result.timed_out = enforce_max_runtime(conn)
|
||||
result.promoted = recompute_ready(conn, failure_limit=failure_limit)
|
||||
result.promoted = recompute_ready(conn)
|
||||
|
||||
# Count tasks already running so max_spawn enforces concurrency rather
|
||||
# than a per-tick spawn budget. See the docstring above for the full
|
||||
@@ -6460,13 +6065,6 @@ def _default_spawn(
|
||||
env["HERMES_KANBAN_RUN_ID"] = str(task.current_run_id)
|
||||
if task.claim_lock:
|
||||
env["HERMES_KANBAN_CLAIM_LOCK"] = task.claim_lock
|
||||
# Goal-loop mode: the worker reads these and wraps its run in the
|
||||
# Ralph-style /goal judge loop (see cli.py quiet-mode path). Only set
|
||||
# when enabled so non-goal tasks keep a clean env.
|
||||
if task.goal_mode:
|
||||
env["HERMES_KANBAN_GOAL_MODE"] = "1"
|
||||
if task.goal_max_turns is not None:
|
||||
env["HERMES_KANBAN_GOAL_MAX_TURNS"] = str(int(task.goal_max_turns))
|
||||
terminal_timeout = _worker_terminal_timeout_env(
|
||||
task.max_runtime_seconds,
|
||||
env.get("TERMINAL_TIMEOUT"),
|
||||
@@ -6702,25 +6300,6 @@ def build_worker_context(conn: sqlite3.Connection, task_id: str) -> str:
|
||||
lines.append(_cap(task.body, _CTX_MAX_BODY_BYTES))
|
||||
lines.append("")
|
||||
|
||||
# Attachments — files uploaded to this task (PDFs, source docs,
|
||||
# images). Surface the absolute on-disk path so the worker, which has
|
||||
# full file-tool access, can read them directly (read_file, terminal
|
||||
# `pdftotext`, etc.). On the local terminal backend the path resolves
|
||||
# as-is; remote backends need the kanban attachments dir mounted.
|
||||
attachments = list_attachments(conn, task_id)
|
||||
if attachments:
|
||||
lines.append("## Attachments")
|
||||
lines.append(
|
||||
"Files attached to this task. Read them with the file/terminal "
|
||||
"tools at the absolute paths below:"
|
||||
)
|
||||
for att in attachments:
|
||||
size_kb = max(1, (att.size + 1023) // 1024) if att.size else 0
|
||||
size_str = f", {size_kb} KB" if size_kb else ""
|
||||
ctype = f", {att.content_type}" if att.content_type else ""
|
||||
lines.append(f"- `{att.filename}`{ctype}{size_str} → `{att.stored_path}`")
|
||||
lines.append("")
|
||||
|
||||
# Prior attempts — show closed runs so a retrying worker sees the
|
||||
# history. Skip the currently-active run (that's this worker).
|
||||
# Cap at _CTX_MAX_PRIOR_ATTEMPTS most-recent closed runs; older
|
||||
|
||||
+119
-503
@@ -65,46 +65,6 @@ import os
|
||||
import sys
|
||||
|
||||
|
||||
def _set_process_title() -> None:
|
||||
"""Set the process title to 'hermes' so tools like 'ps', 'top', and
|
||||
'htop' show the app name instead of 'python3.xx'.
|
||||
|
||||
Purely cosmetic — non-fatal on any platform.
|
||||
|
||||
Strategy (try in order):
|
||||
1. ``setproctitle`` (opt-in dep — installed via ``hermes tools`` or
|
||||
``pip install setproctitle``, or bundled in a future release).
|
||||
2. ctypes ``prctl(PR_SET_NAME)`` (Linux only, 15-char limit).
|
||||
3. ctypes ``pthread_setname_np`` (macOS only, kernel thread name —
|
||||
changes lldb/top but not ``ps aux``).
|
||||
4. No-op on Windows (the .exe name is already ``hermes.exe``).
|
||||
"""
|
||||
# Strategy 1: setproctitle (best — works on macOS, Linux, BSD)
|
||||
try:
|
||||
import setproctitle # type: ignore[import-untyped]
|
||||
|
||||
setproctitle.setproctitle("hermes")
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Strategy 2/3: platform-specific ctypes fallback
|
||||
import ctypes
|
||||
import platform
|
||||
|
||||
try:
|
||||
system = platform.system()
|
||||
if system == "Linux":
|
||||
libc = ctypes.CDLL("libc.so.6", use_errno=True)
|
||||
libc.prctl(15, b"hermes", 0, 0, 0) # PR_SET_NAME = 15
|
||||
elif system == "Darwin":
|
||||
libc = ctypes.CDLL("libc.dylib", use_errno=True)
|
||||
libc.pthread_setname_np(b"hermes")
|
||||
# Windows: the .exe name is already ``hermes.exe`` — nothing to do.
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# Mouse-tracking residue suppression — runs BEFORE every other import on the
|
||||
# TUI hot path so the terminal stops emitting SGR/X10 mouse reports while the
|
||||
# Python launcher is still doing imports (≈100–300ms in cooked + echo mode,
|
||||
@@ -2425,12 +2385,7 @@ def select_provider_and_model(args=None):
|
||||
if active == "openrouter" and get_env_value("OPENAI_BASE_URL"):
|
||||
active = "custom"
|
||||
|
||||
from hermes_cli.models import (
|
||||
CANONICAL_PROVIDERS,
|
||||
_PROVIDER_LABELS,
|
||||
group_providers,
|
||||
provider_group_for_slug,
|
||||
)
|
||||
from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS
|
||||
|
||||
provider_labels = dict(_PROVIDER_LABELS) # derive from canonical list
|
||||
if active and active in _custom_provider_map:
|
||||
@@ -2443,44 +2398,8 @@ def select_provider_and_model(args=None):
|
||||
print(f" Active provider: {active_label}")
|
||||
print()
|
||||
|
||||
# Step 1: Provider selection.
|
||||
#
|
||||
# Canonical providers are folded into top-level groups (display only — see
|
||||
# PROVIDER_GROUPS in hermes_cli/models.py). A multi-member group shows one
|
||||
# row ("Kimi / Moonshot ▸"); picking it opens a member sub-picker that
|
||||
# resolves back to a concrete slug, so the dispatch chain below is
|
||||
# unchanged. Custom providers and the trailing actions stay flat.
|
||||
canonical_descs = {p.slug: p.tui_desc for p in CANONICAL_PROVIDERS}
|
||||
grouped_rows = group_providers([p.slug for p in CANONICAL_PROVIDERS])
|
||||
|
||||
# The group/slug that should be pre-selected: the active provider's group
|
||||
# if it's grouped, otherwise the active slug itself.
|
||||
active_group = provider_group_for_slug(active) if active else ""
|
||||
|
||||
# ordered entries: (key, label, members)
|
||||
# members == [] → leaf row, key is a provider slug / action
|
||||
# members != [] → group row, key is "group:<gid>"
|
||||
ordered: list[tuple[str, str, list[str]]] = []
|
||||
default_idx = 0
|
||||
for row in grouped_rows:
|
||||
if row["kind"] == "group":
|
||||
gid = row["group_id"]
|
||||
group_desc = row.get("description", "")
|
||||
label = f"{row['label']} ▸ ({group_desc})" if group_desc else f"{row['label']} ▸"
|
||||
key = f"group:{gid}"
|
||||
is_active = bool(active_group) and gid == active_group
|
||||
members = row["members"]
|
||||
else:
|
||||
slug = row["slug"]
|
||||
label = canonical_descs.get(slug, provider_labels.get(slug, slug))
|
||||
key = slug
|
||||
is_active = bool(active) and slug == active
|
||||
members = []
|
||||
if is_active:
|
||||
ordered.append((key, f"{label} ← currently active", members))
|
||||
default_idx = len(ordered) - 1
|
||||
else:
|
||||
ordered.append((key, label, members))
|
||||
# Step 1: Provider selection — flat list from CANONICAL_PROVIDERS
|
||||
all_providers = [(p.slug, p.tui_desc) for p in CANONICAL_PROVIDERS]
|
||||
|
||||
for key, provider_info in _custom_provider_map.items():
|
||||
name = provider_info["name"]
|
||||
@@ -2488,50 +2407,36 @@ def select_provider_and_model(args=None):
|
||||
short_url = base_url.replace("https://", "").replace("http://", "").rstrip("/")
|
||||
saved_model = provider_info.get("model", "")
|
||||
model_hint = f" — {saved_model}" if saved_model else ""
|
||||
label = f"{name} ({short_url}){model_hint}"
|
||||
all_providers.append((key, f"{name} ({short_url}){model_hint}"))
|
||||
|
||||
# Build the menu
|
||||
ordered = []
|
||||
default_idx = 0
|
||||
for key, label in all_providers:
|
||||
if active and key == active:
|
||||
ordered.append((key, f"{label} ← currently active", []))
|
||||
ordered.append((key, f"{label} ← currently active"))
|
||||
default_idx = len(ordered) - 1
|
||||
else:
|
||||
ordered.append((key, label, []))
|
||||
ordered.append((key, label))
|
||||
|
||||
ordered.append(("custom", "Custom endpoint (enter URL manually)", []))
|
||||
ordered.append(("custom", "Custom endpoint (enter URL manually)"))
|
||||
_has_saved_custom_list = isinstance(config.get("custom_providers"), list) and bool(
|
||||
config.get("custom_providers")
|
||||
)
|
||||
if _has_saved_custom_list:
|
||||
ordered.append(("remove-custom", "Remove a saved custom provider", []))
|
||||
ordered.append(("aux-config", "Configure auxiliary models...", []))
|
||||
ordered.append(("cancel", "Leave unchanged", []))
|
||||
ordered.append(("remove-custom", "Remove a saved custom provider"))
|
||||
ordered.append(("aux-config", "Configure auxiliary models..."))
|
||||
ordered.append(("cancel", "Leave unchanged"))
|
||||
|
||||
provider_idx = _prompt_provider_choice(
|
||||
[label for _, label, _ in ordered],
|
||||
[label for _, label in ordered],
|
||||
default=default_idx,
|
||||
)
|
||||
if provider_idx is None or ordered[provider_idx][0] == "cancel":
|
||||
print("No change.")
|
||||
return
|
||||
|
||||
selected_key = ordered[provider_idx][0]
|
||||
selected_members = ordered[provider_idx][2]
|
||||
|
||||
# Group row → drill into a member sub-picker. Default to the active member
|
||||
# if the active provider lives in this group. The descriptive text lives on
|
||||
# the group row itself, so member rows show only their short label here.
|
||||
if selected_members:
|
||||
member_default = 0
|
||||
if active in selected_members:
|
||||
member_default = selected_members.index(active)
|
||||
member_labels = [
|
||||
provider_labels.get(m, m) for m in selected_members
|
||||
]
|
||||
member_idx = _prompt_provider_choice(member_labels, default=member_default)
|
||||
if member_idx is None:
|
||||
print("No change.")
|
||||
return
|
||||
selected_provider = selected_members[member_idx]
|
||||
else:
|
||||
selected_provider = selected_key
|
||||
selected_provider = ordered[provider_idx][0]
|
||||
|
||||
if selected_provider == "aux-config":
|
||||
_aux_config_menu()
|
||||
@@ -4488,17 +4393,23 @@ def _remove_custom_provider(config):
|
||||
choices.append("Cancel")
|
||||
|
||||
try:
|
||||
from hermes_cli.curses_ui import curses_radiolist
|
||||
from simple_term_menu import TerminalMenu
|
||||
|
||||
idx = curses_radiolist(
|
||||
"Select provider to remove:",
|
||||
list(choices),
|
||||
selected=0,
|
||||
cancel_returns=-1,
|
||||
menu = TerminalMenu(
|
||||
[f" {c}" for c in choices],
|
||||
cursor_index=0,
|
||||
menu_cursor="-> ",
|
||||
menu_cursor_style=("fg_red", "bold"),
|
||||
menu_highlight_style=("fg_red",),
|
||||
cycle_cursor=True,
|
||||
clear_screen=False,
|
||||
title="Select provider to remove:",
|
||||
)
|
||||
idx = menu.show()
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
|
||||
flush_stdin()
|
||||
print()
|
||||
if idx < 0:
|
||||
idx = None
|
||||
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
|
||||
for i, c in enumerate(choices, 1):
|
||||
print(f" {i}. {c}")
|
||||
@@ -4565,19 +4476,27 @@ def _model_flow_named_custom(config, provider_info):
|
||||
|
||||
print(f"Found {len(models)} model(s):\n")
|
||||
try:
|
||||
from hermes_cli.curses_ui import curses_radiolist
|
||||
from simple_term_menu import TerminalMenu
|
||||
|
||||
menu_items = [
|
||||
f"{m} (current)" if m == saved_model else m for m in models
|
||||
] + ["Cancel"]
|
||||
idx = curses_radiolist(
|
||||
f"Select model from {name}:",
|
||||
f" {m} (current)" if m == saved_model else f" {m}" for m in models
|
||||
] + [" Cancel"]
|
||||
menu = TerminalMenu(
|
||||
menu_items,
|
||||
selected=default_idx,
|
||||
cancel_returns=-1,
|
||||
cursor_index=default_idx,
|
||||
menu_cursor="-> ",
|
||||
menu_cursor_style=("fg_green", "bold"),
|
||||
menu_highlight_style=("fg_green",),
|
||||
cycle_cursor=True,
|
||||
clear_screen=False,
|
||||
title=f"Select model from {name}:",
|
||||
)
|
||||
idx = menu.show()
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
|
||||
flush_stdin()
|
||||
print()
|
||||
if idx < 0 or idx >= len(models):
|
||||
if idx is None or idx >= len(models):
|
||||
print("Cancelled.")
|
||||
return
|
||||
model_name = models[idx]
|
||||
@@ -4754,18 +4673,26 @@ def _prompt_reasoning_effort_selection(efforts, current_effort=""):
|
||||
default_idx = 0
|
||||
|
||||
try:
|
||||
from hermes_cli.curses_ui import curses_radiolist
|
||||
from simple_term_menu import TerminalMenu
|
||||
|
||||
choices = [_label(effort) for effort in ordered]
|
||||
choices.append(disable_label)
|
||||
choices.append(skip_label)
|
||||
idx = curses_radiolist(
|
||||
"Select reasoning effort:",
|
||||
choices = [f" {_label(effort)}" for effort in ordered]
|
||||
choices.append(f" {disable_label}")
|
||||
choices.append(f" {skip_label}")
|
||||
menu = TerminalMenu(
|
||||
choices,
|
||||
selected=default_idx,
|
||||
cancel_returns=-1,
|
||||
cursor_index=default_idx,
|
||||
menu_cursor="-> ",
|
||||
menu_cursor_style=("fg_green", "bold"),
|
||||
menu_highlight_style=("fg_green",),
|
||||
cycle_cursor=True,
|
||||
clear_screen=False,
|
||||
title="Select reasoning effort:",
|
||||
)
|
||||
if idx < 0:
|
||||
idx = menu.show()
|
||||
from hermes_cli.curses_ui import flush_stdin
|
||||
|
||||
flush_stdin()
|
||||
if idx is None:
|
||||
return None
|
||||
print()
|
||||
if idx < len(ordered):
|
||||
@@ -6891,43 +6818,6 @@ def _desktop_packaged_executable(desktop_dir: Path) -> Optional[Path]:
|
||||
return max(existing, key=lambda p: p.stat().st_mtime)
|
||||
|
||||
|
||||
def _desktop_macos_relaunchable_fixup(desktop_dir: Path) -> None:
|
||||
"""Make a locally-built (unsigned) macOS desktop app survive in-place self-update.
|
||||
|
||||
An ad-hoc-signed .app has no stable Designated Requirement (no Team ID), so
|
||||
when the self-updater rebuilds the bundle in place with a fresh build (a new,
|
||||
different cdhash) Gatekeeper/LaunchServices treats the changed code as
|
||||
tampering and macOS reports "Hermes is damaged and can't be opened." The
|
||||
bundle also inherits the com.apple.quarantine flag from the downloaded
|
||||
installer process chain. Both make the relaunch fail.
|
||||
|
||||
Clearing the quarantine xattrs and re-applying a clean deep ad-hoc signature
|
||||
(omitting the hardened-runtime flag, which is meaningless without a real
|
||||
Developer ID) lets the rebuilt app relaunch. No-op when a real signing
|
||||
identity is configured (CSC_LINK / APPLE_SIGNING_IDENTITY) so a properly
|
||||
signed/notarized build is never clobbered. Best-effort: never raises.
|
||||
"""
|
||||
if sys.platform != "darwin":
|
||||
return
|
||||
if os.environ.get("CSC_LINK") or os.environ.get("APPLE_SIGNING_IDENTITY"):
|
||||
return
|
||||
exe = _desktop_packaged_executable(desktop_dir)
|
||||
if exe is None:
|
||||
return
|
||||
# exe = .../Hermes.app/Contents/MacOS/Hermes -> app bundle = .../Hermes.app
|
||||
app = exe.parents[2]
|
||||
if not str(app).endswith(".app") or not app.is_dir():
|
||||
return
|
||||
codesign = shutil.which("codesign")
|
||||
if not codesign:
|
||||
return
|
||||
try:
|
||||
subprocess.run(["xattr", "-cr", str(app)], check=False)
|
||||
subprocess.run([codesign, "--force", "--deep", "--sign", "-", str(app)], check=False)
|
||||
except Exception as exc:
|
||||
print(f" (warning: macOS relaunch fixup skipped: {exc})")
|
||||
|
||||
|
||||
def cmd_gui(args):
|
||||
"""Build and launch the native Electron desktop GUI."""
|
||||
desktop_dir = PROJECT_ROOT / "apps" / "desktop"
|
||||
@@ -7001,11 +6891,6 @@ def cmd_gui(args):
|
||||
print(f" Run manually: cd apps/desktop && npm run {build_script}")
|
||||
sys.exit(build_result.returncode or 1)
|
||||
packaged_executable = _desktop_packaged_executable(desktop_dir)
|
||||
if not source_mode:
|
||||
# Locally-built apps are ad-hoc signed; make them relaunchable after
|
||||
# an in-place self-update (otherwise macOS reports "Hermes is
|
||||
# damaged"). No-op on non-macOS and on real-identity builds.
|
||||
_desktop_macos_relaunchable_fixup(desktop_dir)
|
||||
|
||||
# --build-only: produce the artifact but do NOT launch. The installer's
|
||||
# --update flow drives the rebuild headlessly and then launches the desktop
|
||||
@@ -8123,6 +8008,39 @@ def _detect_concurrent_hermes_instances(
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# Build a set of PIDs to exclude: the Python process itself plus its
|
||||
# entire parent chain. On Windows the setuptools-generated hermes.exe
|
||||
# launcher is a separate native process that spawns python.exe (the
|
||||
# interpreter that runs our code). os.getpid() returns the Python PID,
|
||||
# but the launcher (which holds the file lock) is the parent. Without
|
||||
# walking the parent chain, every ``hermes update`` reports its own
|
||||
# launcher as a concurrent instance — a false positive.
|
||||
if exclude_pid is not None:
|
||||
exclude_pids: set[int] = {exclude_pid}
|
||||
else:
|
||||
exclude_pids = {os.getpid()}
|
||||
# The parent-walk is best-effort: if psutil rejects a PID (NoSuchProcess /
|
||||
# AccessDenied) we stop walking and use whatever we've collected so far.
|
||||
# Broader Exception catch on the outer block guards against partially-
|
||||
# stubbed psutil in unit tests (e.g. a SimpleNamespace lacking Process /
|
||||
# NoSuchProcess) — the surrounding update flow documents this helper as
|
||||
# "never raises".
|
||||
try:
|
||||
current = psutil.Process(next(iter(exclude_pids)))
|
||||
while True:
|
||||
try:
|
||||
parent = current.parent()
|
||||
except Exception:
|
||||
break
|
||||
if parent is None or parent.pid <= 0:
|
||||
break
|
||||
if parent.pid in exclude_pids:
|
||||
break # loop detected
|
||||
exclude_pids.add(parent.pid)
|
||||
current = parent
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Resolve every shim path to its canonical form once for cheap comparison.
|
||||
shim_paths: set[str] = set()
|
||||
for shim in _hermes_exe_shims(scripts_dir):
|
||||
@@ -8133,56 +8051,6 @@ def _detect_concurrent_hermes_instances(
|
||||
if not shim_paths:
|
||||
return []
|
||||
|
||||
# Build a set of PIDs to exclude: the Python process itself plus every
|
||||
# ancestor whose executable is one of our shims. On Windows the
|
||||
# setuptools-generated hermes.exe launcher is a separate native process
|
||||
# that spawns python.exe (the interpreter that runs our code).
|
||||
# os.getpid() returns the Python PID, but the launcher (which holds the
|
||||
# file lock) is the parent. Without excluding it, every ``hermes update``
|
||||
# reports its own launcher as a concurrent instance — a false positive
|
||||
# (issues #29341, #34795).
|
||||
#
|
||||
# Two robustness points learned from the field:
|
||||
# 1. Use ``proc.parents()`` — it returns the WHOLE ancestor list in one
|
||||
# call. The earlier per-hop ``current.parent()`` loop bailed on the
|
||||
# first psutil error (AccessDenied/NoSuchProcess is common on Windows
|
||||
# across session/elevation boundaries), leaving the launcher shim in
|
||||
# the candidate set and re-triggering the false positive.
|
||||
# 2. Only exclude ancestors whose exe is itself a shim. A genuine second
|
||||
# hermes.exe sitting *under* a non-Hermes parent (e.g. a Hermes
|
||||
# Desktop backend child) must still be flagged, so we don't blanket-
|
||||
# exclude unrelated ancestors like the shell or terminal.
|
||||
# Broad ``except Exception`` guards against partially-stubbed psutil in
|
||||
# unit tests; this helper is documented as "never raises".
|
||||
if exclude_pid is not None:
|
||||
exclude_pids: set[int] = {int(exclude_pid)}
|
||||
else:
|
||||
exclude_pids = {os.getpid()}
|
||||
try:
|
||||
seed = next(iter(exclude_pids))
|
||||
try:
|
||||
ancestors = psutil.Process(seed).parents()
|
||||
except Exception:
|
||||
ancestors = []
|
||||
for ancestor in ancestors:
|
||||
try:
|
||||
anc_exe = ancestor.exe()
|
||||
except Exception:
|
||||
continue
|
||||
if not anc_exe:
|
||||
continue
|
||||
try:
|
||||
anc_norm = str(Path(anc_exe).resolve()).lower()
|
||||
except (OSError, ValueError):
|
||||
anc_norm = str(anc_exe).lower()
|
||||
if anc_norm in shim_paths:
|
||||
try:
|
||||
exclude_pids.add(int(ancestor.pid))
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
matches: list[tuple[int, str]] = []
|
||||
try:
|
||||
proc_iter = psutil.process_iter(["pid", "exe", "name"])
|
||||
@@ -8223,13 +8091,6 @@ def _format_concurrent_instances_message(
|
||||
lines.append("")
|
||||
lines.append(" Close Hermes Desktop, exit any open `hermes` REPLs, and")
|
||||
lines.append(" stop the gateway (`hermes gateway stop`) before retrying.")
|
||||
lines.append("")
|
||||
if matches:
|
||||
pid_args = " ".join(f"/PID {pid}" for pid, _ in matches)
|
||||
lines.append(" If you've already closed everything and these PIDs are")
|
||||
lines.append(" stale, terminate them directly, then retry the update:")
|
||||
lines.append(f" taskkill {pid_args} /F")
|
||||
lines.append("")
|
||||
lines.append(" Override with `hermes update --force` if you've already")
|
||||
lines.append(" confirmed those processes will not write to the venv.")
|
||||
return "\n".join(lines)
|
||||
@@ -9141,43 +9002,6 @@ def _run_pre_update_backup(args) -> None:
|
||||
print()
|
||||
|
||||
|
||||
def _discard_lockfile_churn(git_cmd, repo_root):
|
||||
"""Restore tracked ``package-lock.json`` files that npm dirtied locally.
|
||||
|
||||
npm rewrites lockfiles non-deterministically at install/build time. On a
|
||||
managed install those diffs are never intentional, so we discard them so
|
||||
``hermes update`` sees a clean tree instead of autostashing every run.
|
||||
Best-effort; only ever touches files named ``package-lock.json``.
|
||||
"""
|
||||
try:
|
||||
diff = subprocess.run(
|
||||
git_cmd + ["diff", "--name-only"],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if diff.returncode != 0:
|
||||
return
|
||||
dirty = [
|
||||
line.strip()
|
||||
for line in diff.stdout.splitlines()
|
||||
if line.strip().endswith("package-lock.json")
|
||||
]
|
||||
if not dirty:
|
||||
return
|
||||
subprocess.run(
|
||||
git_cmd + ["checkout", "--", *dirty],
|
||||
cwd=repo_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
print(f"→ Discarded npm lockfile churn ({len(dirty)} file(s))")
|
||||
except Exception:
|
||||
# Never let lockfile cleanup block an update.
|
||||
pass
|
||||
|
||||
|
||||
def cmd_update(args):
|
||||
"""Update Hermes Agent to the latest version.
|
||||
|
||||
@@ -9231,51 +9055,18 @@ def cmd_update(args):
|
||||
def _cmd_update_pip(args):
|
||||
"""Update Hermes via pip (for PyPI installs)."""
|
||||
from hermes_cli import __version__
|
||||
from hermes_cli.config import is_uv_tool_install
|
||||
|
||||
print(f"→ Current version: {__version__}")
|
||||
print("→ Checking PyPI for updates...")
|
||||
|
||||
uv = shutil.which("uv")
|
||||
in_venv = sys.prefix != sys.base_prefix
|
||||
# pipx-managed installs live under .../pipx/venvs/<name>/...
|
||||
pipx_managed = "pipx" in sys.prefix.split(os.sep)
|
||||
pipx = shutil.which("pipx") if pipx_managed else None
|
||||
|
||||
# Only the ``uv pip install`` path inside a venv needs VIRTUAL_ENV
|
||||
# exported (uv refuses to install without it when the launcher shim
|
||||
# didn't activate the venv). ``uv tool upgrade`` / ``pipx upgrade``
|
||||
# operate on a named environment and ignore VIRTUAL_ENV, so we don't
|
||||
# set it for them.
|
||||
export_virtualenv = False
|
||||
|
||||
if is_uv_tool_install():
|
||||
if not uv:
|
||||
print("✗ Detected a uv-tool install but `uv` is not on PATH; install uv and retry.")
|
||||
sys.exit(1)
|
||||
cmd = [uv, "tool", "upgrade", "hermes-agent"]
|
||||
elif pipx_managed and pipx:
|
||||
# pipx owns its own venv; ``pipx upgrade`` is the only correct path.
|
||||
# Matches scripts/auto-update.sh, which already uses pipx upgrade.
|
||||
cmd = [pipx, "upgrade", "hermes-agent"]
|
||||
elif uv:
|
||||
if uv:
|
||||
cmd = [uv, "pip", "install", "--upgrade", "hermes-agent"]
|
||||
if in_venv:
|
||||
# Launcher shim runs the venv interpreter but doesn't export
|
||||
# VIRTUAL_ENV; without it uv errors "No virtual environment found".
|
||||
export_virtualenv = True
|
||||
else:
|
||||
# Outside any venv, ``--system`` lets uv target the active
|
||||
# interpreter, matching pip's default behaviour.
|
||||
cmd.insert(3, "--system")
|
||||
else:
|
||||
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "hermes-agent"]
|
||||
|
||||
print(f"→ Running: {' '.join(cmd)}")
|
||||
run_kwargs = {}
|
||||
if export_virtualenv:
|
||||
run_kwargs["env"] = {**os.environ, "VIRTUAL_ENV": sys.prefix}
|
||||
result = subprocess.run(cmd, **run_kwargs)
|
||||
result = subprocess.run(cmd)
|
||||
if result.returncode != 0:
|
||||
print("✗ Update failed")
|
||||
sys.exit(1)
|
||||
@@ -9355,15 +9146,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
if sys.platform == "win32":
|
||||
git_cmd = ["git", "-c", "windows.appendAtomically=false"]
|
||||
|
||||
# Discard npm lockfile churn before any stash/branch logic. npm rewrites
|
||||
# tracked package-lock.json files non-deterministically at install/build
|
||||
# time (platform-specific optional deps, ideallyInert annotations, etc.),
|
||||
# which is never an intentional edit on a managed install but leaves the
|
||||
# tree dirty — forcing an autostash on every update and making branch
|
||||
# switches fragile. Restoring them first lets the common case (only
|
||||
# lockfile churn) update with a clean tree.
|
||||
_discard_lockfile_churn(git_cmd, PROJECT_ROOT)
|
||||
|
||||
# Detect if we're updating from a fork (before any branch logic)
|
||||
origin_url = _get_origin_url(git_cmd, PROJECT_ROOT)
|
||||
is_fork = _is_fork(origin_url)
|
||||
@@ -9798,61 +9580,16 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
missing_config = get_missing_config_fields()
|
||||
current_ver, latest_ver = check_config_version()
|
||||
|
||||
has_new_options = bool(missing_env or missing_config)
|
||||
version_bump_only = (
|
||||
not has_new_options and current_ver < latest_ver
|
||||
)
|
||||
needs_migration = has_new_options or current_ver < latest_ver
|
||||
needs_migration = missing_env or missing_config or current_ver < latest_ver
|
||||
|
||||
if version_bump_only:
|
||||
# Nothing for the user to fill in — only the config format version
|
||||
# changed (new defaults already merge in transparently). Asking
|
||||
# "configure new options now?" here is misleading: saying yes just
|
||||
# bumps the version and looks like a no-op (issue: ScottFive /
|
||||
# Tt2021). Apply it silently and say what actually happened.
|
||||
if needs_migration:
|
||||
print()
|
||||
print(
|
||||
f" ℹ Updating config format (v{current_ver} → v{latest_ver})…"
|
||||
)
|
||||
try:
|
||||
migrate_config(interactive=False, quiet=True)
|
||||
print(" ✓ Config format updated (no new settings to configure)")
|
||||
except Exception as _mig_err:
|
||||
print(f" ⚠️ Config format update failed: {_mig_err}")
|
||||
print(" Run 'hermes config migrate' to retry.")
|
||||
elif needs_migration:
|
||||
print()
|
||||
# Show WHAT changed, not just a count, so the user can make an
|
||||
# informed yes/no decision (previously the prompt named nothing).
|
||||
def _print_items(items, label, key, fallback_key=None):
|
||||
if not items:
|
||||
return
|
||||
print(f" {label}:")
|
||||
shown = items[:8]
|
||||
for it in shown:
|
||||
if isinstance(it, dict):
|
||||
name = it.get(key) or (fallback_key and it.get(fallback_key)) or "?"
|
||||
desc = (it.get("description") or "").strip()
|
||||
else:
|
||||
# Defensive: some callers/mocks pass bare name strings.
|
||||
name = str(it)
|
||||
desc = ""
|
||||
if desc:
|
||||
print(f" • {name} — {desc}")
|
||||
else:
|
||||
print(f" • {name}")
|
||||
extra = len(items) - len(shown)
|
||||
if extra > 0:
|
||||
print(f" … and {extra} more")
|
||||
|
||||
if missing_env:
|
||||
print(
|
||||
f" ⚠️ {len(missing_env)} new required setting(s) need configuration"
|
||||
)
|
||||
_print_items(missing_env, "New settings", "name")
|
||||
if missing_config:
|
||||
print(f" ℹ️ {len(missing_config)} new config option(s) available")
|
||||
_print_items(missing_config, "New options", "key")
|
||||
|
||||
print()
|
||||
if assume_yes:
|
||||
@@ -10174,7 +9911,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# agent runs drain instead of being SIGKILLed.
|
||||
# The gateway's SIGUSR1 handler calls
|
||||
# request_restart(via_service=True) → drain →
|
||||
# exit; systemd's Restart=always respawns the unit.
|
||||
# exit(75); systemd's Restart=on-failure (and
|
||||
# RestartForceExitStatus=75) respawns the unit.
|
||||
_main_pid = 0
|
||||
try:
|
||||
_show = subprocess.run(
|
||||
@@ -10208,9 +9946,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
)
|
||||
|
||||
if _graceful_ok:
|
||||
# Gateway exited after a planned restart.
|
||||
# ``Restart=always`` means systemd WILL respawn
|
||||
# the unit — but only after
|
||||
# Gateway exited 75. ``Restart=always`` +
|
||||
# ``RestartForceExitStatus=75`` means systemd
|
||||
# WILL respawn the unit — but only after
|
||||
# ``RestartSec`` (default 60s on our unit
|
||||
# file). That 60s wait is a crash-loop guard,
|
||||
# and is the right default when the gateway
|
||||
@@ -11419,13 +11157,6 @@ def cmd_completion(args, parser=None):
|
||||
print(generate_bash(parser))
|
||||
|
||||
|
||||
def cmd_prompt_size(args):
|
||||
"""Show a byte/char breakdown of the system prompt + tool schemas."""
|
||||
from hermes_cli.prompt_size import cmd_prompt_size as _impl
|
||||
|
||||
_impl(args)
|
||||
|
||||
|
||||
def cmd_logs(args):
|
||||
"""View and filter Hermes log files."""
|
||||
from hermes_cli.logs import tail_log, list_logs
|
||||
@@ -11462,7 +11193,6 @@ _BUILTIN_SUBCOMMANDS = frozenset(
|
||||
"dump", "fallback", "gateway", "hooks", "import", "insights",
|
||||
"gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate",
|
||||
"model", "pairing", "plugins", "portal", "postinstall", "profile", "proxy",
|
||||
"prompt-size",
|
||||
"send", "sessions", "setup",
|
||||
"skills", "slack", "status", "tools", "uninstall", "update",
|
||||
"version", "webhook", "whatsapp", "chat", "secrets", "security",
|
||||
@@ -11563,26 +11293,6 @@ _AGENT_SUBCOMMANDS = {
|
||||
}
|
||||
|
||||
|
||||
def _is_tui_chat_launch(args) -> bool:
|
||||
return bool(getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1")
|
||||
|
||||
|
||||
def _command_has_dedicated_mcp_startup(args) -> bool:
|
||||
if args.command == "acp":
|
||||
return True
|
||||
if args.command == "gateway" and getattr(args, "gateway_command", None) == "run":
|
||||
return True
|
||||
if args.command == "cron" and getattr(args, "cron_command", None) in {"run", "tick"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _should_background_mcp_startup(args) -> bool:
|
||||
if _is_tui_chat_launch(args):
|
||||
return False
|
||||
return args.command in {None, "chat", "rl"}
|
||||
|
||||
|
||||
def _prepare_agent_startup(args) -> None:
|
||||
"""Discover plugins/MCP/hooks for commands that can run an agent turn."""
|
||||
_sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))
|
||||
@@ -11602,42 +11312,19 @@ def _prepare_agent_startup(args) -> None:
|
||||
"plugin discovery failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
_run_inline_mcp_discovery = True
|
||||
if _is_tui_chat_launch(args):
|
||||
# The TUI launcher hands off to a dedicated startup path that already
|
||||
# backgrounds MCP discovery with a bounded join before the first tool
|
||||
# snapshot.
|
||||
_run_inline_mcp_discovery = False
|
||||
elif _command_has_dedicated_mcp_startup(args):
|
||||
# These entrypoints already do their own MCP startup later on the real
|
||||
# runtime path (gateway executor, ACP launcher, cron job runner).
|
||||
_run_inline_mcp_discovery = False
|
||||
elif _should_background_mcp_startup(args):
|
||||
try:
|
||||
from hermes_cli.mcp_startup import start_background_mcp_discovery
|
||||
try:
|
||||
# MCP tool discovery — no event loop running in CLI/TUI startup,
|
||||
# so inline is safe. Moved here from model_tools.py module scope
|
||||
# to avoid freezing the gateway's event loop on its first message
|
||||
# via the same lazy import path (#16856).
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
|
||||
start_background_mcp_discovery(
|
||||
logger=logger,
|
||||
thread_name="cli-mcp-discovery",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Background MCP tool discovery failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
_run_inline_mcp_discovery = False
|
||||
if _run_inline_mcp_discovery:
|
||||
try:
|
||||
# MCP tool discovery remains synchronous for entrypoints that do
|
||||
# not own a later bounded/executor startup path.
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
|
||||
discover_mcp_tools()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"MCP tool discovery failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
discover_mcp_tools()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"MCP tool discovery failed at CLI startup",
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from agent.shell_hooks import register_from_config
|
||||
@@ -11778,10 +11465,6 @@ def _try_termux_fast_tui_launch() -> bool:
|
||||
|
||||
def main():
|
||||
"""Main entry point for hermes CLI."""
|
||||
# Cosmetic: make the process show up as 'hermes' instead of 'python3.11'
|
||||
# in ps/top/htop. Non-fatal — just a nicer UX.
|
||||
_set_process_title()
|
||||
|
||||
# Force UTF-8 stdio on Windows before anything prints. No-op elsewhere.
|
||||
try:
|
||||
from hermes_cli.stdio import configure_windows_stdio
|
||||
@@ -13267,43 +12950,6 @@ Examples:
|
||||
help="Skip confirmation prompt when using --restore",
|
||||
)
|
||||
|
||||
skills_opt_out = skills_subparsers.add_parser(
|
||||
"opt-out",
|
||||
help="Stop bundled skills from being seeded into this profile",
|
||||
description=(
|
||||
"Write the .no-bundled-skills marker so the installer, "
|
||||
"`hermes update`, and any direct sync stop seeding bundled skills "
|
||||
"into the active profile. By default nothing already on disk is "
|
||||
"touched. Pass --remove to ALSO delete bundled skills that are "
|
||||
"unmodified (user-edited and hub/local skills are never removed)."
|
||||
),
|
||||
)
|
||||
skills_opt_out.add_argument(
|
||||
"--remove",
|
||||
action="store_true",
|
||||
help="Also delete already-present unmodified bundled skills",
|
||||
)
|
||||
skills_opt_out.add_argument(
|
||||
"--yes",
|
||||
"-y",
|
||||
action="store_true",
|
||||
help="Skip confirmation prompt when using --remove",
|
||||
)
|
||||
|
||||
skills_opt_in = skills_subparsers.add_parser(
|
||||
"opt-in",
|
||||
help="Re-enable bundled-skill seeding (undo opt-out)",
|
||||
description=(
|
||||
"Remove the .no-bundled-skills marker so bundled skills are seeded "
|
||||
"again on the next `hermes update`. Pass --sync to re-seed now."
|
||||
),
|
||||
)
|
||||
skills_opt_in.add_argument(
|
||||
"--sync",
|
||||
action="store_true",
|
||||
help="Re-seed bundled skills immediately instead of waiting for update",
|
||||
)
|
||||
|
||||
skills_repair_official = skills_subparsers.add_parser(
|
||||
"repair-official",
|
||||
help="Backfill or restore official optional skills from repo source",
|
||||
@@ -13572,15 +13218,9 @@ Examples:
|
||||
),
|
||||
)
|
||||
memory_sub = memory_parser.add_subparsers(dest="memory_command")
|
||||
_setup_parser = memory_sub.add_parser(
|
||||
memory_sub.add_parser(
|
||||
"setup", help="Interactive provider selection and configuration"
|
||||
)
|
||||
_setup_parser.add_argument(
|
||||
"provider",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help="Provider to configure directly (e.g. honcho), skipping the picker",
|
||||
)
|
||||
memory_sub.add_parser("status", help="Show current memory provider config")
|
||||
memory_sub.add_parser("off", help="Disable external provider (built-in only)")
|
||||
_reset_parser = memory_sub.add_parser(
|
||||
@@ -14831,30 +14471,6 @@ Examples:
|
||||
)
|
||||
logs_parser.set_defaults(func=cmd_logs)
|
||||
|
||||
# =========================================================================
|
||||
# prompt-size command
|
||||
# =========================================================================
|
||||
prompt_size_parser = subparsers.add_parser(
|
||||
"prompt-size",
|
||||
help="Show a byte breakdown of the system prompt + tool schemas",
|
||||
description=(
|
||||
"Report the fixed prompt budget for a fresh session: system "
|
||||
"prompt total, skills index, memory, user profile, and tool-schema "
|
||||
"JSON. Runs offline (no API call)."
|
||||
),
|
||||
)
|
||||
prompt_size_parser.add_argument(
|
||||
"--platform",
|
||||
default="cli",
|
||||
help="Platform to simulate (cli, telegram, discord, ...). Default: cli",
|
||||
)
|
||||
prompt_size_parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Emit the breakdown as JSON",
|
||||
)
|
||||
prompt_size_parser.set_defaults(func=cmd_prompt_size)
|
||||
|
||||
# =========================================================================
|
||||
# Parse and execute
|
||||
# =========================================================================
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Shared CLI/TUI-safe helpers for background MCP discovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
_mcp_discovery_lock = threading.Lock()
|
||||
_mcp_discovery_started = False
|
||||
_mcp_discovery_thread: Optional[threading.Thread] = None
|
||||
|
||||
|
||||
def _has_configured_mcp_servers() -> bool:
|
||||
"""Cheap config probe so non-MCP users avoid importing the MCP stack."""
|
||||
try:
|
||||
from hermes_cli.config import read_raw_config
|
||||
|
||||
mcp_servers = (read_raw_config() or {}).get("mcp_servers")
|
||||
return isinstance(mcp_servers, dict) and len(mcp_servers) > 0
|
||||
except Exception:
|
||||
# Be conservative: if config probing fails, try discovery in the
|
||||
# background so startup still can't block.
|
||||
return True
|
||||
|
||||
|
||||
def start_background_mcp_discovery(*, logger, thread_name: str) -> None:
|
||||
"""Spawn one shared background MCP discovery thread for this process."""
|
||||
global _mcp_discovery_started, _mcp_discovery_thread
|
||||
|
||||
with _mcp_discovery_lock:
|
||||
if _mcp_discovery_started:
|
||||
return
|
||||
_mcp_discovery_started = True
|
||||
if not _has_configured_mcp_servers():
|
||||
return
|
||||
|
||||
def _discover() -> None:
|
||||
try:
|
||||
from tools.mcp_tool import discover_mcp_tools
|
||||
|
||||
discover_mcp_tools()
|
||||
except Exception:
|
||||
logger.debug("Background MCP tool discovery failed", exc_info=True)
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_discover,
|
||||
name=thread_name,
|
||||
daemon=True,
|
||||
)
|
||||
_mcp_discovery_thread = thread
|
||||
thread.start()
|
||||
|
||||
|
||||
def wait_for_mcp_discovery(timeout: float = 0.75) -> None:
|
||||
"""Briefly wait for background MCP discovery before the first tool snapshot."""
|
||||
thread = _mcp_discovery_thread
|
||||
if thread is None or not thread.is_alive():
|
||||
return
|
||||
thread.join(timeout=timeout)
|
||||
@@ -452,11 +452,7 @@ def memory_command(args) -> None:
|
||||
"""Route memory subcommands."""
|
||||
sub = getattr(args, "memory_command", None)
|
||||
if sub == "setup":
|
||||
provider = getattr(args, "provider", None)
|
||||
if provider:
|
||||
cmd_setup_provider(provider)
|
||||
else:
|
||||
cmd_setup(args)
|
||||
cmd_setup(args)
|
||||
elif sub == "status":
|
||||
cmd_status(args)
|
||||
else:
|
||||
|
||||
@@ -73,7 +73,7 @@ DEFAULT_CATALOG_URL = (
|
||||
DEFAULT_CATALOG_FALLBACK_URLS: tuple[str, ...] = (
|
||||
"https://raw.githubusercontent.com/NousResearch/hermes-agent/main/website/static/api/model-catalog.json",
|
||||
)
|
||||
DEFAULT_TTL_HOURS = 1
|
||||
DEFAULT_TTL_HOURS = 24
|
||||
DEFAULT_FETCH_TIMEOUT = 8.0
|
||||
SUPPORTED_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
@@ -1359,43 +1359,6 @@ def list_authenticated_providers(
|
||||
model_ids = _ids if _ids else (curated.get(hermes_slug, []) or curated.get(pid, []))
|
||||
except Exception:
|
||||
model_ids = curated.get(hermes_slug, []) or curated.get(pid, [])
|
||||
elif hermes_slug == "nous":
|
||||
# Nous serves a large live /v1/models catalog (vendor-prefixed
|
||||
# models from many providers, returned alphabetically). The
|
||||
# `hermes model` picker deliberately shows ONLY the curated agentic
|
||||
# list — augmented with the Portal's free/paid recommendations so
|
||||
# newly-launched models surface without a CLI release — in curated
|
||||
# order. Mirror that exactly (see _model_flow_nous in main.py) so
|
||||
# the GUI picker matches the CLI. Was: falling through to
|
||||
# cached_provider_model_ids, which dumped the full alphabetical
|
||||
# catalog; then: curated-only, which dropped the 4 Portal
|
||||
# recommendations (e.g. stepfun/step-3.7-flash:free).
|
||||
model_ids = curated.get("nous", [])
|
||||
try:
|
||||
from hermes_cli.models import (
|
||||
get_pricing_for_provider as _nous_pricing,
|
||||
check_nous_free_tier as _nous_free,
|
||||
union_with_portal_free_recommendations as _union_free,
|
||||
union_with_portal_paid_recommendations as _union_paid,
|
||||
)
|
||||
from hermes_cli.auth import get_provider_auth_state as _nous_state
|
||||
|
||||
_pricing = _nous_pricing("nous") or {}
|
||||
_portal = ""
|
||||
try:
|
||||
_st = _nous_state("nous") or {}
|
||||
_portal = _st.get("portal_base_url", "") or ""
|
||||
except Exception:
|
||||
_portal = ""
|
||||
if _nous_free(force_fresh=True):
|
||||
model_ids, _ = _union_free(model_ids, _pricing, _portal)
|
||||
else:
|
||||
model_ids, _ = _union_paid(model_ids, _pricing, _portal)
|
||||
except Exception:
|
||||
# Portal recommendation fetch failed — fall back to the
|
||||
# curated list alone (still correct, just may lag newly
|
||||
# launched models, exactly like an offline CLI run).
|
||||
pass
|
||||
else:
|
||||
# Unified pathway — see Section 1 rationale. Fall back to the
|
||||
# curated dict (with models.dev merge for preferred providers)
|
||||
|
||||
+64
-188
@@ -32,43 +32,34 @@ COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
|
||||
# Fallback OpenRouter snapshot used when the live catalog is unavailable.
|
||||
# (model_id, display description shown in menus)
|
||||
OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
# Anthropic
|
||||
("anthropic/claude-opus-4.8", ""),
|
||||
("anthropic/claude-opus-4.8-fast", "2x price, higher output speed"),
|
||||
("anthropic/claude-opus-4.7", ""),
|
||||
("anthropic/claude-opus-4.6", ""),
|
||||
("anthropic/claude-sonnet-4.6", ""),
|
||||
("moonshotai/kimi-k2.6", "recommended"),
|
||||
("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"),
|
||||
("qwen/qwen3.7-max", ""),
|
||||
("anthropic/claude-haiku-4.5", ""),
|
||||
# OpenAI
|
||||
("openai/gpt-5.5", ""),
|
||||
("openai/gpt-5.5-pro", ""),
|
||||
("openai/gpt-5.4-mini", ""),
|
||||
# Google
|
||||
("google/gemini-3-pro-preview", ""),
|
||||
("google/gemini-3.1-pro-preview", ""),
|
||||
("google/gemini-3.5-flash", ""),
|
||||
# xAI
|
||||
("x-ai/grok-4.3", ""),
|
||||
# DeepSeek
|
||||
("deepseek/deepseek-v4-pro", ""),
|
||||
("deepseek/deepseek-v4-flash", ""),
|
||||
# Qwen
|
||||
("qwen/qwen3.7-max", ""),
|
||||
("qwen/qwen3.6-35b-a3b", ""),
|
||||
# MoonshotAI
|
||||
("moonshotai/kimi-k2.6", "recommended"),
|
||||
# MiniMax
|
||||
("minimax/minimax-m3", ""),
|
||||
# Z-AI
|
||||
("z-ai/glm-5.1", ""),
|
||||
# Xiaomi
|
||||
("openai/gpt-5.4-nano", ""),
|
||||
("openai/gpt-5.3-codex", ""),
|
||||
("xiaomi/mimo-v2.5-pro", ""),
|
||||
# Tencent
|
||||
("tencent/hy3-preview", ""),
|
||||
# StepFun
|
||||
("google/gemini-3-pro-image-preview", ""),
|
||||
("google/gemini-3.5-flash", ""),
|
||||
("google/gemini-3.1-pro-preview", ""),
|
||||
("google/gemini-3.1-flash-lite-preview", ""),
|
||||
("qwen/qwen3.6-35b-a3b", ""),
|
||||
("stepfun/step-3.7-flash", ""),
|
||||
# NVIDIA
|
||||
("minimax/minimax-m2.7", ""),
|
||||
("z-ai/glm-5.1", ""),
|
||||
("x-ai/grok-4.20", ""),
|
||||
("x-ai/grok-4.3", ""),
|
||||
("nvidia/nemotron-3-super-120b-a12b", ""),
|
||||
# OpenRouter routers
|
||||
("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"),
|
||||
("deepseek/deepseek-v4-pro", ""),
|
||||
# Free tier
|
||||
("openrouter/elephant-alpha", "free"),
|
||||
("openrouter/owl-alpha", "free"),
|
||||
@@ -150,40 +141,31 @@ def _xai_curated_models() -> list[str]:
|
||||
|
||||
_PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"nous": [
|
||||
# Anthropic
|
||||
"anthropic/claude-opus-4.8",
|
||||
"anthropic/claude-opus-4.7",
|
||||
"anthropic/claude-opus-4.6",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"moonshotai/kimi-k2.6",
|
||||
"qwen/qwen3.7-max",
|
||||
"anthropic/claude-haiku-4.5",
|
||||
# OpenAI
|
||||
"openai/gpt-5.5",
|
||||
"openai/gpt-5.5-pro",
|
||||
"openai/gpt-5.4-mini",
|
||||
# Google
|
||||
"google/gemini-3-pro-preview",
|
||||
"google/gemini-3.1-pro-preview",
|
||||
"google/gemini-3.5-flash",
|
||||
# xAI
|
||||
"x-ai/grok-4.3",
|
||||
# DeepSeek
|
||||
"deepseek/deepseek-v4-pro",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
# Qwen
|
||||
"qwen/qwen3.7-max",
|
||||
"qwen/qwen3.6-35b-a3b",
|
||||
# MoonshotAI
|
||||
"moonshotai/kimi-k2.6",
|
||||
# MiniMax
|
||||
"minimax/minimax-m3",
|
||||
# Z-AI
|
||||
"z-ai/glm-5.1",
|
||||
# Xiaomi
|
||||
"openai/gpt-5.4-nano",
|
||||
"openai/gpt-5.3-codex",
|
||||
"xiaomi/mimo-v2.5-pro",
|
||||
# Tencent
|
||||
"tencent/hy3-preview",
|
||||
# StepFun
|
||||
"google/gemini-3-pro-preview",
|
||||
"google/gemini-3.5-flash",
|
||||
"google/gemini-3.1-pro-preview",
|
||||
"google/gemini-3.1-flash-lite-preview",
|
||||
"qwen/qwen3.6-35b-a3b",
|
||||
"stepfun/step-3.7-flash",
|
||||
# NVIDIA
|
||||
"minimax/minimax-m2.7",
|
||||
"z-ai/glm-5.1",
|
||||
"x-ai/grok-4.3",
|
||||
"nvidia/nemotron-3-super-120b-a12b",
|
||||
"deepseek/deepseek-v4-pro",
|
||||
],
|
||||
# Native OpenAI Chat Completions (api.openai.com). Used by /model counts and
|
||||
# provider_model_ids fallback when /v1/models is unavailable.
|
||||
@@ -295,19 +277,16 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"kimi-k2-0905-preview",
|
||||
],
|
||||
"minimax": [
|
||||
"MiniMax-M3",
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.5",
|
||||
"MiniMax-M2.1",
|
||||
"MiniMax-M2",
|
||||
],
|
||||
"minimax-oauth": [
|
||||
"MiniMax-M3",
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
],
|
||||
"minimax-cn": [
|
||||
"MiniMax-M3",
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.5",
|
||||
"MiniMax-M2.1",
|
||||
@@ -896,41 +875,41 @@ class ProviderEntry(NamedTuple):
|
||||
tui_desc: str # detailed description for `hermes model` TUI
|
||||
|
||||
CANONICAL_PROVIDERS: list[ProviderEntry] = [
|
||||
ProviderEntry("nous", "Nous Portal", "Nous Portal (Everything your agent needs, 300+ models with bundled tool use)"),
|
||||
ProviderEntry("openrouter", "OpenRouter", "OpenRouter (Pay-per-use API aggregator)"),
|
||||
ProviderEntry("novita", "NovitaAI", "NovitaAI (Cloud: Model API, Agent Sandbox, GPU Cloud)"),
|
||||
ProviderEntry("lmstudio", "LM Studio", "LM Studio (Local desktop app with built-in model server)"),
|
||||
ProviderEntry("anthropic", "Anthropic", "Anthropic (Claude models via API key or Claude Code)"),
|
||||
ProviderEntry("openai-codex", "OpenAI Codex", "OpenAI Codex (Codex CLI via ChatGPT subscription or API key)"),
|
||||
ProviderEntry("nous", "Nous Portal", "Nous Portal (Nous Research subscription)"),
|
||||
ProviderEntry("openrouter", "OpenRouter", "OpenRouter (100+ models, pay-per-use)"),
|
||||
ProviderEntry("novita", "NovitaAI", "NovitaAI (AI-native cloud: Model API, Agent Sandbox, GPU Cloud)"),
|
||||
ProviderEntry("lmstudio", "LM Studio", "LM Studio (local desktop app with built-in model server)"),
|
||||
ProviderEntry("anthropic", "Anthropic", "Anthropic (Claude models — API key or Claude Code)"),
|
||||
ProviderEntry("openai-codex", "OpenAI Codex", "OpenAI Codex"),
|
||||
ProviderEntry("openai-api", "OpenAI API", "OpenAI API (api.openai.com, API key)"),
|
||||
ProviderEntry("alibaba", "Qwen Cloud", "Qwen Cloud / DashScope (Qwen + multi-provider)"),
|
||||
ProviderEntry("xai-oauth", "xAI Grok OAuth (SuperGrok / Premium+)", "xAI Grok OAuth (SuperGrok / Premium+ subscription)"),
|
||||
ProviderEntry("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2.5 and V2 models: pro, omni, flash)"),
|
||||
ProviderEntry("tencent-tokenhub", "Tencent TokenHub", "Tencent TokenHub (Hy3 Preview via tokenhub.tencentmaas.com)"),
|
||||
ProviderEntry("nvidia", "NVIDIA NIM", "NVIDIA NIM (Nemotron models via build.nvidia.com or local NIM)"),
|
||||
ProviderEntry("copilot", "GitHub Copilot", "GitHub Copilot (Uses GITHUB_TOKEN or gh auth token)"),
|
||||
ProviderEntry("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (Spawns copilot --acp --stdio)"),
|
||||
ProviderEntry("huggingface", "Hugging Face", "Hugging Face Inference Providers"),
|
||||
ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Native Gemini API)"),
|
||||
ProviderEntry("google-gemini-cli", "Google Gemini (OAuth)", "Google Gemini via OAuth + Code Assist (Code Assist OAuth flow)"),
|
||||
ProviderEntry("deepseek", "DeepSeek", "DeepSeek (V3, R1, coder, direct API)"),
|
||||
ProviderEntry("xai", "xAI", "xAI Grok (Direct API)"),
|
||||
ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu direct API)"),
|
||||
ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "Kimi Coding Plan (api.kimi.com & Moonshot API)"),
|
||||
ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "Kimi / Moonshot China (Domestic direct API)"),
|
||||
ProviderEntry("stepfun", "StepFun Step Plan", "StepFun Step Plan (Agent / coding models via Step Plan API)"),
|
||||
ProviderEntry("minimax", "MiniMax", "MiniMax (Global direct API)"),
|
||||
ProviderEntry("alibaba", "Qwen Cloud", "Qwen Cloud / DashScope Coding (Qwen + multi-provider)"),
|
||||
ProviderEntry("xai-oauth", "xAI Grok OAuth (SuperGrok / Premium+)", "xAI Grok OAuth (SuperGrok / Premium+)"),
|
||||
ProviderEntry("xiaomi", "Xiaomi MiMo", "Xiaomi MiMo (MiMo-V2.5 and V2 models — pro, omni, flash)"),
|
||||
ProviderEntry("tencent-tokenhub", "Tencent TokenHub", "Tencent TokenHub (Hy3 Preview — direct API via tokenhub.tencentmaas.com)"),
|
||||
ProviderEntry("nvidia", "NVIDIA NIM", "NVIDIA NIM (Nemotron models — build.nvidia.com or local NIM)"),
|
||||
ProviderEntry("copilot", "GitHub Copilot", "GitHub Copilot (uses GITHUB_TOKEN or gh auth token)"),
|
||||
ProviderEntry("copilot-acp", "GitHub Copilot ACP", "GitHub Copilot ACP (spawns `copilot --acp --stdio`)"),
|
||||
ProviderEntry("huggingface", "Hugging Face", "Hugging Face Inference Providers (20+ open models)"),
|
||||
ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Gemini models — native Gemini API)"),
|
||||
ProviderEntry("google-gemini-cli", "Google Gemini (OAuth)", "Google Gemini via OAuth + Code Assist (free tier supported; no API key needed)"),
|
||||
ProviderEntry("deepseek", "DeepSeek", "DeepSeek (DeepSeek-V3, R1, coder — direct API)"),
|
||||
ProviderEntry("xai", "xAI", "xAI (Grok models — direct API)"),
|
||||
ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu AI direct API)"),
|
||||
ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "Kimi Coding Plan (api.kimi.com) & Moonshot API"),
|
||||
ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "Kimi / Moonshot China (Moonshot CN direct API)"),
|
||||
ProviderEntry("stepfun", "StepFun Step Plan", "StepFun Step Plan (agent/coding models via Step Plan API)"),
|
||||
ProviderEntry("minimax", "MiniMax", "MiniMax (global direct API)"),
|
||||
ProviderEntry("minimax-oauth", "MiniMax (OAuth)", "MiniMax via OAuth browser login (Coding Plan, minimax.io)"),
|
||||
ProviderEntry("minimax-cn", "MiniMax (China)", "MiniMax China (Domestic direct API)"),
|
||||
ProviderEntry("ollama-cloud", "Ollama Cloud", "Ollama Cloud (Cloud-hosted open models, ollama.com)"),
|
||||
ProviderEntry("arcee", "Arcee AI", "Arcee AI (Trinity models, direct API)"),
|
||||
ProviderEntry("gmi", "GMI Cloud", "GMI Cloud (Multi-model direct API)"),
|
||||
ProviderEntry("minimax-cn", "MiniMax (China)", "MiniMax China (domestic direct API)"),
|
||||
ProviderEntry("ollama-cloud", "Ollama Cloud", "Ollama Cloud (cloud-hosted open models — ollama.com)"),
|
||||
ProviderEntry("arcee", "Arcee AI", "Arcee AI (Trinity models — direct API)"),
|
||||
ProviderEntry("gmi", "GMI Cloud", "GMI Cloud (multi-model direct API)"),
|
||||
ProviderEntry("kilocode", "Kilo Code", "Kilo Code (Kilo Gateway API)"),
|
||||
ProviderEntry("opencode-zen", "OpenCode Zen", "OpenCode Zen (Curated models, pay-as-you-go)"),
|
||||
ProviderEntry("opencode-go", "OpenCode Go", "OpenCode Go (Open models subscription)"),
|
||||
ProviderEntry("bedrock", "AWS Bedrock", "AWS Bedrock (Claude, Nova, Llama, DeepSeek; IAM or API key)"),
|
||||
ProviderEntry("azure-foundry", "Azure Foundry", "Azure Foundry (OpenAI-style or Anthropic-style endpoint, your Azure AI deployment)"),
|
||||
ProviderEntry("qwen-oauth", "Qwen OAuth (Portal)", "Qwen OAuth (Reuses local Qwen CLI login)"),
|
||||
ProviderEntry("opencode-zen", "OpenCode Zen", "OpenCode Zen (35+ curated models, pay-as-you-go)"),
|
||||
ProviderEntry("opencode-go", "OpenCode Go", "OpenCode Go (open models, $10/month subscription)"),
|
||||
ProviderEntry("bedrock", "AWS Bedrock", "AWS Bedrock (Claude, Nova, Llama, DeepSeek — IAM or API key)"),
|
||||
ProviderEntry("azure-foundry", "Azure Foundry", "Azure Foundry (OpenAI-style or Anthropic-style endpoint — your Azure AI deployment)"),
|
||||
ProviderEntry("qwen-oauth", "Qwen OAuth (Portal)", "Qwen OAuth (reuses local Qwen CLI login)"),
|
||||
]
|
||||
|
||||
# Auto-extend CANONICAL_PROVIDERS with any provider registered in providers/
|
||||
@@ -957,109 +936,6 @@ _PROVIDER_LABELS = {p.slug: p.label for p in CANONICAL_PROVIDERS}
|
||||
_PROVIDER_LABELS["custom"] = "Custom endpoint" # special case: not a named provider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider groups — DISPLAY ONLY
|
||||
#
|
||||
# Some vendors expose several Hermes provider slugs (one per endpoint /
|
||||
# auth method: global API, China API, OAuth coding plan, ...). Listing every
|
||||
# slug as a top-level row in the interactive `hermes model` / setup wizard /
|
||||
# Telegram `/model` pickers makes that list long and noisy.
|
||||
#
|
||||
# These groups fold related slugs under one top-level row in INTERACTIVE
|
||||
# PICKERS only. They do NOT change ``CANONICAL_PROVIDERS``, slug identity,
|
||||
# the ``--provider`` flag, ``/model <provider:model>``, or any typed path —
|
||||
# every member slug remains individually addressable. Grouping is a pure
|
||||
# display affordance; ``group_providers()`` is the single fold used by all
|
||||
# three picker surfaces so they stay consistent.
|
||||
#
|
||||
# group_id -> (display_label, group_description, [member_slug, ...])
|
||||
#
|
||||
# ``group_description`` is a short blurb shown on the collapsed top-level group
|
||||
# row in the interactive pickers (alongside the label). Member-specific detail
|
||||
# lives in each member's ``tui_desc`` and shows in the drill-down sub-picker.
|
||||
# Member order is the order shown inside the group submenu.
|
||||
# ---------------------------------------------------------------------------
|
||||
PROVIDER_GROUPS: dict[str, tuple[str, str, list[str]]] = {
|
||||
"kimi": ("Kimi / Moonshot", "Coding Plan, Moonshot global & China endpoints", ["kimi-coding", "kimi-coding-cn"]),
|
||||
"minimax": ("MiniMax", "Global, OAuth Coding Plan & China endpoints", ["minimax", "minimax-oauth", "minimax-cn"]),
|
||||
"xai": ("xAI Grok", "Direct API or SuperGrok / Premium+ OAuth", ["xai", "xai-oauth"]),
|
||||
"google": ("Google Gemini", "AI Studio API or OAuth + Code Assist", ["gemini", "google-gemini-cli"]),
|
||||
"openai": ("OpenAI", "Codex CLI or direct OpenAI API", ["openai-codex", "openai-api"]),
|
||||
"opencode": ("OpenCode", "Zen pay-as-you-go or Go subscription", ["opencode-zen", "opencode-go"]),
|
||||
"copilot": ("GitHub Copilot", "GitHub token API or copilot --acp process", ["copilot", "copilot-acp"]),
|
||||
}
|
||||
|
||||
# Reverse index: member slug -> group_id. Built once at import.
|
||||
_SLUG_TO_GROUP: dict[str, str] = {
|
||||
slug: gid for gid, (_label, _desc, members) in PROVIDER_GROUPS.items() for slug in members
|
||||
}
|
||||
|
||||
|
||||
def provider_group_for_slug(slug: str) -> str:
|
||||
"""Return the group_id a provider slug belongs to, or "" if ungrouped."""
|
||||
return _SLUG_TO_GROUP.get(str(slug or "").strip().lower(), "")
|
||||
|
||||
|
||||
def group_providers(slugs):
|
||||
"""Fold a flat ordered slug iterable into picker rows by provider group.
|
||||
|
||||
DISPLAY ONLY. Used by every interactive picker (``hermes model``, the
|
||||
setup wizard, the Telegram ``/model`` keyboard) so grouping is identical
|
||||
across surfaces.
|
||||
|
||||
Each returned row is a dict::
|
||||
|
||||
{"kind": "single", "slug": <slug>} # ungrouped, or
|
||||
# 1-member group
|
||||
{"kind": "group", "group_id": <gid>, "label": <label>,
|
||||
"description": <desc>, "members": [<slug>, ...]} # 2+ members
|
||||
|
||||
Rules:
|
||||
* A group row appears at the position of its FIRST present member, in
|
||||
the input order. Subsequent members fold into that row (and are not
|
||||
emitted again).
|
||||
* Member order inside a group follows ``PROVIDER_GROUPS`` declaration,
|
||||
restricted to the members actually present in ``slugs``.
|
||||
* A group reduced to a single present member degrades to a ``single``
|
||||
row — no pointless one-item submenu.
|
||||
* Slugs not in any group pass through as ``single`` rows, order
|
||||
preserved.
|
||||
* Duplicate slugs in the input are ignored after first sight.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
# Which present members each group has, in declaration order.
|
||||
group_members: dict[str, list[str]] = {}
|
||||
for gid, (_label, _desc, members) in PROVIDER_GROUPS.items():
|
||||
present = [m for m in members if m in set(slugs)]
|
||||
if present:
|
||||
group_members[gid] = present
|
||||
|
||||
rows = []
|
||||
emitted_groups: set[str] = set()
|
||||
for slug in slugs:
|
||||
s = str(slug or "").strip().lower()
|
||||
if not s or s in seen:
|
||||
continue
|
||||
seen.add(s)
|
||||
gid = _SLUG_TO_GROUP.get(s, "")
|
||||
if not gid:
|
||||
rows.append({"kind": "single", "slug": s})
|
||||
continue
|
||||
if gid in emitted_groups:
|
||||
continue # already folded at the first member's position
|
||||
emitted_groups.add(gid)
|
||||
members = group_members.get(gid, [s])
|
||||
if len(members) <= 1:
|
||||
rows.append({"kind": "single", "slug": members[0]})
|
||||
else:
|
||||
label, desc, _ = PROVIDER_GROUPS[gid]
|
||||
rows.append(
|
||||
{"kind": "group", "group_id": gid, "label": label,
|
||||
"description": desc, "members": list(members)}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
_PROVIDER_ALIASES = {
|
||||
"glm": "zai",
|
||||
"z-ai": "zai",
|
||||
|
||||
+15
-99
@@ -4,33 +4,17 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
|
||||
NousAccountInfoSource = Literal["jwt", "account_api", "inference_key", "none", "error"]
|
||||
|
||||
# Free tool-pool coverage categories. Kept byte-for-byte aligned with the
|
||||
# Portal's TOOL_COVERAGE_CATEGORIES (nous-account-service
|
||||
# src/server/tool-pool-eligibility.ts). The Portal mints these into the
|
||||
# `tool_access.coverage` map on the JWT and /api/oauth/account; FAL video gen
|
||||
# (`fal-video`) is intentionally excluded from the pool.
|
||||
TOOL_COVERAGE_CATEGORIES = (
|
||||
"firecrawl",
|
||||
"fal",
|
||||
"fal-video",
|
||||
"openai-audio",
|
||||
"browser-use",
|
||||
"modal",
|
||||
)
|
||||
|
||||
_ACCOUNT_INFO_CACHE_TTL = 60
|
||||
_account_info_cache: tuple[str, float, "NousPortalAccountInfo"] | None = None
|
||||
_ACCOUNT_INFO_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -59,19 +43,6 @@ class NousPaidServiceAccessInfo:
|
||||
total_usable_credits: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NousToolAccessInfo:
|
||||
"""Free tool-pool entitlement, decoupled from paid/billing access.
|
||||
|
||||
Mirrors the Portal's ``tool_access`` claim/field: ``enabled`` is true when a
|
||||
positive tool-pool balance is live and not gated off; ``coverage`` maps each
|
||||
tool category to whether the pool funds it (FAL video is excluded).
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
coverage: dict[str, bool] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NousPortalAccountInfo:
|
||||
logged_in: bool
|
||||
@@ -92,7 +63,6 @@ class NousPortalAccountInfo:
|
||||
subscription: Optional[NousPortalSubscriptionInfo] = None
|
||||
paid_service_access: Optional[bool] = None
|
||||
paid_service_access_info: Optional[NousPaidServiceAccessInfo] = None
|
||||
tool_access: Optional[NousToolAccessInfo] = None
|
||||
raw_claims: Optional[dict[str, Any]] = None
|
||||
raw_account: Optional[dict[str, Any]] = None
|
||||
error: Optional[str] = None
|
||||
@@ -107,21 +77,7 @@ class NousPortalAccountInfo:
|
||||
|
||||
@property
|
||||
def tool_gateway_entitled(self) -> bool:
|
||||
"""Coarse "entitled to any managed tool" gate: paid access OR a live
|
||||
free tool pool. Use :meth:`tool_gateway_entitled_for` to gate a specific
|
||||
tool category (the pool does not cover every category)."""
|
||||
if self.paid_service_access is True:
|
||||
return True
|
||||
return self.tool_access is not None and self.tool_access.enabled
|
||||
|
||||
def tool_gateway_entitled_for(self, category: str) -> bool:
|
||||
"""Whether a specific tool category is entitled. Paid users are entitled
|
||||
everywhere; free tool-pool users only where ``coverage[category]`` is
|
||||
true (e.g. image but not video)."""
|
||||
if self.paid_service_access is True:
|
||||
return True
|
||||
ta = self.tool_access
|
||||
return bool(ta and ta.enabled and ta.coverage.get(category) is True)
|
||||
return self.paid_service_access is True
|
||||
|
||||
|
||||
def nous_portal_billing_url(account_info: Optional[NousPortalAccountInfo] = None) -> str:
|
||||
@@ -144,38 +100,19 @@ def format_nous_portal_entitlement_message(
|
||||
*,
|
||||
capability: str = "this feature",
|
||||
include_refresh_hint: bool = True,
|
||||
coverage_category: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Return user-facing guidance for a missing Nous tool-gateway entitlement.
|
||||
"""Return user-facing guidance for a missing Nous paid entitlement.
|
||||
|
||||
``None`` means the account is entitled to use the capability — via paid
|
||||
service access OR a live free tool pool that covers it. The message works
|
||||
from normalized entitlement fields rather than subscription price alone:
|
||||
purchased credits without a subscription still count as paid access, while a
|
||||
paid subscription with exhausted usable credits does not.
|
||||
|
||||
``coverage_category`` scopes the check to a single tool category (e.g.
|
||||
``"fal-video"``). When given, a user who is entitled overall but whose
|
||||
access does not fund that category gets a neutral billing nudge instead of a
|
||||
message implying their credits are exhausted. The pool-vs-paid distinction is
|
||||
never surfaced to the user.
|
||||
``None`` means the account is known to have paid service access. The
|
||||
message intentionally works from normalized entitlement fields rather than
|
||||
subscription price alone: purchased credits without a subscription still
|
||||
count as paid access, while a paid subscription with exhausted usable
|
||||
credits does not.
|
||||
"""
|
||||
billing_url = nous_portal_billing_url(account_info)
|
||||
|
||||
if account_info is not None:
|
||||
if coverage_category is not None:
|
||||
if account_info.tool_gateway_entitled_for(coverage_category):
|
||||
return None
|
||||
if account_info.tool_gateway_entitled:
|
||||
# Entitled overall (e.g. via the managed tool pool), but this
|
||||
# specific capability isn't covered. Surface a neutral billing
|
||||
# nudge without exposing pool-vs-paid internals to the user.
|
||||
return (
|
||||
f"{capability} isn't included with your current Nous Portal "
|
||||
f"access. Add credits or a subscription to enable it at {billing_url}."
|
||||
)
|
||||
elif account_info.tool_gateway_entitled:
|
||||
return None
|
||||
if account_info is not None and account_info.paid_service_access is True:
|
||||
return None
|
||||
|
||||
if account_info is None:
|
||||
return (
|
||||
@@ -365,11 +302,10 @@ def _fresh_account_info(
|
||||
portal_base_url = _portal_base_url(refreshed_state) or portal_base_url
|
||||
cache_key = _cache_key(access_token, portal_base_url)
|
||||
|
||||
with _ACCOUNT_INFO_CACHE_LOCK:
|
||||
if not force_fresh and _account_info_cache is not None:
|
||||
cached_key, cached_at, cached_info = _account_info_cache
|
||||
if cached_key == cache_key and (time.monotonic() - cached_at) < _ACCOUNT_INFO_CACHE_TTL:
|
||||
return cached_info
|
||||
if not force_fresh and _account_info_cache is not None:
|
||||
cached_key, cached_at, cached_info = _account_info_cache
|
||||
if cached_key == cache_key and (time.monotonic() - cached_at) < _ACCOUNT_INFO_CACHE_TTL:
|
||||
return cached_info
|
||||
|
||||
payload = _fetch_nous_account_info(access_token, portal_base_url)
|
||||
if not payload:
|
||||
@@ -391,8 +327,7 @@ def _fresh_account_info(
|
||||
state=refreshed_state,
|
||||
portal_base_url=portal_base_url,
|
||||
)
|
||||
with _ACCOUNT_INFO_CACHE_LOCK:
|
||||
_account_info_cache = (cache_key, time.monotonic(), info)
|
||||
_account_info_cache = (cache_key, time.monotonic(), info)
|
||||
return info
|
||||
except Exception as exc:
|
||||
return _error_info(
|
||||
@@ -595,7 +530,6 @@ def _info_from_valid_jwt(
|
||||
expires_at=datetime.fromtimestamp(exp, tz=timezone.utc),
|
||||
paid_service_access=paid_access,
|
||||
paid_service_access_info=access_info,
|
||||
tool_access=_tool_access_from_value(claims.get("tool_access")),
|
||||
raw_claims=dict(claims),
|
||||
)
|
||||
|
||||
@@ -633,28 +567,10 @@ def _info_from_account_payload(
|
||||
subscription=subscription,
|
||||
paid_service_access=paid_access,
|
||||
paid_service_access_info=access,
|
||||
tool_access=_tool_access_from_value(payload.get("tool_access")),
|
||||
raw_account=dict(payload),
|
||||
)
|
||||
|
||||
|
||||
def _tool_access_from_value(value: Any) -> Optional[NousToolAccessInfo]:
|
||||
"""Parse a Portal ``tool_access`` object (from the JWT claim or the account
|
||||
API) into :class:`NousToolAccessInfo`. Fails closed: a non-object value
|
||||
yields ``None``, and only literal ``true`` counts for ``enabled`` and each
|
||||
coverage entry."""
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
enabled = _coerce_bool(value.get("enabled")) is True
|
||||
raw_coverage = value.get("coverage")
|
||||
coverage: dict[str, bool] = {}
|
||||
if isinstance(raw_coverage, dict):
|
||||
for key, val in raw_coverage.items():
|
||||
if isinstance(key, str):
|
||||
coverage[key] = val is True
|
||||
return NousToolAccessInfo(enabled=enabled, coverage=coverage)
|
||||
|
||||
|
||||
def _subscription_from_payload(value: Any) -> Optional[NousPortalSubscriptionInfo]:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user