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
|
||||
@@ -26,10 +26,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# Needed so the arm64 job can push/pull its registry-backed build cache
|
||||
# to ghcr.io (cache-to/cache-from type=registry). See the build-arm64
|
||||
# job for why registry cache replaced the gha cache on that arch.
|
||||
packages: write
|
||||
|
||||
# Concurrency: push/release runs are NEVER cancelled so every merge gets
|
||||
# its own image. PR runs reuse a PR-scoped group with
|
||||
@@ -200,34 +196,11 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
# Log in to ghcr.io so the registry-backed build cache below can be
|
||||
# read (cache-from) on every event and written (cache-to) on
|
||||
# push/release. Uses the workflow's GITHUB_TOKEN, which is valid for
|
||||
# the whole job — unlike the gha cache backend's short-lived Azure SAS
|
||||
# token, which expired mid-build on slow cold-cache arm64 runs and
|
||||
# crashed the build before the smoke test (the reason the gha cache
|
||||
# was removed from arm64 PRs in the first place).
|
||||
- name: Log in to ghcr.io (build cache)
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Build once, load into the local daemon for smoke testing.
|
||||
#
|
||||
# PR builds use the registry-backed cache READ-ONLY (cache-from only):
|
||||
# they pull warm layers pushed by the most recent main build but never
|
||||
# write, so rapid PR pushes don't race on cache writes or pollute the
|
||||
# cache ref. This restores warm-cache speed to arm64 PR builds (which
|
||||
# were running fully uncached and were ~45% slower than amd64, making
|
||||
# them the job most often cancelled on supersede).
|
||||
#
|
||||
# Registry cache (type=registry on ghcr.io) is used instead of the gha
|
||||
# cache that previously broke here: its credential is the job-lifetime
|
||||
# GITHUB_TOKEN, not a short-lived SAS token, so the cold-build-outlives-
|
||||
# token failure mode cannot recur.
|
||||
- name: Build image (arm64, smoke test, cache read-only PR)
|
||||
# Build once, load into the local daemon for smoke testing. PR arm64
|
||||
# builds deliberately avoid the gha cache: cold-cache arm64 builds can
|
||||
# outlive GitHub's short-lived Azure cache SAS token, then fail while
|
||||
# reading or writing cache blobs before the smoke test can run.
|
||||
- name: Build image (arm64, smoke test, uncached PR)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
@@ -238,11 +211,9 @@ jobs:
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
|
||||
|
||||
# Main/release builds read AND write the registry cache so the digest
|
||||
# push below reuses layers from this smoke-test build, and so the next
|
||||
# PR/main build starts warm.
|
||||
# Main/release builds still use the per-arch gha cache so the digest
|
||||
# push below can reuse layers from this smoke-test build.
|
||||
- name: Build image (arm64, smoke test, cached publish)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
@@ -254,8 +225,8 @@ jobs:
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
|
||||
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
|
||||
- name: Smoke test image
|
||||
uses: ./.github/actions/hermes-smoke-test
|
||||
@@ -282,8 +253,8 @@ jobs:
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
|
||||
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
|
||||
- name: Export digest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
|
||||
+2
-12
@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# hermes process, the dashboard, and per-profile gateways.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev procps git openssh-client docker-cli xz-utils && \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli xz-utils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---------- s6-overlay install ----------
|
||||
@@ -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:
|
||||
|
||||
@@ -308,14 +308,11 @@ def compress_context(
|
||||
# The check itself sets ``agent._compression_warning`` so the
|
||||
# status-callback replay machinery still emits the warning to the user
|
||||
# the first time it would matter.
|
||||
if not getattr(agent, "_compression_feasibility_checked", False):
|
||||
# Mark as checked only after the probe completes. If the check
|
||||
# raises (e.g. a fatal aux-context ValueError that aborts the
|
||||
# session), leaving the flag unset is harmless; a non-fatal
|
||||
# transient failure is swallowed inside the function so the flag
|
||||
# is set normally on the next successful pass.
|
||||
check_compression_model_feasibility(agent)
|
||||
agent._compression_feasibility_checked = True
|
||||
if not getattr(agent, "_compression_feasibility_checked", True):
|
||||
try:
|
||||
check_compression_model_feasibility(agent)
|
||||
finally:
|
||||
agent._compression_feasibility_checked = True
|
||||
|
||||
_pre_msg_count = len(messages)
|
||||
logger.info(
|
||||
@@ -578,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
|
||||
@@ -601,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:,}",
|
||||
)
|
||||
@@ -647,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."""
|
||||
@@ -719,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
|
||||
|
||||
@@ -451,190 +451,3 @@ def get_cross_profile_warning(path: str) -> Optional[str]:
|
||||
f"``cross_profile=True``. (Defense-in-depth — not a security "
|
||||
f"boundary; the terminal tool can still bypass.)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox-mirror write guard (#32049)
|
||||
#
|
||||
# Non-local terminal backends (Docker, Daytona, etc.) bind a sandbox-local
|
||||
# directory to the container's ``$HOME``. The on-disk layout looks like
|
||||
#
|
||||
# <HERMES_HOME>/profiles/<name>/sandboxes/<backend>/<task>/home/.hermes/...
|
||||
#
|
||||
# When the agent (running host-side) speculates that authoritative profile
|
||||
# state lives at one of those sandbox-mirror paths, the write lands on the
|
||||
# mirror — never read by the host process — while the host file is left
|
||||
# untouched. The agent reports success, the user sees no change, and on
|
||||
# disk two divergent copies accumulate. See #32049 for evidence.
|
||||
#
|
||||
# This guard is path-shape-only: it detects the
|
||||
# ``…/sandboxes/<backend>/<task>/home/.hermes/…`` segment and warns
|
||||
# regardless of which Hermes profile is active. It does NOT cover the
|
||||
# inner-container case where the bind mount strips the ``sandboxes/`` prefix
|
||||
# (the agent's view inside the container is plain ``/root/.hermes/...``);
|
||||
# that case needs a separate dispatch-layer or host-side ``profile_state``
|
||||
# tool.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_sandbox_mirror_segments(parts: tuple) -> Optional[int]:
|
||||
"""Return the index of the inner ``.hermes`` part in a sandbox-mirror path.
|
||||
|
||||
Matches ``…/sandboxes/<backend>/<task>/home/.hermes/…`` and returns the
|
||||
index where the inner Hermes-state portion starts. Returns ``None`` for
|
||||
paths that do not contain the sandbox-mirror shape.
|
||||
"""
|
||||
for i, part in enumerate(parts):
|
||||
if part != "sandboxes":
|
||||
continue
|
||||
# Need at least: sandboxes / <backend> / <task> / home / .hermes / <thing>
|
||||
if i + 5 >= len(parts):
|
||||
continue
|
||||
if parts[i + 3] == "home" and parts[i + 4] == ".hermes":
|
||||
return i + 4
|
||||
return None
|
||||
|
||||
|
||||
def classify_sandbox_mirror_target(path: str) -> Optional[dict]:
|
||||
"""Classify a write target as a sandbox-mirror of authoritative Hermes state.
|
||||
|
||||
Returns ``None`` when the path does not match the sandbox-mirror shape.
|
||||
Otherwise returns a dict with:
|
||||
|
||||
* ``target_path``: the resolved path string
|
||||
* ``mirror_root``: the ``…/sandboxes/<backend>/<task>/home/.hermes``
|
||||
prefix (so callers can show users which sandbox owns the mirror)
|
||||
* ``inner_path``: the portion under the mirror's ``.hermes`` (what the
|
||||
agent likely meant to address on the host)
|
||||
|
||||
Detection is path-shape-only — does not require any Hermes resolver to
|
||||
succeed, so it works correctly even when called from contexts where
|
||||
HERMES_HOME resolution would be ambiguous.
|
||||
"""
|
||||
try:
|
||||
target = Path(os.path.expanduser(str(path))).resolve()
|
||||
except (OSError, RuntimeError):
|
||||
return None
|
||||
|
||||
parts = target.parts
|
||||
inner_idx = _find_sandbox_mirror_segments(parts)
|
||||
if inner_idx is None:
|
||||
return None
|
||||
|
||||
mirror_root = str(Path(*parts[: inner_idx + 1]))
|
||||
inner_path = str(Path(*parts[inner_idx + 1 :])) if inner_idx + 1 < len(parts) else ""
|
||||
|
||||
return {
|
||||
"target_path": str(target),
|
||||
"mirror_root": mirror_root,
|
||||
"inner_path": inner_path,
|
||||
}
|
||||
|
||||
|
||||
def get_sandbox_mirror_warning(path: str) -> Optional[str]:
|
||||
"""Return a model-facing warning when ``path`` lands in a sandbox mirror.
|
||||
|
||||
Returns ``None`` when the path is not a sandbox-mirror target. Caller
|
||||
is expected to surface the warning to the agent as a tool-result
|
||||
error. The bypass kwarg (``cross_profile=True``) is shared with the
|
||||
cross-profile guard: both are soft "I know what I'm doing" overrides
|
||||
a user can authorise.
|
||||
|
||||
Defense-in-depth, NOT a security boundary: the terminal tool runs as
|
||||
the same OS user and can write the mirror path directly. The guard
|
||||
exists to surface the misclassification before the silent-success +
|
||||
divergent-copy footgun in #32049 fires.
|
||||
"""
|
||||
info = classify_sandbox_mirror_target(path)
|
||||
if info is None:
|
||||
return None
|
||||
return (
|
||||
f"Sandbox-mirror write blocked by soft guard: {info['target_path']} "
|
||||
f"sits under {info['mirror_root']!r}, which is a per-task mirror "
|
||||
f"created by a non-local terminal backend (docker/daytona/etc.). "
|
||||
f"Writes here land on a copy that the host Hermes process never "
|
||||
f"reads — the authoritative file is likely {info['inner_path']!r} "
|
||||
f"under the real HERMES_HOME. Use the host-side tool for "
|
||||
f"authoritative state (e.g. ``memory`` for memories), or address "
|
||||
f"the host path directly. To bypass this guard after explicit "
|
||||
f"user direction, retry the call with ``cross_profile=True``. "
|
||||
f"(Defense-in-depth — not a security boundary; the terminal tool "
|
||||
f"can still bypass.)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Container-context mirror guard (inner-container case — #32049 follow-up)
|
||||
#
|
||||
# Brian's shape-based detector (#32213) catches paths that still carry the
|
||||
# full ``…/sandboxes/<backend>/<task>/home/.hermes/…`` prefix on the host.
|
||||
# But when file tools execute *inside* the container the bind-mount strips
|
||||
# that prefix: the agent sees plain ``/root/.hermes/…``. The root:root
|
||||
# ownership on the divergent SOUL.md in #32049 confirms this is the primary
|
||||
# failure mode.
|
||||
#
|
||||
# Fix: file_tools passes the active Docker mirror prefix when the terminal
|
||||
# backend is docker + persistent. This catches the very first file-tool call,
|
||||
# before a DockerEnvironment object necessarily exists.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def classify_container_mirror_target(
|
||||
path: str,
|
||||
mirror_prefix: str | None = None,
|
||||
) -> Optional[dict]:
|
||||
"""Classify a write target as a container-side sandbox mirror.
|
||||
|
||||
``mirror_prefix`` must be supplied by the caller after it has established
|
||||
that file tools are executing in a container whose home is a sandbox
|
||||
mirror. Returns ``None`` when no such context is active or the path is not
|
||||
under the mirror prefix. Otherwise returns:
|
||||
|
||||
* ``target_path``: resolved path string
|
||||
* ``mirror_root``: the declared container mirror prefix
|
||||
* ``inner_path``: portion under the mirror root (what the agent
|
||||
likely meant to address in the host HERMES_HOME)
|
||||
"""
|
||||
if not mirror_prefix:
|
||||
return None
|
||||
try:
|
||||
target = Path(os.path.expanduser(str(path))).resolve()
|
||||
mirror = Path(os.path.expanduser(mirror_prefix)).resolve()
|
||||
inner = target.relative_to(mirror)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return None
|
||||
return {
|
||||
"target_path": str(target),
|
||||
"mirror_root": str(mirror),
|
||||
"inner_path": inner.as_posix(),
|
||||
}
|
||||
|
||||
|
||||
def get_container_mirror_warning(
|
||||
path: str,
|
||||
mirror_prefix: str | None = None,
|
||||
) -> Optional[str]:
|
||||
"""Return a model-facing warning when *path* lands in the container's
|
||||
sandbox mirror of authoritative Hermes state.
|
||||
|
||||
The caller supplies ``mirror_prefix`` only when the current file-tool
|
||||
backend is known to execute inside a Docker sandbox. Same contract as
|
||||
``get_cross_profile_warning``: soft guard, returns ``None`` for
|
||||
non-mirror paths, caller surfaces as a tool-result error. Bypass via
|
||||
``cross_profile=True`` after explicit user direction.
|
||||
"""
|
||||
info = classify_container_mirror_target(path, mirror_prefix)
|
||||
if info is None:
|
||||
return None
|
||||
return (
|
||||
f"Sandbox-mirror write blocked by soft guard: {info['target_path']} "
|
||||
f"sits under {info['mirror_root']!r}, which is the container's "
|
||||
f"bind-mounted home — a per-task mirror that the host Hermes "
|
||||
f"process never reads. The authoritative file is "
|
||||
f"{info['inner_path']!r} under the real HERMES_HOME. Use the "
|
||||
f"host-side tool for authoritative state (e.g. ``memory`` for "
|
||||
f"memories), or address the host path directly. To bypass after "
|
||||
f"explicit user direction, retry with ``cross_profile=True``. "
|
||||
f"(Defense-in-depth — not a security boundary; the terminal tool "
|
||||
f"can still bypass.)"
|
||||
)
|
||||
|
||||
+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.
|
||||
"""
|
||||
|
||||
+2
-31
@@ -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,
|
||||
@@ -1128,18 +1124,6 @@ def _model_name_suggests_kimi(model: str) -> bool:
|
||||
return lower.startswith("kimi") or "moonshot" in lower
|
||||
|
||||
|
||||
def _model_name_suggests_minimax_m3(model: str) -> bool:
|
||||
"""Return True if the model name looks like MiniMax M3.
|
||||
|
||||
Catches ``MiniMax-M3``, ``minimax/minimax-m3``, and similar variants
|
||||
across surfaces (native MiniMax-M3, OpenRouter/Nous minimax/minimax-m3).
|
||||
Used as a guard against stale cache entries seeded by pre-catalog builds
|
||||
that resolved M3 via the generic ``minimax`` catch-all (204,800) before
|
||||
the ``minimax-m3`` (1M) entry existed in DEFAULT_CONTEXT_LENGTHS.
|
||||
"""
|
||||
return "minimax-m3" in model.lower()
|
||||
|
||||
|
||||
def _query_local_context_length(model: str, base_url: str, api_key: str = "") -> Optional[int]:
|
||||
"""Query a local server for the model's context length."""
|
||||
import httpx
|
||||
@@ -1551,19 +1535,6 @@ def get_model_context_length(
|
||||
model, base_url, f"{cached:,}",
|
||||
)
|
||||
_invalidate_cached_context_length(model, base_url)
|
||||
# Invalidate stale ≤204,800 cache entries for MiniMax-M3. Pre-catalog
|
||||
# builds resolved M3 via the generic ``minimax`` catch-all (204,800)
|
||||
# and persisted it before the ``minimax-m3`` (1M) entry existed; that
|
||||
# stale value would otherwise stick forever here at step 1. M3 is 1M,
|
||||
# so any sub-256K cached value for an M3 slug is a leftover — drop it
|
||||
# and fall through to the hardcoded default.
|
||||
elif cached <= 204_800 and _model_name_suggests_minimax_m3(model):
|
||||
logger.info(
|
||||
"Dropping stale MiniMax-M3 cache entry %s@%s -> %s (pre-catalog value); "
|
||||
"re-resolving via hardcoded defaults",
|
||||
model, base_url, f"{cached:,}",
|
||||
)
|
||||
_invalidate_cached_context_length(model, base_url)
|
||||
# Nous Portal: the portal /v1/models endpoint is authoritative.
|
||||
# Bypass the persistent cache so step 5b can always reconcile
|
||||
# against it — this corrects pre-fix entries seeded from the
|
||||
|
||||
@@ -14,7 +14,6 @@ from pathlib import Path
|
||||
from hermes_constants import get_hermes_home, get_skills_dir, is_wsl
|
||||
from typing import Optional
|
||||
|
||||
from agent.runtime_cwd import resolve_agent_cwd
|
||||
from agent.skill_utils import (
|
||||
extract_skill_conditions,
|
||||
extract_skill_description,
|
||||
@@ -803,7 +802,7 @@ def build_environment_hints() -> str:
|
||||
|
||||
host_lines.append(f"User home directory: {os.path.expanduser('~')}")
|
||||
try:
|
||||
host_lines.append(f"Current working directory: {resolve_agent_cwd()}")
|
||||
host_lines.append(f"Current working directory: {os.getcwd()}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""Single source of truth for the agent working directory.
|
||||
|
||||
`TERMINAL_CWD` is the runtime carrier for the configured working directory
|
||||
(design #19214/#19242: `terminal.cwd` is bridged once to `TERMINAL_CWD` at
|
||||
gateway/cron startup). The local-CLI backend deliberately leaves it unset and
|
||||
relies on the launch dir. Reading it in one place keeps the system prompt, the
|
||||
tool surfaces, and context-file discovery agreeing on where the agent lives.
|
||||
|
||||
The #29531 per-session extension point is this function: a future PR adds a
|
||||
contextvar arm inside `resolve_agent_cwd` and `.set()`s it at the
|
||||
`set_session_vars` seam — by design, not a reopening hazard.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def resolve_agent_cwd() -> Path:
|
||||
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
||||
if raw:
|
||||
p = Path(raw).expanduser()
|
||||
if p.is_dir():
|
||||
return p
|
||||
return Path(os.getcwd())
|
||||
|
||||
|
||||
def resolve_context_cwd() -> Path | None:
|
||||
# None means "no configured cwd": build_context_files_prompt then falls back
|
||||
# to the launch dir (os.getcwd()) — correct for the local CLI. The gateway
|
||||
# avoids slurping its install dir by setting TERMINAL_CWD (see system_prompt.py).
|
||||
# No getcwd arm here: that fallback is owned by the caller, not this resolver.
|
||||
raw = os.environ.get("TERMINAL_CWD", "").strip()
|
||||
return Path(raw).expanduser() if raw else None
|
||||
@@ -24,6 +24,7 @@ Pure helpers that read the agent's state. AIAgent keeps thin forwarders.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.prompt_builder import (
|
||||
@@ -40,7 +41,6 @@ from agent.prompt_builder import (
|
||||
TOOL_USE_ENFORCEMENT_GUIDANCE,
|
||||
TOOL_USE_ENFORCEMENT_MODELS,
|
||||
)
|
||||
from agent.runtime_cwd import resolve_context_cwd
|
||||
|
||||
|
||||
def _ra():
|
||||
@@ -288,12 +288,13 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
context_parts.append(system_message)
|
||||
|
||||
if not agent.skip_context_files:
|
||||
# Prefer the configured TERMINAL_CWD (gateway mode). When unset (local
|
||||
# CLI), None lets build_context_files_prompt fall back to the launch
|
||||
# dir — the user's real cwd there, but the install dir for the gateway
|
||||
# daemon, which is why the gateway sets TERMINAL_CWD.
|
||||
# Use TERMINAL_CWD for context file discovery when set (gateway
|
||||
# mode). The gateway process runs from the hermes-agent install
|
||||
# dir, so os.getcwd() would pick up the repo's AGENTS.md and
|
||||
# other dev files — inflating token usage by ~10k for no benefit.
|
||||
_context_cwd = os.getenv("TERMINAL_CWD") or None
|
||||
context_files_prompt = _r.build_context_files_prompt(
|
||||
cwd=resolve_context_cwd(), skip_soul=_soul_loaded)
|
||||
cwd=_context_cwd, skip_soul=_soul_loaded)
|
||||
if context_files_prompt:
|
||||
context_parts.append(context_files_prompt)
|
||||
|
||||
|
||||
+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())
|
||||
|
||||
@@ -8,24 +8,18 @@ fn main() {
|
||||
// `option_env!()` macro to default the install-script reference.
|
||||
// Precedence (matches install.ps1's own arg precedence): commit > branch.
|
||||
//
|
||||
// The COMMIT pin is opt-in. By default a dev build pins ONLY the branch,
|
||||
// so the produced installer follows that branch's HEAD at install time
|
||||
// (tolerant of fast-forwards/new commits, and never references a SHA the
|
||||
// local checkout hasn't pushed). Set HERMES_BUILD_PIN_COMMIT to bake an
|
||||
// immutable commit pin for reproducible/release installers.
|
||||
//
|
||||
// Commit pin resolution:
|
||||
// - HERMES_BUILD_PIN_COMMIT, if set and non-empty. Accepts a SHA, tag,
|
||||
// or branch name; resolved to an immutable SHA via `git rev-parse`
|
||||
// when possible, else used verbatim if it already looks like a SHA.
|
||||
// - Otherwise: NO commit pin (branch-follow is the default).
|
||||
//
|
||||
// Branch pin resolution:
|
||||
// 1. HERMES_BUILD_PIN_BRANCH, if set and non-empty.
|
||||
// 2. `git rev-parse --abbrev-ref HEAD` of the checkout this build.rs
|
||||
// lives in — the current branch. (None on a detached HEAD.)
|
||||
// 3. Last-resort fallback handled below: if neither commit nor branch
|
||||
// resolves, warn — the binary needs a runtime arg or dev-repo env.
|
||||
// Resolution order:
|
||||
// 1. Env var override at build time (HERMES_BUILD_PIN_COMMIT, etc.).
|
||||
// Useful for CI builds that want to pin to a tagged release SHA
|
||||
// rather than whatever the checkout's HEAD happens to be.
|
||||
// 2. `git rev-parse HEAD` + `git rev-parse --abbrev-ref HEAD` against
|
||||
// the repo this build.rs lives in. Default for `cargo tauri build`
|
||||
// from a dev machine — pins the produced .exe to your current
|
||||
// checkout state.
|
||||
// 3. Last-resort fallback: hardcoded `main` branch, no commit. The
|
||||
// installer will fetch HEAD-of-main at runtime. Used when the
|
||||
// build is happening outside a git checkout (e.g. cargo install
|
||||
// from a packaged crate, unlikely for this binary but defensive).
|
||||
//
|
||||
// Build script reruns on git HEAD change so a new commit triggers
|
||||
// a rebuild without `cargo clean`.
|
||||
@@ -36,20 +30,11 @@ fn main() {
|
||||
|
||||
if let Some(c) = &commit {
|
||||
println!("cargo:rustc-env=BUILD_PIN_COMMIT={c}");
|
||||
println!(
|
||||
"cargo:warning=hermes-bootstrap: pinning to commit {}",
|
||||
short(c)
|
||||
);
|
||||
println!("cargo:warning=hermes-bootstrap: pinning to commit {}", short(c));
|
||||
}
|
||||
if let Some(b) = &branch {
|
||||
println!("cargo:rustc-env=BUILD_PIN_BRANCH={b}");
|
||||
match &commit {
|
||||
Some(_) => println!("cargo:warning=hermes-bootstrap: pinning to branch {b}"),
|
||||
None => println!(
|
||||
"cargo:warning=hermes-bootstrap: following branch {b} HEAD (no commit pin; \
|
||||
set HERMES_BUILD_PIN_COMMIT for an immutable pin)"
|
||||
),
|
||||
}
|
||||
println!("cargo:warning=hermes-bootstrap: pinning to branch {b}");
|
||||
}
|
||||
if commit.is_none() && branch.is_none() {
|
||||
// Fail loudly rather than silently produce a binary that errors
|
||||
@@ -61,11 +46,8 @@ fn main() {
|
||||
);
|
||||
}
|
||||
|
||||
// Rerun build.rs when HEAD moves. With branch-follow as the default the
|
||||
// baked commit no longer changes per-commit, but a branch *switch* changes
|
||||
// the detected branch name, so we still re-trigger. When an explicit
|
||||
// HERMES_BUILD_PIN_COMMIT resolves a moving ref (tag/branch) to a SHA, a
|
||||
// HEAD move can also change that resolution. .git/HEAD changes on every
|
||||
// Rerun build.rs when HEAD moves so successive builds pick up new
|
||||
// commits without needing `cargo clean`. .git/HEAD changes on every
|
||||
// commit / branch switch / rebase.
|
||||
let git_dir = locate_git_dir();
|
||||
if let Some(gd) = &git_dir {
|
||||
@@ -101,46 +83,24 @@ fn main() {
|
||||
}
|
||||
|
||||
fn resolve_commit_pin() -> Option<String> {
|
||||
// Commit pinning is OPT-IN. Only bake a commit when the caller explicitly
|
||||
// asks for one via HERMES_BUILD_PIN_COMMIT. With no env var, we return
|
||||
// None and the installer follows the branch HEAD at install time.
|
||||
let requested = std::env::var("HERMES_BUILD_PIN_COMMIT").ok()?;
|
||||
let requested = requested.trim();
|
||||
if requested.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Resolve the request (which may be a SHA, tag, or branch name) to an
|
||||
// immutable commit SHA so the baked pin is reproducible. `^{commit}`
|
||||
// dereferences tags to the commit they point at.
|
||||
if let Ok(out) = Command::new("git")
|
||||
.args(["rev-parse", "--verify", &format!("{requested}^{{commit}}")])
|
||||
.output()
|
||||
{
|
||||
if out.status.success() {
|
||||
if let Ok(s) = String::from_utf8(out.stdout) {
|
||||
let s = s.trim().to_string();
|
||||
if !s.is_empty() {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
if let Ok(v) = std::env::var("HERMES_BUILD_PIN_COMMIT") {
|
||||
if !v.trim().is_empty() {
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
// Couldn't resolve via git (e.g. building outside a checkout). Accept the
|
||||
// literal value only if it already looks like a SHA; otherwise fail loud
|
||||
// rather than bake an unresolvable ref into the binary.
|
||||
if is_sha(requested) {
|
||||
return Some(requested.to_string());
|
||||
let out = Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
panic!(
|
||||
"HERMES_BUILD_PIN_COMMIT={requested:?} could not be resolved to a commit \
|
||||
(git rev-parse failed and it is not a valid SHA)"
|
||||
);
|
||||
}
|
||||
|
||||
/// True if `s` looks like an abbreviated-or-full git SHA (7..=40 hex chars).
|
||||
fn is_sha(s: &str) -> bool {
|
||||
let len = s.len();
|
||||
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn resolve_branch_pin() -> Option<String> {
|
||||
|
||||
@@ -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
-405
@@ -8,8 +8,6 @@ const {
|
||||
ipcMain,
|
||||
nativeImage,
|
||||
nativeTheme,
|
||||
net: electronNet,
|
||||
protocol,
|
||||
safeStorage,
|
||||
session,
|
||||
shell,
|
||||
@@ -190,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.
|
||||
@@ -366,66 +364,6 @@ app.setAboutPanelOptions({
|
||||
copyright: 'Copyright © 2026 Nous Research'
|
||||
})
|
||||
|
||||
// Custom scheme for streaming local media (video/audio) into the renderer.
|
||||
// Reading large media through `readFileDataUrl` failed: it base64-loads the
|
||||
// whole file into memory and is hard-capped at DATA_URL_READ_MAX_BYTES (16 MB),
|
||||
// so any non-trivial video silently refused to load. Streaming via a protocol
|
||||
// handler removes the size cap and gives the <video> element seekable,
|
||||
// range-aware playback. Must be registered before the app is ready.
|
||||
const MEDIA_PROTOCOL = 'hermes-media'
|
||||
// Only audio/video may be streamed. Without this the handler would read any
|
||||
// non-blocklisted local file (no size cap) for any `fetch(hermes-media://…)`.
|
||||
const STREAMABLE_MEDIA_EXTS = new Set([
|
||||
'.avi',
|
||||
'.flac',
|
||||
'.m4a',
|
||||
'.mkv',
|
||||
'.mov',
|
||||
'.mp3',
|
||||
'.mp4',
|
||||
'.ogg',
|
||||
'.opus',
|
||||
'.wav',
|
||||
'.webm'
|
||||
])
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: MEDIA_PROTOCOL,
|
||||
privileges: {
|
||||
secure: true,
|
||||
standard: true,
|
||||
stream: true,
|
||||
supportFetchAPI: true
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
function registerMediaProtocol() {
|
||||
protocol.handle(MEDIA_PROTOCOL, async request => {
|
||||
let resolvedPath
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const filePath = decodeURIComponent(url.pathname.replace(/^\/+/, ''))
|
||||
;({ resolvedPath } = await resolveReadableFileForIpc(filePath, { purpose: 'Media stream' }))
|
||||
} catch {
|
||||
return new Response('Media not found', { status: 404 })
|
||||
}
|
||||
|
||||
if (!STREAMABLE_MEDIA_EXTS.has(path.extname(resolvedPath).toLowerCase())) {
|
||||
return new Response('Unsupported media type', { status: 415 })
|
||||
}
|
||||
|
||||
// Delegate to Electron's net stack on a file:// URL — it resolves the
|
||||
// content-type and honors Range requests so seeking works. Forward the
|
||||
// renderer's headers (notably Range) and skip custom-protocol re-entry.
|
||||
return electronNet.fetch(pathToFileURL(resolvedPath).toString(), {
|
||||
bypassCustomProtocolHandlers: true,
|
||||
headers: request.headers
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
let mainWindow = null
|
||||
let hermesProcess = null
|
||||
let connectionPromise = null
|
||||
@@ -1086,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 {
|
||||
@@ -1124,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 {
|
||||
@@ -1214,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
|
||||
@@ -1237,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.
|
||||
@@ -1275,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'))
|
||||
@@ -1480,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) {
|
||||
@@ -1715,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
|
||||
@@ -1885,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2702,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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3239,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())
|
||||
@@ -3411,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([
|
||||
@@ -3679,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,
|
||||
@@ -3716,7 +3361,6 @@ app.whenReady().then(() => {
|
||||
Menu.setApplicationMenu(null)
|
||||
}
|
||||
installMediaPermissions()
|
||||
registerMediaProtocol()
|
||||
ensureWslWindowsFonts()
|
||||
createWindow()
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -97,7 +93,7 @@ function ChatHeader({
|
||||
const sessions = useStore($sessions)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const activeStoredSession = sessions.find(session => session.id === selectedSessionId) || null
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New agent'
|
||||
const selectedIsPinned = selectedSessionId ? pinnedSessionIds.includes(selectedSessionId) : false
|
||||
|
||||
return (
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -67,12 +67,7 @@ import { VirtualSessionList } from './virtual-session-list'
|
||||
const VIRTUALIZE_THRESHOLD = 25
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{
|
||||
id: 'new-session',
|
||||
label: 'New session',
|
||||
icon: props => <Codicon name="robot" {...props} />,
|
||||
action: 'new-session'
|
||||
},
|
||||
{ id: 'new-session', label: 'New agent', icon: props => <Codicon name="robot" {...props} />, action: 'new-session' },
|
||||
{ id: 'skills', label: 'Skills', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE },
|
||||
{ id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
|
||||
{ id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
|
||||
@@ -154,8 +149,6 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
onLoadMoreSessions: () => void
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
onNewSessionInWorkspace: (path: null | string) => void
|
||||
}
|
||||
|
||||
export function ChatSidebar({
|
||||
@@ -163,9 +156,7 @@ export function ChatSidebar({
|
||||
onNavigate,
|
||||
onLoadMoreSessions,
|
||||
onResumeSession,
|
||||
onDeleteSession,
|
||||
onArchiveSession,
|
||||
onNewSessionInWorkspace
|
||||
onDeleteSession
|
||||
}: ChatSidebarProps) {
|
||||
const sidebarOpen = useStore($sidebarOpen)
|
||||
const agentsGrouped = useStore($sidebarAgentsGrouped)
|
||||
@@ -337,7 +328,6 @@ export function ChatSidebar({
|
||||
dndSensors={dndSensors}
|
||||
emptyState={<SidebarPinnedEmptyState />}
|
||||
label="Pinned"
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onReorder={handlePinnedDragEnd}
|
||||
onResumeSession={onResumeSession}
|
||||
@@ -371,9 +361,9 @@ export function ChatSidebar({
|
||||
groups={agentsGrouped ? agentGroups : undefined}
|
||||
headerAction={
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
aria-label={agentsGrouped ? 'Show agents as a single list' : 'Group agents by workspace'}
|
||||
className={cn(
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-0 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100 group-hover/section:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
@@ -382,17 +372,15 @@ export function ChatSidebar({
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
|
||||
title={agentsGrouped ? 'Ungroup agents' : 'Group by workspace'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
}
|
||||
label="Sessions"
|
||||
label="Agents"
|
||||
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onNewSessionInWorkspace={onNewSessionInWorkspace}
|
||||
onReorder={handleAgentDragEnd}
|
||||
onResumeSession={onResumeSession}
|
||||
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
|
||||
@@ -484,9 +472,7 @@ interface SidebarSessionsSectionProps {
|
||||
workingSessionIdSet: Set<string>
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
onTogglePin: (sessionId: string) => void
|
||||
onNewSessionInWorkspace?: (path: null | string) => void
|
||||
pinned: boolean
|
||||
rootClassName?: string
|
||||
contentClassName?: string
|
||||
@@ -510,9 +496,7 @@ function SidebarSessionsSection({
|
||||
workingSessionIdSet,
|
||||
onResumeSession,
|
||||
onDeleteSession,
|
||||
onArchiveSession,
|
||||
onTogglePin,
|
||||
onNewSessionInWorkspace,
|
||||
pinned,
|
||||
rootClassName,
|
||||
contentClassName,
|
||||
@@ -534,7 +518,6 @@ function SidebarSessionsSection({
|
||||
isPinned: pinned,
|
||||
isSelected: session.id === activeSessionId,
|
||||
isWorking: workingSessionIdSet.has(session.id),
|
||||
onArchive: () => onArchiveSession(session.id),
|
||||
onDelete: () => onDeleteSession(session.id),
|
||||
onPin: () => onTogglePin(session.id),
|
||||
onResume: () => onResumeSession(session.id),
|
||||
@@ -568,19 +551,9 @@ function SidebarSessionsSection({
|
||||
} else if (groups?.length) {
|
||||
const groupNodes = groups.map(group =>
|
||||
dndActive ? (
|
||||
<SortableSidebarWorkspaceGroup
|
||||
group={group}
|
||||
key={group.id}
|
||||
onNewSession={onNewSessionInWorkspace}
|
||||
renderRows={renderSessionList}
|
||||
/>
|
||||
<SortableSidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
|
||||
) : (
|
||||
<SidebarWorkspaceGroup
|
||||
group={group}
|
||||
key={group.id}
|
||||
onNewSession={onNewSessionInWorkspace}
|
||||
renderRows={renderSessionList}
|
||||
/>
|
||||
<SidebarWorkspaceGroup group={group} key={group.id} renderRows={renderSessionList} />
|
||||
)
|
||||
)
|
||||
|
||||
@@ -595,7 +568,6 @@ function SidebarSessionsSection({
|
||||
inner = (
|
||||
<VirtualSessionList
|
||||
activeSessionId={activeSessionId}
|
||||
onArchiveSession={onArchiveSession}
|
||||
onDeleteSession={onDeleteSession}
|
||||
onResumeSession={onResumeSession}
|
||||
onTogglePin={onTogglePin}
|
||||
@@ -638,7 +610,6 @@ function SidebarSessionsSection({
|
||||
interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
|
||||
group: SidebarSessionGroup
|
||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||
onNewSession?: (path: null | string) => void
|
||||
reorderable?: boolean
|
||||
dragging?: boolean
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>
|
||||
@@ -647,7 +618,6 @@ interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
|
||||
function SidebarWorkspaceGroup({
|
||||
group,
|
||||
renderRows,
|
||||
onNewSession,
|
||||
reorderable = false,
|
||||
dragging = false,
|
||||
dragHandleProps,
|
||||
@@ -664,31 +634,18 @@ function SidebarWorkspaceGroup({
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
|
||||
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
|
||||
<button
|
||||
className="flex min-w-0 cursor-pointer items-center gap-1 bg-transparent text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
title={group.path ?? undefined}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{group.label}</span>
|
||||
<SidebarCount>{group.sessions.length}</SidebarCount>
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
</button>
|
||||
{onNewSession && (
|
||||
<button
|
||||
aria-label={`New session in ${group.label}`}
|
||||
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
|
||||
onClick={() => onNewSession(group.path)}
|
||||
title={`New session in ${group.label}`}
|
||||
type="button"
|
||||
>
|
||||
<Codicon name="add" size="0.75rem" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="group/workspace flex min-h-6 cursor-pointer items-center gap-1 px-2 pt-1 text-left text-[0.6875rem] font-medium text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)"
|
||||
onClick={() => setOpen(value => !value)}
|
||||
title={group.path ?? undefined}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{group.label}</span>
|
||||
<SidebarCount>{group.sessions.length}</SidebarCount>
|
||||
<DisclosureCaret
|
||||
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
|
||||
open={open}
|
||||
/>
|
||||
{reorderable && (
|
||||
<span
|
||||
{...dragHandleProps}
|
||||
@@ -706,7 +663,7 @@ function SidebarWorkspaceGroup({
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
{renderRows(visibleSessions)}
|
||||
@@ -730,7 +687,6 @@ function SidebarWorkspaceGroup({
|
||||
interface SortableWorkspaceProps {
|
||||
group: SidebarSessionGroup
|
||||
renderRows: (sessions: SessionInfo[]) => React.ReactNode
|
||||
onNewSession?: (path: null | string) => void
|
||||
}
|
||||
|
||||
function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
|
||||
@@ -746,7 +702,6 @@ interface SortableSessionRowProps {
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onArchive: () => void
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
|
||||
@@ -26,7 +26,6 @@ interface SessionActions {
|
||||
title: string
|
||||
pinned?: boolean
|
||||
onPin?: () => void
|
||||
onArchive?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
@@ -41,7 +40,7 @@ interface ItemSpec {
|
||||
variant?: 'destructive'
|
||||
}
|
||||
|
||||
function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive, onDelete }: SessionActions) {
|
||||
function useSessionActions({ sessionId, title, pinned = false, onPin, onDelete }: SessionActions) {
|
||||
const [renameOpen, setRenameOpen] = useState(false)
|
||||
|
||||
const items: ItemSpec[] = [
|
||||
@@ -82,15 +81,6 @@ function useSessionActions({ sessionId, title, pinned = false, onPin, onArchive,
|
||||
setRenameOpen(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
disabled: !onArchive,
|
||||
icon: 'archive',
|
||||
label: 'Archive',
|
||||
onSelect: () => {
|
||||
triggerHaptic('selection')
|
||||
onArchive?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
className: 'text-destructive focus:text-destructive',
|
||||
disabled: !onDelete,
|
||||
|
||||
@@ -14,7 +14,6 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onArchive: () => void
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
@@ -46,7 +45,6 @@ export function SidebarSessionRow({
|
||||
isPinned,
|
||||
isSelected,
|
||||
isWorking,
|
||||
onArchive,
|
||||
onDelete,
|
||||
onPin,
|
||||
onResume,
|
||||
@@ -63,14 +61,7 @@ export function SidebarSessionRow({
|
||||
const handleLabel = `Reorder ${title}`
|
||||
|
||||
return (
|
||||
<SessionContextMenu
|
||||
onArchive={onArchive}
|
||||
onDelete={onDelete}
|
||||
onPin={onPin}
|
||||
pinned={isPinned}
|
||||
sessionId={session.id}
|
||||
title={title}
|
||||
>
|
||||
<SessionContextMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
|
||||
<div
|
||||
className={cn(
|
||||
'group relative grid min-h-[1.625rem] cursor-pointer grid-cols-[minmax(0,1fr)_1.375rem] items-center rounded-md transition-colors duration-100 ease-out hover:bg-(--ui-row-hover-background) hover:transition-none',
|
||||
@@ -97,15 +88,6 @@ export function SidebarSessionRow({
|
||||
return
|
||||
}
|
||||
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
onArchive()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onResume()
|
||||
}}
|
||||
type="button"
|
||||
@@ -145,14 +127,7 @@ export function SidebarSessionRow({
|
||||
{age}
|
||||
</span>
|
||||
)}
|
||||
<SessionActionsMenu
|
||||
onArchive={onArchive}
|
||||
onDelete={onDelete}
|
||||
onPin={onPin}
|
||||
pinned={isPinned}
|
||||
sessionId={session.id}
|
||||
title={title}
|
||||
>
|
||||
<SessionActionsMenu onDelete={onDelete} onPin={onPin} pinned={isPinned} sessionId={session.id} title={title}>
|
||||
<Button
|
||||
aria-label={`Actions for ${title}`}
|
||||
className="size-5 rounded-md bg-transparent text-transparent transition-colors duration-100 hover:bg-(--ui-control-active-background) hover:text-foreground focus-visible:bg-(--ui-control-active-background) focus-visible:text-foreground focus-visible:ring-0 data-[state=open]:bg-(--ui-control-active-background) data-[state=open]:text-foreground group-hover:text-(--ui-text-tertiary) [&_svg]:size-3.5!"
|
||||
|
||||
@@ -12,7 +12,6 @@ interface SessionRowCommonProps {
|
||||
isPinned: boolean
|
||||
isSelected: boolean
|
||||
isWorking: boolean
|
||||
onArchive: () => void
|
||||
onDelete: () => void
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
@@ -21,7 +20,6 @@ interface SessionRowCommonProps {
|
||||
interface VirtualSessionListProps {
|
||||
activeSessionId: null | string
|
||||
className?: string
|
||||
onArchiveSession: (sessionId: string) => void
|
||||
onDeleteSession: (sessionId: string) => void
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onTogglePin: (sessionId: string) => void
|
||||
@@ -37,7 +35,6 @@ const OVERSCAN_ROWS = 12
|
||||
export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
activeSessionId,
|
||||
className,
|
||||
onArchiveSession,
|
||||
onDeleteSession,
|
||||
onResumeSession,
|
||||
onTogglePin,
|
||||
@@ -75,7 +72,6 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
isPinned: pinned,
|
||||
isSelected: session.id === activeSessionId,
|
||||
isWorking: workingSessionIdSet.has(session.id),
|
||||
onArchive: () => onArchiveSession(session.id),
|
||||
onDelete: () => onDeleteSession(session.id),
|
||||
onPin: () => onTogglePin(session.id),
|
||||
onResume: () => onResumeSession(session.id)
|
||||
|
||||
@@ -113,7 +113,7 @@ interface SectionSearchEntry {
|
||||
}
|
||||
|
||||
const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
|
||||
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New session', detail: 'Start a fresh session' },
|
||||
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New agent', detail: 'Start a fresh session' },
|
||||
{ id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
|
||||
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
|
||||
{
|
||||
|
||||
@@ -3,10 +3,8 @@ 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 { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
|
||||
import { Pane, PaneMain } from '@/components/pane-shell'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
@@ -34,8 +32,6 @@ import {
|
||||
$selectedStoredSessionId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentModel,
|
||||
setCurrentProvider,
|
||||
setMessages,
|
||||
@@ -125,7 +121,6 @@ export function DesktopController() {
|
||||
settingsOpen,
|
||||
toggleCommandCenter
|
||||
} = useOverlayRouting()
|
||||
|
||||
const terminalTakeoverActive = chatOpen && terminalTakeover
|
||||
|
||||
const titlebarToolGroups = useGroupRegistry<TitlebarTool>()
|
||||
@@ -196,10 +191,7 @@ export function DesktopController() {
|
||||
|
||||
try {
|
||||
const limit = $sessionsLimit.get()
|
||||
// Require at least one message so abandoned/empty "Untitled" drafts (one
|
||||
// was created per TUI/desktop launch before the lazy-create fix) don't
|
||||
// clutter the sidebar.
|
||||
const result = await listSessions(limit, 1)
|
||||
const result = await listSessions(limit)
|
||||
|
||||
if (refreshSessionsRequestRef.current === requestId) {
|
||||
setSessions(result.sessions)
|
||||
@@ -331,7 +323,6 @@ export function DesktopController() {
|
||||
})
|
||||
|
||||
const {
|
||||
archiveSession,
|
||||
branchCurrentSession,
|
||||
createBackendSessionForSend,
|
||||
openSettings,
|
||||
@@ -400,29 +391,6 @@ export function DesktopController() {
|
||||
[branchCurrentSession, refreshSessions]
|
||||
)
|
||||
|
||||
const startSessionInWorkspace = useCallback(
|
||||
(path: null | string) => {
|
||||
startFreshSessionDraft()
|
||||
|
||||
const target = path?.trim()
|
||||
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
// The next message creates the backend session in $currentCwd, so seed
|
||||
// it (and the branch) from the workspace the user clicked the + on.
|
||||
setCurrentCwd(target)
|
||||
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
|
||||
.then(info => {
|
||||
setCurrentCwd(info.cwd || target)
|
||||
setCurrentBranch(info.branch || '')
|
||||
})
|
||||
.catch(() => undefined)
|
||||
},
|
||||
[requestGateway, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
const handleSkinCommand = useSkinCommand()
|
||||
|
||||
const { cancelRun, editMessage, handleThreadMessagesChange, reloadFromMessage, submitText, transcribeVoiceAudio } =
|
||||
@@ -492,11 +460,9 @@ export function DesktopController() {
|
||||
const sidebar = (
|
||||
<ChatSidebar
|
||||
currentView={currentView}
|
||||
onArchiveSession={sessionId => void archiveSession(sessionId)}
|
||||
onDeleteSession={sessionId => void removeSession(sessionId)}
|
||||
onLoadMoreSessions={loadMoreSessions}
|
||||
onNavigate={selectSidebarItem}
|
||||
onNewSessionInWorkspace={startSessionInWorkspace}
|
||||
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
|
||||
/>
|
||||
)
|
||||
@@ -518,8 +484,6 @@ export function DesktopController() {
|
||||
/>
|
||||
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
|
||||
<UpdatesOverlay />
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
|
||||
{settingsOpen && (
|
||||
<Suspense fallback={null}>
|
||||
@@ -609,10 +573,10 @@ export function DesktopController() {
|
||||
titlebarTools={titlebarToolGroups.flat.right}
|
||||
>
|
||||
<Pane
|
||||
disabled={terminalTakeoverActive}
|
||||
id="chat-sidebar"
|
||||
maxWidth={SIDEBAR_MAX_WIDTH}
|
||||
minWidth={SIDEBAR_DEFAULT_WIDTH}
|
||||
disabled={terminalTakeoverActive}
|
||||
resizable
|
||||
side="left"
|
||||
width={`${SIDEBAR_DEFAULT_WIDTH}px`}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { MutableRefObject } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { NavigateFunction } from 'react-router-dom'
|
||||
|
||||
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
|
||||
import { deleteSession, getSessionMessages } from '@/hermes'
|
||||
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
import { normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
|
||||
@@ -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)
|
||||
}
|
||||
@@ -751,39 +748,7 @@ export function useSessionActions({
|
||||
]
|
||||
)
|
||||
|
||||
const archiveSession = useCallback(
|
||||
async (storedSessionId: string) => {
|
||||
clearNotifications()
|
||||
|
||||
const archived = $sessions.get().find(s => s.id === storedSessionId)
|
||||
const wasSelected = selectedStoredSessionId === storedSessionId
|
||||
const previousPinned = $pinnedSessionIds.get()
|
||||
|
||||
// Soft-hide: drop from the sidebar immediately, keep the data.
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
|
||||
|
||||
if (wasSelected) {
|
||||
startFreshSessionDraft(true)
|
||||
}
|
||||
|
||||
try {
|
||||
await setSessionArchived(storedSessionId, true)
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Archived' })
|
||||
} catch (err) {
|
||||
if (archived) {
|
||||
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
|
||||
}
|
||||
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
notifyError(err, 'Archive failed')
|
||||
}
|
||||
},
|
||||
[selectedStoredSessionId, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
return {
|
||||
archiveSession,
|
||||
branchCurrentSession,
|
||||
closeSettings,
|
||||
createBackendSessionForSend,
|
||||
|
||||
@@ -308,18 +308,14 @@ 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' | 'sessions' | 'tools',
|
||||
string
|
||||
> = {
|
||||
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools', string> = {
|
||||
about: 'About Hermes Desktop',
|
||||
config: 'Search settings...',
|
||||
gateway: 'Gateway connection...',
|
||||
keys: 'Search API keys...',
|
||||
mcp: 'Search MCP servers...',
|
||||
sessions: 'Search archived sessions...',
|
||||
tools: 'Search skills and tools...'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'
|
||||
import { Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
||||
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
||||
@@ -19,7 +19,6 @@ import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KeysSettings } from './keys-settings'
|
||||
import { McpSettings } from './mcp-settings'
|
||||
import { SessionsSettings } from './sessions-settings'
|
||||
import { ToolsSettings } from './tools-settings'
|
||||
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
|
||||
|
||||
@@ -28,7 +27,6 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
'gateway',
|
||||
'keys',
|
||||
'mcp',
|
||||
'sessions',
|
||||
'tools',
|
||||
'about'
|
||||
]
|
||||
@@ -42,7 +40,6 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
|
||||
gateway: '',
|
||||
keys: '',
|
||||
mcp: '',
|
||||
sessions: '',
|
||||
tools: ''
|
||||
})
|
||||
|
||||
@@ -152,12 +149,6 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
|
||||
label="MCP"
|
||||
onClick={() => setActiveView('mcp')}
|
||||
/>
|
||||
<OverlayNavItem
|
||||
active={activeView === 'sessions'}
|
||||
icon={Archive}
|
||||
label="Archived Chats"
|
||||
onClick={() => setActiveView('sessions')}
|
||||
/>
|
||||
<div className="my-2 h-px bg-border/30" />
|
||||
<OverlayNavItem
|
||||
active={activeView === 'about'}
|
||||
@@ -209,8 +200,6 @@ export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPagePr
|
||||
<KeysSettings query={queries.keys} />
|
||||
) : activeView === 'mcp' ? (
|
||||
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />
|
||||
) : activeView === 'sessions' ? (
|
||||
<SessionsSettings query={queries.sessions} />
|
||||
) : (
|
||||
<ToolsSettings query={queries.tools} />
|
||||
)}
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, ArchiveOff, Loader2, Trash2 } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { SearchProps } from './types'
|
||||
|
||||
const ARCHIVED_FETCH_LIMIT = 200
|
||||
|
||||
function workspaceLabel(cwd: null | string | undefined): string {
|
||||
const path = cwd?.trim()
|
||||
|
||||
if (!path) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return (
|
||||
path
|
||||
.replace(/[/\\]+$/, '')
|
||||
.split(/[/\\]/)
|
||||
.filter(Boolean)
|
||||
.pop() ?? path
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionsSettings({ query }: SearchProps) {
|
||||
const [sessions, setLocalSessions] = useState<SessionInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const result = await listSessions(ARCHIVED_FETCH_LIMIT, 0, 'only')
|
||||
setLocalSessions(result.sessions)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not load archived sessions')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const unarchive = useCallback(async (session: SessionInfo) => {
|
||||
setBusyId(session.id)
|
||||
|
||||
try {
|
||||
await setSessionArchived(session.id, false)
|
||||
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
|
||||
// Surface it again in the sidebar without waiting for a full refresh.
|
||||
setSessions(prev => [{ ...session, archived: false }, ...prev.filter(s => s.id !== session.id)])
|
||||
triggerHaptic('selection')
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Restored' })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Unarchive failed')
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const remove = useCallback(async (session: SessionInfo) => {
|
||||
if (!window.confirm(`Permanently delete "${sessionTitle(session)}"? This cannot be undone.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusyId(session.id)
|
||||
|
||||
try {
|
||||
await deleteSession(session.id)
|
||||
setLocalSessions(prev => prev.filter(s => s.id !== session.id))
|
||||
triggerHaptic('warning')
|
||||
} catch (err) {
|
||||
notifyError(err, 'Delete failed')
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
if (!needle) {
|
||||
return sessions
|
||||
}
|
||||
|
||||
return sessions.filter(session =>
|
||||
[sessionTitle(session), session.preview ?? '', session.cwd ?? ''].join(' ').toLowerCase().includes(needle)
|
||||
)
|
||||
}, [query, sessions])
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState label="Loading archived sessions…" />
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<SectionHeading
|
||||
icon={Archive}
|
||||
meta={sessions.length ? String(sessions.length) : undefined}
|
||||
title="Archived sessions"
|
||||
/>
|
||||
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to
|
||||
archive it.
|
||||
</p>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
description={query.trim() ? 'No archived chats match your search.' : 'Archive a chat to hide it here.'}
|
||||
title="Nothing archived"
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-border/30">
|
||||
{filtered.map(session => {
|
||||
const label = workspaceLabel(session.cwd)
|
||||
const busy = busyId === session.id
|
||||
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void unarchive(session)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <ArchiveOff className="size-3.5" />}
|
||||
<span>Unarchive</span>
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Delete permanently"
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => void remove(session)}
|
||||
size="icon"
|
||||
title="Delete permanently"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
description={session.preview || undefined}
|
||||
hint={label ? `${label} · ${session.message_count} messages` : `${session.message_count} messages`}
|
||||
key={session.id}
|
||||
title={sessionTitle(session)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import type { HermesGateway } from '@/hermes'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import type { EnvVarInfo } from '@/types/hermes'
|
||||
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools' | `config:${string}`
|
||||
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools'
|
||||
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'tools' | `config:${string}`
|
||||
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools'
|
||||
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
|
||||
|
||||
export interface SettingsPageProps {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,9 +19,9 @@ import {
|
||||
filePathFromMediaPath,
|
||||
mediaExternalUrl,
|
||||
mediaKind,
|
||||
mediaMime,
|
||||
mediaName,
|
||||
mediaPathFromMarkdownHref,
|
||||
mediaStreamUrl
|
||||
mediaPathFromMarkdownHref
|
||||
} from '@/lib/media'
|
||||
import { previewTargetFromMarkdownHref } from '@/lib/preview-targets'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -40,22 +40,24 @@ import { cn } from '@/lib/utils'
|
||||
// LLM convention). The default false-setting only accepts `$$...$$`.
|
||||
const mathPlugin = createMemoizedMathPlugin({ singleDollarTextMath: true })
|
||||
|
||||
async function typedBlobUrl(dataUrl: string, mime: string): Promise<string> {
|
||||
const blob = await fetch(dataUrl).then(response => response.blob())
|
||||
|
||||
return URL.createObjectURL(new Blob([await blob.arrayBuffer()], { type: mime }))
|
||||
}
|
||||
|
||||
async function mediaSrc(path: string): Promise<string> {
|
||||
if (/^(?:https?|data):/i.test(path)) {
|
||||
return path
|
||||
}
|
||||
|
||||
// Stream audio/video through the custom protocol: data URLs are capped and
|
||||
// load the whole file into memory, which broke playback for larger videos.
|
||||
if (window.hermesDesktop && ['audio', 'video'].includes(mediaKind(path))) {
|
||||
return mediaStreamUrl(path)
|
||||
}
|
||||
|
||||
if (!window.hermesDesktop?.readFileDataUrl) {
|
||||
return mediaExternalUrl(path)
|
||||
}
|
||||
|
||||
return window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
|
||||
const dataUrl = await window.hermesDesktop.readFileDataUrl(filePathFromMediaPath(path))
|
||||
|
||||
return ['audio', 'video'].includes(mediaKind(path)) ? typedBlobUrl(dataUrl, mediaMime(path)) : dataUrl
|
||||
}
|
||||
|
||||
function OpenMediaButton({ kind, path }: { kind: 'audio' | 'video'; path: string }) {
|
||||
@@ -276,7 +278,10 @@ const MarkdownTextImpl = () => {
|
||||
// render, which churns Streamdown's outer memo + propagates new prop
|
||||
// identities into every Block. The plugin set really only varies on
|
||||
// `isStreaming`, so memoize on that.
|
||||
const plugins = useMemo(() => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }), [isStreaming])
|
||||
const plugins = useMemo(
|
||||
() => (isStreaming ? { math: mathPlugin } : { math: mathPlugin, code }),
|
||||
[isStreaming]
|
||||
)
|
||||
|
||||
const components = useMemo(
|
||||
() =>
|
||||
@@ -350,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,183 +0,0 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
|
||||
// Static, always-legible prefix; only TAIL ever scrambles. Splitting them at
|
||||
// the render level means no timer logic (even a stale HMR one) can ever
|
||||
// scramble "CONN".
|
||||
const PREFIX = 'CONN'
|
||||
const TAIL = 'ECTING'
|
||||
// Even-weight mono ascii so cycling glyphs don't jump width (matches the
|
||||
// nousnet-web download-button decode effect).
|
||||
const SCRAMBLE_CHARS = '/\\|-_=+<>~:*'
|
||||
const TICK_MS = 45
|
||||
|
||||
// Exit choreography (ms): text fades down + out, hold, then the overlay fades.
|
||||
const TEXT_OUT_MS = 360
|
||||
const POST_TEXT_HOLD_MS = 300
|
||||
const OVERLAY_OUT_MS = 520
|
||||
// Preview-only: how long to "connect" for, and the pause before replaying.
|
||||
const PREVIEW_CONNECT_MS = 2600
|
||||
const PREVIEW_REPLAY_MS = 1100
|
||||
|
||||
type Phase = 'live' | 'text-out' | 'overlay-out' | 'gone'
|
||||
|
||||
// Dev affordance: a warm Cmd+R reconnects almost instantly, so the overlay
|
||||
// only flashes. Load with `?connecting=1` to force a looping preview.
|
||||
function forcedPreview(): boolean {
|
||||
if (!import.meta.env.DEV || typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).get('connecting') === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function scrambledTail(resolvedCount: number): string {
|
||||
return Array.from(TAIL, (ch, i) =>
|
||||
i < resolvedCount ? ch : SCRAMBLE_CHARS[(Math.random() * SCRAMBLE_CHARS.length) | 0]
|
||||
).join('')
|
||||
}
|
||||
|
||||
export function GatewayConnectingOverlay() {
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const boot = useStore($desktopBoot)
|
||||
const [previewing] = useState(forcedPreview)
|
||||
const [tail, setTail] = useState(TAIL)
|
||||
const [phase, setPhase] = useState<Phase>('live')
|
||||
|
||||
const connecting = gatewayState !== 'open' && !boot.error
|
||||
// Latches once we've actually shown the overlay, so the brief frame where
|
||||
// gatewayState flips to "open" (connecting -> false) before the exit phase
|
||||
// kicks in doesn't unmount us and cause a flash.
|
||||
const shownRef = useRef(false)
|
||||
|
||||
if (previewing || connecting) {
|
||||
shownRef.current = true
|
||||
}
|
||||
|
||||
// Decode loop — only while live (freeze the resolved word during the exit).
|
||||
useEffect(() => {
|
||||
if (phase !== 'live' || (!previewing && !connecting)) {
|
||||
return
|
||||
}
|
||||
|
||||
let resolved = 0
|
||||
let hold = 0
|
||||
|
||||
const id = window.setInterval(() => {
|
||||
if (resolved >= TAIL.length) {
|
||||
hold += 1
|
||||
|
||||
if (hold > 16) {
|
||||
resolved = 0
|
||||
hold = 0
|
||||
}
|
||||
|
||||
setTail(TAIL)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
resolved += 0.5
|
||||
setTail(scrambledTail(Math.floor(resolved)))
|
||||
}, TICK_MS)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [phase, previewing, connecting])
|
||||
|
||||
// Kick off the exit when connected: real connect, or a faked timer in preview.
|
||||
useEffect(() => {
|
||||
if (phase !== 'live') {
|
||||
return
|
||||
}
|
||||
|
||||
if (previewing) {
|
||||
const id = window.setTimeout(() => {
|
||||
setTail(TAIL)
|
||||
setPhase('text-out')
|
||||
}, PREVIEW_CONNECT_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}
|
||||
|
||||
if (gatewayState === 'open' && shownRef.current) {
|
||||
setTail(TAIL)
|
||||
setPhase('text-out')
|
||||
}
|
||||
}, [phase, previewing, gatewayState])
|
||||
|
||||
// Advance the exit choreography: text-out -> overlay-out -> gone.
|
||||
useEffect(() => {
|
||||
if (phase === 'text-out') {
|
||||
const id = window.setTimeout(() => setPhase('overlay-out'), TEXT_OUT_MS + POST_TEXT_HOLD_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}
|
||||
|
||||
if (phase === 'overlay-out') {
|
||||
const id = window.setTimeout(() => setPhase('gone'), OVERLAY_OUT_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}
|
||||
|
||||
// Preview replays so we can keep watching the transition.
|
||||
if (phase === 'gone' && previewing) {
|
||||
const id = window.setTimeout(() => {
|
||||
setTail(TAIL)
|
||||
setPhase('live')
|
||||
}, PREVIEW_REPLAY_MS)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}
|
||||
}, [phase, previewing])
|
||||
|
||||
// Boot failed — BootFailureOverlay owns the screen; don't linger behind it.
|
||||
if (boot.error && !previewing) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Real connect: once the fade finishes, get out of the way for good.
|
||||
if (phase === 'gone' && !previewing) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Never showed (e.g. gateway already up on a warm reload) — stay out.
|
||||
if (!previewing && !connecting && !shownRef.current) {
|
||||
return null
|
||||
}
|
||||
|
||||
const leaving = phase !== 'live'
|
||||
const overlayHidden = phase === 'overlay-out' || phase === 'gone'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-[1200] grid place-items-center bg-(--ui-chat-surface-background) transition-opacity duration-500 ease-out',
|
||||
overlayHidden ? 'pointer-events-none opacity-0' : 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<style>{'@keyframes gco-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center pl-[0.4em] font-mono text-[0.64rem] font-semibold uppercase tracking-[0.4em] tabular-nums text-(--theme-primary) transition duration-300 ease-out',
|
||||
leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100'
|
||||
)}
|
||||
>
|
||||
{PREFIX}
|
||||
{tail}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="dither ml-0.5 inline-block size-2 shrink-0 -translate-y-px rounded-[1px]"
|
||||
style={{ animation: 'gco-cursor 1s step-end infinite' }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -111,13 +109,9 @@ export class HermesGateway extends JsonRpcGatewayClient {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSessions(
|
||||
limit = 40,
|
||||
minMessages = 0,
|
||||
archived: 'exclude' | 'include' | 'only' = 'exclude'
|
||||
): Promise<PaginatedSessions> {
|
||||
export async function listSessions(limit = 40, minMessages = 0): Promise<PaginatedSessions> {
|
||||
const result = await window.hermesDesktop.api<PaginatedSessions>({
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}`
|
||||
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}`
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -127,14 +121,6 @@ export async function listSessions(
|
||||
}
|
||||
}
|
||||
|
||||
export function setSessionArchived(id: string, archived: boolean): Promise<{ ok: boolean }> {
|
||||
return window.hermesDesktop.api<{ ok: boolean }>({
|
||||
path: `/api/sessions/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: { archived }
|
||||
})
|
||||
}
|
||||
|
||||
export function searchSessions(query: string): Promise<SessionSearchResponse> {
|
||||
return window.hermesDesktop.api<SessionSearchResponse>({
|
||||
path: `/api/sessions/search?q=${encodeURIComponent(query)}`
|
||||
@@ -251,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',
|
||||
@@ -333,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'
|
||||
@@ -502,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
|
||||
|
||||
@@ -59,7 +59,7 @@ const DESKTOP_ALIASES = new Map([
|
||||
|
||||
const DESKTOP_COMMAND_DESCRIPTIONS: ReadonlyMap<string, string> = new Map(DESKTOP_COMMAND_META)
|
||||
|
||||
const PICKER_OWNED_COMMANDS = new Set(['/model'])
|
||||
const PICKER_OWNED_COMMANDS = new Set(['/model', '/provider'])
|
||||
|
||||
const TERMINAL_ONLY_COMMANDS = new Set([
|
||||
'/browser',
|
||||
|
||||
@@ -2,8 +2,6 @@ import {
|
||||
IconActivity as Activity,
|
||||
IconAlertCircle as AlertCircle,
|
||||
IconAlertTriangle as AlertTriangle,
|
||||
IconArchive as Archive,
|
||||
IconArchiveOff as ArchiveOff,
|
||||
IconArrowUp as ArrowUp,
|
||||
IconArrowUpRight as ArrowUpRight,
|
||||
IconAt as AtSign,
|
||||
@@ -100,8 +98,6 @@ export {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Archive,
|
||||
ArchiveOff,
|
||||
ArrowUp,
|
||||
ArrowUpRight,
|
||||
AtSign,
|
||||
|
||||
@@ -58,13 +58,6 @@ export function mediaExternalUrl(path: string): string {
|
||||
return /^(?:https?|file):/i.test(path) ? path : `file://${path}`
|
||||
}
|
||||
|
||||
// Custom Electron scheme (registered in electron/main.cjs) that streams a local
|
||||
// file with Range support. Used for audio/video so playback bypasses the data
|
||||
// URL size cap and supports seeking. `path` may be a plain path or `file://…`.
|
||||
export function mediaStreamUrl(path: string): string {
|
||||
return `hermes-media://stream/${encodeURIComponent(filePathFromMediaPath(path))}`
|
||||
}
|
||||
|
||||
export function mediaPathFromMarkdownHref(href?: string): string | null {
|
||||
if (!href?.startsWith('#media:')) {
|
||||
return null
|
||||
|
||||
@@ -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 {
|
||||
@@ -240,7 +222,6 @@ export interface SessionCreateResponse {
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
archived?: boolean
|
||||
cwd?: null | string
|
||||
ended_at: null | number
|
||||
id: string
|
||||
@@ -291,7 +272,6 @@ export interface SessionRuntimeInfo {
|
||||
config_warning?: string
|
||||
credential_warning?: string
|
||||
cwd?: string
|
||||
desktop_contract?: number
|
||||
fast?: boolean
|
||||
model?: string
|
||||
personality?: string
|
||||
@@ -456,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
|
||||
@@ -556,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
|
||||
|
||||
@@ -2116,41 +2100,6 @@ def _cprint(text: str):
|
||||
pass
|
||||
|
||||
|
||||
def _prepend_note_to_message(message, note: str):
|
||||
"""Prepend a one-shot system-style note to a user message.
|
||||
|
||||
``message`` is normally a plain string, but when the user attaches an image
|
||||
to a vision-capable model it becomes a list of OpenAI-style content parts
|
||||
(text + ``image_url`` blocks). Naively doing ``note + "\\n\\n" + message``
|
||||
then raises ``TypeError: can only concatenate str (not "list") to str`` —
|
||||
e.g. running ``/model ...`` (which queues a model-switch note) and then
|
||||
sending a pasted image in the same turn.
|
||||
|
||||
Returns the message with ``note`` prepended:
|
||||
* ``str`` → ``f"{note}\\n\\n{message}"`` (just ``note`` when empty)
|
||||
* ``list`` → note folded into the first text part, or inserted as a new
|
||||
leading ``{"type": "text"}`` part when there is no text part.
|
||||
Unknown shapes are returned unchanged (fail-open).
|
||||
"""
|
||||
note = str(note or "").strip()
|
||||
if not note:
|
||||
return message
|
||||
if isinstance(message, str):
|
||||
return f"{note}\n\n{message}" if message else note
|
||||
if isinstance(message, list):
|
||||
parts = list(message)
|
||||
for i, part in enumerate(parts):
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
merged = dict(part)
|
||||
text = merged.get("text", "")
|
||||
merged["text"] = f"{note}\n\n{text}" if text else note
|
||||
parts[i] = merged
|
||||
return parts
|
||||
# No text part (image-only) — insert the note as a leading text block.
|
||||
return [{"type": "text", "text": note}, *parts]
|
||||
return message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File-drop / local attachment detection — extracted as pure helpers for tests.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -3613,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
|
||||
@@ -4932,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:
|
||||
@@ -5594,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']}")
|
||||
@@ -7138,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."""
|
||||
@@ -8753,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":
|
||||
@@ -12170,21 +11971,17 @@ class HermesCLI:
|
||||
reset_current_session_key = None # type: ignore[assignment]
|
||||
_approval_session_token = None
|
||||
agent_message = _voice_prefix + message if _voice_prefix else message
|
||||
# Prepend pending notes via _prepend_note_to_message, which
|
||||
# handles both plain-string and multimodal content-parts list
|
||||
# messages. Naive ``note + "\n\n" + agent_message`` crashed with
|
||||
# TypeError when an image was attached (agent_message is a list)
|
||||
# and a /model or /reload-skills note was queued for the turn.
|
||||
# Prepend pending model switch note so the model knows about the switch
|
||||
_msn = getattr(self, '_pending_model_switch_note', None)
|
||||
if _msn:
|
||||
agent_message = _prepend_note_to_message(agent_message, _msn)
|
||||
agent_message = _msn + "\n\n" + agent_message
|
||||
self._pending_model_switch_note = None
|
||||
# Prepend pending /reload-skills note so the model sees which
|
||||
# skills were added/removed before handling this turn. Same
|
||||
# one-shot queue pattern as the model-switch note above.
|
||||
_srn = getattr(self, '_pending_skills_reload_note', None)
|
||||
if _srn:
|
||||
agent_message = _prepend_note_to_message(agent_message, _srn)
|
||||
agent_message = _srn + "\n\n" + agent_message
|
||||
self._pending_skills_reload_note = None
|
||||
try:
|
||||
result = self.agent.run_conversation(
|
||||
@@ -13131,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
|
||||
@@ -13149,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
|
||||
@@ -15240,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,
|
||||
@@ -15727,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)
|
||||
|
||||
|
||||
+8
-27
@@ -428,18 +428,22 @@ def load_jobs() -> List[Dict[str, Any]]:
|
||||
ensure_dirs()
|
||||
if not JOBS_FILE.exists():
|
||||
return []
|
||||
|
||||
_strict_retry = False # track whether we used the strict=False fallback
|
||||
|
||||
|
||||
try:
|
||||
with open(JOBS_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
return data.get("jobs", [])
|
||||
except json.JSONDecodeError:
|
||||
# Retry with strict=False to handle bare control chars in string values
|
||||
_strict_retry = True
|
||||
try:
|
||||
with open(JOBS_FILE, 'r', encoding='utf-8') as f:
|
||||
data = json.loads(f.read(), strict=False)
|
||||
jobs = data.get("jobs", [])
|
||||
if jobs:
|
||||
# Auto-repair: rewrite with proper escaping
|
||||
save_jobs(jobs)
|
||||
logger.warning("Auto-repaired jobs.json (had invalid control characters)")
|
||||
return jobs
|
||||
except Exception as e:
|
||||
logger.error("Failed to auto-repair jobs.json: %s", e)
|
||||
raise RuntimeError(f"Cron database corrupted and unrepairable: {e}") from e
|
||||
@@ -447,29 +451,6 @@ def load_jobs() -> List[Dict[str, Any]]:
|
||||
logger.error("IOError reading jobs.json: %s", e)
|
||||
raise RuntimeError(f"Failed to read cron database: {e}") from e
|
||||
|
||||
# Validate the top-level JSON shape: accept a dict (expected) or a bare
|
||||
# list (auto-repair). Anything else (str/number/null) is corruption that
|
||||
# would otherwise raise an uncaught AttributeError on ``.get()`` and take
|
||||
# down the whole cron subsystem.
|
||||
if isinstance(data, dict):
|
||||
jobs = data.get("jobs", [])
|
||||
if _strict_retry and jobs:
|
||||
# Hit control-character corruption — rewrite with proper escaping.
|
||||
save_jobs(jobs)
|
||||
logger.warning("Auto-repaired jobs.json (had invalid control characters)")
|
||||
return jobs
|
||||
if isinstance(data, list):
|
||||
# Bare array — likely saved/edited outside save_jobs(). Wrap it back
|
||||
# into the expected {"jobs": [...]} structure.
|
||||
if data:
|
||||
save_jobs(data)
|
||||
logger.warning("Auto-repaired jobs.json (bare list wrapped as dict)")
|
||||
return data
|
||||
|
||||
raise RuntimeError(
|
||||
f"Cron database corrupted: expected {{'jobs': [...]}}, got {type(data).__name__}"
|
||||
)
|
||||
|
||||
|
||||
def save_jobs(jobs: List[Dict[str, Any]]):
|
||||
"""Save all jobs to storage."""
|
||||
|
||||
+5
-13
@@ -1182,22 +1182,14 @@ def _scan_assembled_cron_prompt(assembled: str, job: dict, *, has_skills: bool =
|
||||
markdown — often security docs / runbooks that *describe* attack
|
||||
commands in prose. The LOOSER ``_scan_cron_skill_assembled``
|
||||
pattern set is used: only unambiguous prompt-injection directives
|
||||
block; command-shape patterns are dropped and invisible unicode is
|
||||
sanitized (stripped + logged) rather than blocked, to avoid
|
||||
false-positives that permanently kill a job. Skill bodies are
|
||||
vetted at install time by ``skills_guard.py``.
|
||||
and invisible unicode block, command-shape patterns are dropped
|
||||
to avoid false-positives. Skill bodies are vetted at install time
|
||||
by ``skills_guard.py``.
|
||||
"""
|
||||
from tools.cronjob_tools import _scan_cron_prompt, _scan_cron_skill_assembled
|
||||
|
||||
if has_skills:
|
||||
# Skill content is install-time vetted by skills_guard.py. Invisible
|
||||
# unicode is sanitized (not blocked) so a stray zero-width space in a
|
||||
# skill code example can't permanently kill the job; the cleaned
|
||||
# prompt is what actually runs.
|
||||
cleaned, scan_error = _scan_cron_skill_assembled(assembled)
|
||||
assembled = cleaned
|
||||
else:
|
||||
scan_error = _scan_cron_prompt(assembled)
|
||||
scanner = _scan_cron_skill_assembled if has_skills else _scan_cron_prompt
|
||||
scan_error = scanner(assembled)
|
||||
if scan_error:
|
||||
job_label = job.get("name") or job.get("id") or "<unknown>"
|
||||
logger.warning(
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-16
@@ -16,39 +16,28 @@
|
||||
# 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)
|
||||
# don't try to write to /root.
|
||||
export HOME=/opt/data
|
||||
|
||||
# Save the Docker -w (or default) working directory before init
|
||||
# scripts cd to /opt/data, so the container starts in the
|
||||
# directory the user requested.
|
||||
_hermes_orig_cwd="${HERMES_ORIG_CWD:-$PWD}"
|
||||
|
||||
cd /opt/data
|
||||
# shellcheck disable=SC1091
|
||||
. /opt/hermes/.venv/bin/activate
|
||||
|
||||
# Restore the original working directory before handing off to
|
||||
# the user's command so `hermes chat` starts in the Docker -w
|
||||
# directory, not /opt/data.
|
||||
cd "$_hermes_orig_cwd"
|
||||
|
||||
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"
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
# Multi-gateway deployment
|
||||
|
||||
Hermes supports multiple gateway processes running concurrently — one per profile
|
||||
(default, writer, admin, coder, researcher). Each gateway opens its own connection
|
||||
to platform APIs and delivers messages for its profile's subscribers.
|
||||
|
||||
## Single-dispatcher posture
|
||||
|
||||
Only one gateway owns the kanban dispatcher. The owning gateway keeps
|
||||
`kanban.dispatch_in_gateway: true` (the default); every other gateway sets it
|
||||
to `false`.
|
||||
|
||||
**Why this matters:** a gateway with `dispatch_in_gateway: true` opens per-board
|
||||
SQLite connections for both the dispatcher and the notifier watcher. Multiple
|
||||
gateways doing this concurrently multiplies the open file descriptors on each
|
||||
`kanban.db` and amplifies WAL `-shm` reader contention. Gating both paths on the
|
||||
same flag means exactly one process touches the kanban DBs.
|
||||
|
||||
## Configuration
|
||||
|
||||
On the dispatch-owning gateway (typically the `default` profile), no change is
|
||||
needed. On every other profile gateway, add to `~/.hermes/config.yaml`:
|
||||
|
||||
```yaml
|
||||
kanban:
|
||||
dispatch_in_gateway: false
|
||||
```
|
||||
|
||||
Or set the env var: `HERMES_KANBAN_DISPATCH_IN_GATEWAY=false`
|
||||
|
||||
## What each gateway does
|
||||
|
||||
| Gateway role | dispatch_in_gateway | Opens per-board DBs? | Runs dispatcher + notifier? |
|
||||
|---|---|---|---|
|
||||
| default (dispatch owner) | true (default) | yes | yes |
|
||||
| writer, admin, coder, etc. | false | no | no |
|
||||
|
||||
Non-dispatch gateways still deliver messages for their own platform adapters
|
||||
(Telegram, Discord, etc.) — they just don't poll kanban boards.
|
||||
@@ -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
-59
@@ -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:
|
||||
@@ -1722,22 +1684,6 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
"webhook_path": os.getenv("BLUEBUBBLES_WEBHOOK_PATH", "/bluebubbles-webhook"),
|
||||
"send_read_receipts": os.getenv("BLUEBUBBLES_SEND_READ_RECEIPTS", "true").lower() in {"true", "1", "yes"},
|
||||
})
|
||||
bluebubbles_require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION")
|
||||
if bluebubbles_require_mention is not None:
|
||||
config.platforms[Platform.BLUEBUBBLES].extra["require_mention"] = (
|
||||
bluebubbles_require_mention.lower() in {"true", "1", "yes", "on"}
|
||||
)
|
||||
bluebubbles_mention_patterns = os.getenv("BLUEBUBBLES_MENTION_PATTERNS")
|
||||
if bluebubbles_mention_patterns:
|
||||
try:
|
||||
parsed_patterns = json.loads(bluebubbles_mention_patterns)
|
||||
except Exception:
|
||||
parsed_patterns = [
|
||||
part.strip()
|
||||
for part in bluebubbles_mention_patterns.replace("\n", ",").split(",")
|
||||
if part.strip()
|
||||
]
|
||||
config.platforms[Platform.BLUEBUBBLES].extra["mention_patterns"] = parsed_patterns
|
||||
bluebubbles_home = os.getenv("BLUEBUBBLES_HOME_CHANNEL")
|
||||
if bluebubbles_home and Platform.BLUEBUBBLES in config.platforms:
|
||||
config.platforms[Platform.BLUEBUBBLES].home_channel = HomeChannel(
|
||||
|
||||
@@ -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
|
||||
|
||||
+31
-295
@@ -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,
|
||||
)
|
||||
@@ -1265,107 +1259,6 @@ def cleanup_document_cache(max_age_hours: int = 24) -> int:
|
||||
return removed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unified media caching
|
||||
#
|
||||
# One entry point for "I have raw attachment bytes from a platform — cache them
|
||||
# and tell me what I got." Classifies by extension/MIME against the shared
|
||||
# registries above, routes to the right cache_*_from_bytes helper, and returns
|
||||
# a small result the caller can store and/or describe in a transcript. Used by
|
||||
# both the addressed-message path and the observed-group-context path, on any
|
||||
# platform — not Telegram-specific.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class CachedMedia:
|
||||
"""Result of caching one attachment's bytes."""
|
||||
|
||||
path: str # absolute cache path, agent-visible (sandbox-translated)
|
||||
media_type: str # MIME type recorded on the MessageEvent
|
||||
kind: str # "image" | "video" | "audio" | "document"
|
||||
display_name: str # human-readable name for transcript notes
|
||||
|
||||
def context_note(self) -> str:
|
||||
"""One-line transcript annotation pointing the agent at the file."""
|
||||
return f"[{self.kind} '{self.display_name}' saved at: {self.path}]"
|
||||
|
||||
|
||||
def _resolve_media_ext(filename: str, mime_type: str) -> str:
|
||||
"""Best-effort file extension from filename, then MIME fallback."""
|
||||
if filename:
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext:
|
||||
return ext
|
||||
mime = (mime_type or "").lower()
|
||||
if not mime:
|
||||
return ""
|
||||
for table in (
|
||||
SUPPORTED_IMAGE_DOCUMENT_TYPES,
|
||||
SUPPORTED_VIDEO_TYPES,
|
||||
SUPPORTED_DOCUMENT_TYPES,
|
||||
):
|
||||
for ext, m in table.items():
|
||||
if m == mime:
|
||||
return ext
|
||||
return ""
|
||||
|
||||
|
||||
def cache_media_bytes(
|
||||
data: bytes,
|
||||
*,
|
||||
filename: str = "",
|
||||
mime_type: str = "",
|
||||
default_kind: Optional[str] = None,
|
||||
) -> Optional[CachedMedia]:
|
||||
"""Classify and cache raw attachment bytes; return a CachedMedia or None.
|
||||
|
||||
``default_kind`` ("image"/"video"/"audio"/"document") biases classification
|
||||
when the extension/MIME are ambiguous — e.g. a Telegram native photo whose
|
||||
file has no usable name. Unsupported document types return None so the
|
||||
caller can record an "unsupported" note. Images that fail validation
|
||||
(``cache_image_from_bytes`` raises ValueError) also return None.
|
||||
"""
|
||||
from tools.credential_files import to_agent_visible_cache_path
|
||||
|
||||
ext = _resolve_media_ext(filename, mime_type)
|
||||
mime = (mime_type or "").lower()
|
||||
display = re.sub(r"[^\w.\- ]", "_", filename) if filename else (ext.lstrip(".") or "file")
|
||||
|
||||
is_image = (
|
||||
mime.startswith("image/")
|
||||
or ext in SUPPORTED_IMAGE_DOCUMENT_TYPES
|
||||
or default_kind == "image"
|
||||
)
|
||||
is_video = mime.startswith("video/") or ext in SUPPORTED_VIDEO_TYPES or default_kind == "video"
|
||||
is_audio = mime.startswith("audio/") or default_kind == "audio"
|
||||
|
||||
if is_image:
|
||||
img_ext = ext if ext in SUPPORTED_IMAGE_DOCUMENT_TYPES else ".jpg"
|
||||
try:
|
||||
path = cache_image_from_bytes(data, ext=img_ext)
|
||||
except ValueError:
|
||||
return None
|
||||
out_mime = mime if mime.startswith("image/") else SUPPORTED_IMAGE_DOCUMENT_TYPES.get(img_ext, "image/jpeg")
|
||||
return CachedMedia(to_agent_visible_cache_path(path), out_mime, "image", display)
|
||||
|
||||
if is_video:
|
||||
vid_ext = ext if ext in SUPPORTED_VIDEO_TYPES else ".mp4"
|
||||
path = cache_video_from_bytes(data, ext=vid_ext)
|
||||
return CachedMedia(to_agent_visible_cache_path(path), SUPPORTED_VIDEO_TYPES.get(vid_ext, "video/mp4"), "video", display)
|
||||
|
||||
if is_audio:
|
||||
aud_ext = ext if ext in {".ogg", ".mp3", ".wav", ".m4a", ".opus", ".flac"} else ".ogg"
|
||||
path = cache_audio_from_bytes(data, ext=aud_ext)
|
||||
out_mime = mime if mime.startswith("audio/") else f"audio/{aud_ext.lstrip('.')}"
|
||||
return CachedMedia(to_agent_visible_cache_path(path), out_mime, "audio", display)
|
||||
|
||||
if ext not in SUPPORTED_DOCUMENT_TYPES:
|
||||
return None
|
||||
|
||||
path = cache_document_from_bytes(data, filename or f"document{ext}")
|
||||
return CachedMedia(to_agent_visible_cache_path(path), SUPPORTED_DOCUMENT_TYPES[ext], "document", display or f"document{ext}")
|
||||
|
||||
|
||||
class MessageType(Enum):
|
||||
"""Types of incoming messages."""
|
||||
TEXT = "text"
|
||||
@@ -1745,22 +1638,6 @@ def resolve_channel_skills(
|
||||
return None
|
||||
|
||||
|
||||
def _strip_media_directives(text: str) -> str:
|
||||
"""Strip internal delivery directives ([[audio_as_voice]], [[as_document]],
|
||||
MEDIA:<path>) so they never render as visible text.
|
||||
|
||||
Backstop only: run ``extract_media`` first. MEDIA cleanup uses the shared
|
||||
``MEDIA_TAG_CLEANUP_RE`` (only tags whose path has a known deliverable
|
||||
extension are removed; an unknown-extension tag is intentionally left so the
|
||||
bare-path detector downstream can still pick it up, per #34517). [[...]] is
|
||||
exact.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
text = text.replace("[[audio_as_voice]]", "").replace("[[as_document]]", "")
|
||||
return MEDIA_TAG_CLEANUP_RE.sub("", text)
|
||||
|
||||
|
||||
class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
Base class for platform adapters.
|
||||
@@ -1851,8 +1728,8 @@ class BasePlatformAdapter(ABC):
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""Whether this adapter gates inbound access before dispatch.
|
||||
|
||||
Some adapters (WeCom, Weixin, Yuanbao, QQBot, WhatsApp) implement a
|
||||
documented config-driven access surface — ``dm_policy`` / ``group_policy`` /
|
||||
Some adapters (WeCom, Weixin, Yuanbao, QQBot) implement a documented
|
||||
config-driven access surface — ``dm_policy`` / ``group_policy`` /
|
||||
``allow_from`` / ``group_allow_from`` in ``PlatformConfig.extra`` — and
|
||||
enforce it at intake: a message is dropped inside the adapter and never
|
||||
reaches the gateway unless it already passed that policy.
|
||||
@@ -2696,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]:
|
||||
"""
|
||||
@@ -2820,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()
|
||||
@@ -2841,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
|
||||
|
||||
@@ -2892,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,
|
||||
)
|
||||
|
||||
@@ -3961,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
|
||||
@@ -4001,52 +3772,31 @@ class BasePlatformAdapter(ABC):
|
||||
# where Telegram's sendPhoto recompression destroys legibility.
|
||||
force_document_attachments = "[[as_document]]" in response
|
||||
|
||||
# Pre-extract snapshot for the #29346 recovery/invariant below.
|
||||
_response_pre_extract = response
|
||||
|
||||
# Extract MEDIA:<path> tags (from TTS tool) before other processing
|
||||
media_files, response = self.extract_media(response)
|
||||
media_files = self.filter_media_delivery_paths(media_files)
|
||||
|
||||
# Extract image URLs and send them as native platform attachments
|
||||
images, text_content = self.extract_images(response)
|
||||
# Strip any remaining internal directives from message body (fixes #1561).
|
||||
# _strip_media_directives shares MEDIA_TAG_CLEANUP_RE, so a MEDIA: tag
|
||||
# with an unknown extension is intentionally left in the body for
|
||||
# extract_local_files below to pick up rather than silently dropped (#34517).
|
||||
text_content = _strip_media_directives(text_content).strip()
|
||||
# Strip any remaining internal directives from message body (fixes #1561)
|
||||
text_content = text_content.replace("[[audio_as_voice]]", "").strip()
|
||||
text_content = text_content.replace("[[as_document]]", "").strip()
|
||||
# Strip only MEDIA: tags whose path has a deliverable extension
|
||||
# (shared MEDIA_TAG_CLEANUP_RE). A MEDIA: tag with an unknown
|
||||
# extension is intentionally left in the body so extract_local_files
|
||||
# below can still pick up the bare path — otherwise the file would
|
||||
# be silently dropped (issue #34517).
|
||||
text_content = MEDIA_TAG_CLEANUP_RE.sub("", text_content).strip()
|
||||
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))
|
||||
|
||||
# A2 (#29346): extraction can reduce a non-empty response to
|
||||
# empty text with no attachment, and the `if text_content` guard
|
||||
# below then drops it silently. Recover on every platform (#33842
|
||||
# was Discord-only); the guard avoids duplicating an attachment.
|
||||
if not (text_content or images or local_files or media_files):
|
||||
# Recover from the post-extract_media `response`, not the raw
|
||||
# snapshot: extract_media already stripped MEDIA (incl. spaced
|
||||
# paths) with its full grammar, so no fragment can leak.
|
||||
_recovered = _strip_media_directives(response).strip()
|
||||
if _recovered:
|
||||
logger.warning(
|
||||
"[%s] response_delivery_recovered: extract pipeline "
|
||||
"reduced a non-empty response (%d chars) to empty with "
|
||||
"no attachment; delivering recovered original to %s",
|
||||
self.name, len(_response_pre_extract), event.source.chat_id,
|
||||
)
|
||||
text_content = _recovered
|
||||
|
||||
# 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
|
||||
# an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is
|
||||
@@ -4244,20 +3994,6 @@ class BasePlatformAdapter(ABC):
|
||||
except Exception as file_err:
|
||||
logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
|
||||
|
||||
# A3 (#29346): if a non-empty response produced nothing
|
||||
# deliverable, fail loudly rather than dropping it in silence.
|
||||
_anything_delivered = (
|
||||
delivery_attempted or _tts_caption_delivered
|
||||
or images or local_files or media_files
|
||||
)
|
||||
if not _anything_delivered and _response_pre_extract.strip():
|
||||
logger.error(
|
||||
"[%s] response_delivery_dropped: non-empty response "
|
||||
"(%d chars) produced no delivered message or attachment "
|
||||
"for %s (empty after extract, recovery yielded nothing).",
|
||||
self.name, len(_response_pre_extract), event.source.chat_id,
|
||||
)
|
||||
|
||||
# Determine overall success for the processing hook
|
||||
processing_ok = delivery_succeeded if delivery_attempted else not bool(response)
|
||||
await self._run_processing_hook(
|
||||
|
||||
@@ -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
|
||||
@@ -44,15 +43,6 @@ DEFAULT_WEBHOOK_PORT = 8645
|
||||
DEFAULT_WEBHOOK_PATH = "/bluebubbles-webhook"
|
||||
MAX_TEXT_LENGTH = 4000
|
||||
|
||||
# BlueBubbles/iMessage does not expose a stable bot mention identity like
|
||||
# Slack (<@U...>), Telegram (@botname), or Matrix (MXID). When users opt into
|
||||
# group mention gating without custom aliases, use conservative Hermes wake
|
||||
# words so `require_mention: true` is a one-line enablement path.
|
||||
DEFAULT_MENTION_PATTERNS = [
|
||||
r"(?<![\w@])@?hermes\s+agent\b[,:\-]?",
|
||||
r"(?<![\w@])@?hermes\b[,:\-]?",
|
||||
]
|
||||
|
||||
# Tapback reaction codes (BlueBubbles associatedMessageType values)
|
||||
_TAPBACK_ADDED = {
|
||||
2000: "love", 2001: "like", 2002: "dislike",
|
||||
@@ -70,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."""
|
||||
@@ -136,20 +124,11 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
if not str(self.webhook_path).startswith("/"):
|
||||
self.webhook_path = f"/{self.webhook_path}"
|
||||
self.send_read_receipts = bool(extra.get("send_read_receipts", True))
|
||||
_require_mention = extra.get("require_mention")
|
||||
if _require_mention is None:
|
||||
_require_mention = os.getenv("BLUEBUBBLES_REQUIRE_MENTION")
|
||||
self.require_mention = str(_require_mention).strip().lower() in {"true", "1", "yes", "on"}
|
||||
self._mention_patterns = self._compile_mention_patterns(
|
||||
extra["mention_patterns"]
|
||||
if "mention_patterns" in extra
|
||||
else os.getenv("BLUEBUBBLES_MENTION_PATTERNS")
|
||||
)
|
||||
self.client: Optional[httpx.AsyncClient] = None
|
||||
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
|
||||
@@ -159,62 +138,6 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
sep = "&" if "?" in path else "?"
|
||||
return f"{self.server_url}{path}{sep}password={quote(self.password, safe='')}"
|
||||
|
||||
@staticmethod
|
||||
def _compile_mention_patterns(raw: Any) -> List[re.Pattern]:
|
||||
"""Compile group-mention wake words from config/env.
|
||||
|
||||
``raw`` is a list (from config or env JSON), a string (raw env var:
|
||||
JSON list, or comma/newline-separated), or None (use Hermes defaults).
|
||||
"""
|
||||
if raw is None:
|
||||
patterns = list(DEFAULT_MENTION_PATTERNS)
|
||||
elif isinstance(raw, str):
|
||||
text = raw.strip()
|
||||
try:
|
||||
loaded = json.loads(text) if text else []
|
||||
except Exception:
|
||||
loaded = None
|
||||
patterns = loaded if isinstance(loaded, list) else [
|
||||
part.strip()
|
||||
for line in text.splitlines()
|
||||
for part in line.split(",")
|
||||
]
|
||||
elif isinstance(raw, list):
|
||||
patterns = raw
|
||||
else:
|
||||
patterns = [raw]
|
||||
|
||||
compiled: List["re.Pattern"] = []
|
||||
for pattern in patterns:
|
||||
text = str(pattern).strip()
|
||||
if not text:
|
||||
continue
|
||||
try:
|
||||
compiled.append(re.compile(text, re.IGNORECASE))
|
||||
except re.error as exc:
|
||||
logger.warning("[bluebubbles] Invalid mention pattern %r: %s", text, exc)
|
||||
return compiled
|
||||
|
||||
def _message_matches_mention_patterns(self, text: str) -> bool:
|
||||
if not text or not self._mention_patterns:
|
||||
return False
|
||||
return any(pattern.search(text) for pattern in self._mention_patterns)
|
||||
|
||||
def _clean_mention_text(self, text: str) -> str:
|
||||
"""Strip a leading BlueBubbles wake word before dispatch.
|
||||
|
||||
Custom mention patterns are regular expressions, so stripping only a
|
||||
leading match avoids deleting ordinary words later in the prompt.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
for pattern in self._mention_patterns:
|
||||
match = pattern.match(text.lstrip())
|
||||
if match:
|
||||
cleaned = text.lstrip()[match.end():].lstrip(" ,:-")
|
||||
return cleaned or text
|
||||
return text
|
||||
|
||||
async def _api_get(self, path: str) -> Dict[str, Any]:
|
||||
assert self.client is not None
|
||||
res = await self.client.get(self._api_url(path))
|
||||
@@ -442,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(
|
||||
@@ -455,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
|
||||
@@ -995,13 +913,6 @@ class BlueBubblesAdapter(BasePlatformAdapter):
|
||||
|
||||
session_chat_id = chat_guid or chat_identifier
|
||||
is_group = bool(record.get("isGroup")) or (";+;" in (chat_guid or ""))
|
||||
if is_group and self.require_mention:
|
||||
if not self._message_matches_mention_patterns(text):
|
||||
logger.debug(
|
||||
"[bluebubbles] ignoring group message (require_mention=true, no mention pattern matched)"
|
||||
)
|
||||
return web.Response(text="ok")
|
||||
text = self._clean_mention_text(text)
|
||||
source = self.build_source(
|
||||
chat_id=session_chat_id,
|
||||
chat_name=chat_identifier or sender,
|
||||
|
||||
@@ -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)
|
||||
|
||||
+58
-249
@@ -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)
|
||||
@@ -4918,109 +4804,13 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
channel_prompt=channel_prompt,
|
||||
)
|
||||
|
||||
def _media_message_type(self, msg: Message) -> MessageType:
|
||||
"""Classify a Telegram media message into a MessageType."""
|
||||
if msg.sticker:
|
||||
return MessageType.STICKER
|
||||
if msg.photo:
|
||||
return MessageType.PHOTO
|
||||
if msg.video:
|
||||
return MessageType.VIDEO
|
||||
if msg.audio:
|
||||
return MessageType.AUDIO
|
||||
if msg.voice:
|
||||
return MessageType.VOICE
|
||||
return MessageType.DOCUMENT
|
||||
|
||||
async def _cache_observed_media(self, msg: Message, event: MessageEvent) -> None:
|
||||
"""Cache an unmentioned group attachment and annotate the observed text.
|
||||
|
||||
Passive group traffic, so downloads are bounded by the same
|
||||
``_max_doc_bytes`` limit as the addressed document path. Oversized or
|
||||
unsupported attachments are noted in the transcript without downloading.
|
||||
"""
|
||||
from gateway.platforms.base import cache_media_bytes
|
||||
|
||||
source, filename, mime, kind = self._observed_media_source(msg)
|
||||
if source is None:
|
||||
return
|
||||
|
||||
max_bytes = getattr(self, "_max_doc_bytes", 20 * 1024 * 1024)
|
||||
file_size = getattr(source, "file_size", None)
|
||||
try:
|
||||
size = int(file_size or 0)
|
||||
except (TypeError, ValueError):
|
||||
size = 0
|
||||
if not (0 < size <= max_bytes):
|
||||
limit_mb = max_bytes // (1024 * 1024)
|
||||
event.text = self._append_observed_note(
|
||||
event.text,
|
||||
f"[Observed Telegram attachment too large or unverifiable. Maximum: {limit_mb} MB.]",
|
||||
)
|
||||
logger.info("[Telegram] Observed group attachment skipped (size=%s)", file_size)
|
||||
return
|
||||
|
||||
try:
|
||||
file_obj = await source.get_file()
|
||||
data = bytes(await file_obj.download_as_bytearray())
|
||||
if not filename:
|
||||
filename = os.path.basename(getattr(file_obj, "file_path", "") or "")
|
||||
cached = cache_media_bytes(data, filename=filename, mime_type=mime, default_kind=kind)
|
||||
except Exception as exc:
|
||||
logger.warning("[Telegram] Failed to cache observed group media: %s", exc, exc_info=True)
|
||||
return
|
||||
|
||||
if cached is None:
|
||||
event.text = self._append_observed_note(
|
||||
event.text, "[Observed Telegram attachment: unsupported type, not cached.]"
|
||||
)
|
||||
return
|
||||
|
||||
event.media_urls = [cached.path]
|
||||
event.media_types = [cached.media_type]
|
||||
if cached.kind == "image":
|
||||
event.message_type = MessageType.PHOTO
|
||||
elif cached.kind == "video":
|
||||
event.message_type = MessageType.VIDEO
|
||||
event.text = self._append_observed_note(event.text, cached.context_note())
|
||||
logger.info("[Telegram] Cached observed group %s at %s", cached.kind, cached.path)
|
||||
|
||||
def _observed_media_source(self, msg: Message):
|
||||
"""Return (telegram_file_source, filename, mime, default_kind) or Nones."""
|
||||
if msg.photo:
|
||||
return msg.photo[-1], "", "", "image"
|
||||
if msg.video:
|
||||
return msg.video, "", "video/mp4", "video"
|
||||
if msg.voice:
|
||||
return msg.voice, "voice.ogg", "audio/ogg", "audio"
|
||||
if msg.audio:
|
||||
return msg.audio, getattr(msg.audio, "file_name", "") or "", "", "audio"
|
||||
if msg.document:
|
||||
doc = msg.document
|
||||
return doc, doc.file_name or "", (doc.mime_type or "").lower(), None
|
||||
return None, "", "", None
|
||||
|
||||
@staticmethod
|
||||
def _append_observed_note(existing: Optional[str], note: str) -> str:
|
||||
if not note:
|
||||
return existing or ""
|
||||
if not existing:
|
||||
return note
|
||||
return f"{existing}\n\n{note}"
|
||||
|
||||
def _observe_unmentioned_group_message(
|
||||
self,
|
||||
message: Message,
|
||||
msg_type: MessageType,
|
||||
update_id: Optional[int] = None,
|
||||
event: Optional[MessageEvent] = None,
|
||||
) -> None:
|
||||
def _observe_unmentioned_group_message(self, message: Message, msg_type: MessageType, update_id: Optional[int] = None) -> None:
|
||||
"""Append skipped group chatter to the target session without dispatching."""
|
||||
store = getattr(self, "_session_store", None)
|
||||
if not store:
|
||||
return
|
||||
try:
|
||||
event = event or self._build_message_event(message, msg_type, update_id=update_id)
|
||||
event = self._build_message_event(message, msg_type, update_id=update_id)
|
||||
shared_source = self._telegram_group_observe_shared_source(event.source)
|
||||
session_entry = store.get_or_create_session(shared_source)
|
||||
entry = {
|
||||
@@ -5381,20 +5171,39 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
if not self._should_process_message(update.message):
|
||||
if self._should_observe_unmentioned_group_message(update.message):
|
||||
_m = update.message
|
||||
_observe_type = self._media_message_type(_m)
|
||||
_event = self._build_message_event(_m, _observe_type, update_id=update.update_id)
|
||||
if _m.caption:
|
||||
_event.text = self._clean_bot_trigger_text(_m.caption)
|
||||
await self._cache_observed_media(_m, _event)
|
||||
self._observe_unmentioned_group_message(
|
||||
_m, _event.message_type, update_id=update.update_id, event=_event
|
||||
)
|
||||
if _m.sticker:
|
||||
_observe_type = MessageType.STICKER
|
||||
elif _m.photo:
|
||||
_observe_type = MessageType.PHOTO
|
||||
elif _m.video:
|
||||
_observe_type = MessageType.VIDEO
|
||||
elif _m.audio:
|
||||
_observe_type = MessageType.AUDIO
|
||||
elif _m.voice:
|
||||
_observe_type = MessageType.VOICE
|
||||
else:
|
||||
_observe_type = MessageType.DOCUMENT
|
||||
self._observe_unmentioned_group_message(_m, _observe_type, update_id=update.update_id)
|
||||
return
|
||||
|
||||
msg = update.message
|
||||
|
||||
msg_type = self._media_message_type(msg)
|
||||
|
||||
|
||||
# Determine media type
|
||||
if msg.sticker:
|
||||
msg_type = MessageType.STICKER
|
||||
elif msg.photo:
|
||||
msg_type = MessageType.PHOTO
|
||||
elif msg.video:
|
||||
msg_type = MessageType.VIDEO
|
||||
elif msg.audio:
|
||||
msg_type = MessageType.AUDIO
|
||||
elif msg.voice:
|
||||
msg_type = MessageType.VOICE
|
||||
elif msg.document:
|
||||
msg_type = MessageType.DOCUMENT
|
||||
else:
|
||||
msg_type = MessageType.DOCUMENT
|
||||
|
||||
event = self._build_message_event(msg, msg_type, update_id=update.update_id)
|
||||
|
||||
# Add caption as text
|
||||
|
||||
@@ -364,15 +364,6 @@ class WebhookAdapter(BasePlatformAdapter):
|
||||
{"error": f"Unknown route: {route_name}"}, status=404
|
||||
)
|
||||
|
||||
# Disabled routes are kept in the subscriptions file (so the dashboard
|
||||
# can re-enable them) but reject incoming events. Default-enabled:
|
||||
# only an explicit ``enabled: false`` turns a route off, matching the
|
||||
# mcp_servers ``enabled`` semantics.
|
||||
if route_config.get("enabled", True) is False:
|
||||
return web.json_response(
|
||||
{"error": f"Route disabled: {route_name}"}, status=403
|
||||
)
|
||||
|
||||
# ── Auth-before-body ─────────────────────────────────────
|
||||
# Check Content-Length before reading the full payload.
|
||||
content_length = request.content_length or 0
|
||||
|
||||
@@ -161,15 +161,7 @@ class WeComAdapter(BasePlatformAdapter):
|
||||
).strip() or DEFAULT_WS_URL
|
||||
|
||||
self._dm_policy = str(extra.get("dm_policy") or os.getenv("WECOM_DM_POLICY", "open")).strip().lower()
|
||||
# dm_policy already honors WECOM_DM_POLICY, so the allowlist must honor
|
||||
# WECOM_ALLOWED_USERS too. Without the env fallback an env-only setup
|
||||
# (dm_policy=allowlist via env, no config extra) runs with an empty
|
||||
# allowlist and drops every authorized DM at intake.
|
||||
self._allow_from = _coerce_list(
|
||||
extra.get("allow_from")
|
||||
or extra.get("allowFrom")
|
||||
or os.getenv("WECOM_ALLOWED_USERS", "")
|
||||
)
|
||||
self._allow_from = _coerce_list(extra.get("allow_from") or extra.get("allowFrom"))
|
||||
|
||||
self._group_policy = str(extra.get("group_policy") or os.getenv("WECOM_GROUP_POLICY", "open")).strip().lower()
|
||||
self._group_allow_from = _coerce_list(extra.get("group_allow_from") or extra.get("groupAllowFrom"))
|
||||
|
||||
+18
-135
@@ -378,16 +378,12 @@ async def _api_post(
|
||||
) -> Dict[str, Any]:
|
||||
body = _json_dumps({**payload, "base_info": _base_info()})
|
||||
url = f"{base_url.rstrip('/')}/{endpoint}"
|
||||
# Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid
|
||||
# "Timeout context manager should be used inside a task" errors when
|
||||
# invoked via asyncio.run_coroutine_threadsafe() from cron jobs.
|
||||
async def _do() -> Dict[str, Any]:
|
||||
async with session.post(url, data=body, headers=_headers(token, body)) as response:
|
||||
raw = await response.text()
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"iLink POST {endpoint} HTTP {response.status}: {raw[:200]}")
|
||||
return json.loads(raw)
|
||||
return await asyncio.wait_for(_do(), timeout=timeout_ms / 1000)
|
||||
timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000)
|
||||
async with session.post(url, data=body, headers=_headers(token, body), timeout=timeout) as response:
|
||||
raw = await response.text()
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"iLink POST {endpoint} HTTP {response.status}: {raw[:200]}")
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
async def _api_get(
|
||||
@@ -402,16 +398,12 @@ async def _api_get(
|
||||
"iLink-App-Id": ILINK_APP_ID,
|
||||
"iLink-App-ClientVersion": str(ILINK_APP_CLIENT_VERSION),
|
||||
}
|
||||
# Use asyncio.wait_for() instead of aiohttp ClientTimeout to avoid
|
||||
# "Timeout context manager should be used inside a task" errors when
|
||||
# invoked via asyncio.run_coroutine_threadsafe() from cron jobs.
|
||||
async def _do() -> Dict[str, Any]:
|
||||
async with session.get(url, headers=headers) as response:
|
||||
raw = await response.text()
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"iLink GET {endpoint} HTTP {response.status}: {raw[:200]}")
|
||||
return json.loads(raw)
|
||||
return await asyncio.wait_for(_do(), timeout=timeout_ms / 1000)
|
||||
timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000)
|
||||
async with session.get(url, headers=headers, timeout=timeout) as response:
|
||||
raw = await response.text()
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"iLink GET {endpoint} HTTP {response.status}: {raw[:200]}")
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
async def _get_updates(
|
||||
@@ -1188,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:
|
||||
@@ -1291,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:
|
||||
@@ -1444,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":
|
||||
@@ -1456,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")
|
||||
@@ -379,11 +342,6 @@ class WhatsAppAdapter(BasePlatformAdapter):
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def enforces_own_access_policy(self) -> bool:
|
||||
"""WhatsApp gates DM/group access at intake via dm_policy/group_policy."""
|
||||
return True
|
||||
|
||||
def _is_dm_allowed(self, sender_id: str) -> bool:
|
||||
"""Check whether a DM from the given sender should be processed."""
|
||||
if self._dm_policy == "disabled":
|
||||
@@ -1181,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:
|
||||
@@ -1196,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:
|
||||
|
||||
+140
-673
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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user