Compare commits
7 Commits
bb/gui
...
feat/iron-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec108c625e | ||
|
|
906b1da57f | ||
|
|
fa4e87b253 | ||
|
|
4833acf046 | ||
|
|
128a6837b7 | ||
|
|
7a74492134 | ||
|
|
69ffb9cfd4 |
@@ -8,10 +8,6 @@ node_modules
|
||||
**/node_modules
|
||||
.venv
|
||||
**/.venv
|
||||
.notebooklm-cli-venv/
|
||||
.notebooklm-playwright/
|
||||
.pip-cache/
|
||||
.uv-cache/
|
||||
|
||||
# Built artifacts that are regenerated inside the image. Excluded so local
|
||||
# rebuilds on the developer's machine don't invalidate the npm-install layer
|
||||
@@ -29,8 +25,6 @@ ui-tui/packages/hermes-ink/dist/
|
||||
|
||||
# Runtime data (bind-mounted at /opt/data; must not leak into build context)
|
||||
data/
|
||||
.hermes-docker/
|
||||
.notebooklm-home/
|
||||
|
||||
# Compose/profile runtime state (bind-mounted; avoid ownership/secret issues)
|
||||
hermes-config/
|
||||
|
||||
@@ -417,9 +417,9 @@ IMAGE_TOOLS_DEBUG=false
|
||||
# Default STT provider is "local" (faster-whisper) — runs on your machine, no API key needed.
|
||||
# Install with: pip install faster-whisper
|
||||
# Model downloads automatically on first use (~150 MB for "base").
|
||||
# To use cloud providers instead, set GROQ_API_KEY, VOICE_TOOLS_OPENAI_KEY, or ELEVENLABS_API_KEY above.
|
||||
# Provider priority: local > groq > openai > mistral > xai > elevenlabs
|
||||
# Configure in config.yaml: stt.provider: local | groq | openai | mistral | xai | elevenlabs
|
||||
# To use cloud providers instead, set GROQ_API_KEY or VOICE_TOOLS_OPENAI_KEY above.
|
||||
# Provider priority: local > groq > openai
|
||||
# Configure in config.yaml: stt.provider: local | groq | openai
|
||||
|
||||
# =============================================================================
|
||||
# STT ADVANCED OVERRIDES (optional)
|
||||
@@ -427,12 +427,10 @@ IMAGE_TOOLS_DEBUG=false
|
||||
# Override default STT models per provider (normally set via stt.model in config.yaml)
|
||||
# STT_GROQ_MODEL=whisper-large-v3-turbo
|
||||
# STT_OPENAI_MODEL=whisper-1
|
||||
# STT_ELEVENLABS_MODEL=scribe_v2
|
||||
|
||||
# Override STT provider endpoints (for proxies or self-hosted instances)
|
||||
# GROQ_BASE_URL=https://api.groq.com/openai/v1
|
||||
# STT_OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
# ELEVENLABS_STT_BASE_URL=https://api.elevenlabs.io/v1
|
||||
|
||||
# =============================================================================
|
||||
# MICROSOFT TEAMS INTEGRATION
|
||||
|
||||
19
.github/workflows/deploy-site.yml
vendored
@@ -50,23 +50,20 @@ jobs:
|
||||
- name: Install PyYAML for skill extraction
|
||||
run: pip install pyyaml==6.0.2 httpx==0.28.1
|
||||
|
||||
- name: Build skills index (unified multi-source catalog)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Always rebuild — the file isn't committed (gitignored), so a
|
||||
# fresh checkout starts without it and we want the freshest crawl
|
||||
# in every deploy. Failure is non-fatal: extract-skills.py will
|
||||
# fall back to the legacy snapshot cache and the Skills Hub page
|
||||
# still renders, just without the latest community catalog.
|
||||
python3 scripts/build_skills_index.py || echo "Skills index build failed (non-fatal)"
|
||||
|
||||
- name: Extract skill metadata for dashboard
|
||||
run: python3 website/scripts/extract-skills.py
|
||||
|
||||
- name: Regenerate per-skill docs pages + catalogs
|
||||
run: python3 website/scripts/generate-skill-docs.py
|
||||
|
||||
- name: Build skills index (if not already present)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [ ! -f website/static/api/skills-index.json ]; then
|
||||
python3 scripts/build_skills_index.py || echo "Skills index build failed (non-fatal)"
|
||||
fi
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: website
|
||||
|
||||
342
.github/workflows/desktop-release.yml
vendored
@@ -1,342 +0,0 @@
|
||||
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
|
||||
250
.github/workflows/docker-publish.yml
vendored
@@ -28,7 +28,8 @@ permissions:
|
||||
contents: read
|
||||
|
||||
# Concurrency: push/release runs are NEVER cancelled so every merge gets
|
||||
# its own image. PR runs reuse a PR-scoped group with
|
||||
# its own :main or release-tagged image. :latest is guarded separately
|
||||
# by the move-latest job. PR runs reuse a PR-scoped group with
|
||||
# cancel-in-progress: true so rapid pushes to the same PR collapse to the
|
||||
# latest commit.
|
||||
concurrency:
|
||||
@@ -139,6 +140,12 @@ jobs:
|
||||
# Push amd64 by digest only (no tag). The merge job assembles the
|
||||
# tagged manifest list. `push-by-digest=true` is docker's recommended
|
||||
# pattern for multi-runner multi-platform builds.
|
||||
#
|
||||
# We apply the OCI revision label here (and again on arm64) because
|
||||
# the move-latest job reads it off the linux/amd64 sub-manifest
|
||||
# config of the floating tag to decide whether it's safe to advance.
|
||||
# The label must be on each per-arch image — manifest lists themselves
|
||||
# don't carry image config labels.
|
||||
- name: Push amd64 by digest
|
||||
id: push
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
@@ -251,17 +258,30 @@ jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stitch both per-arch digests into a single tagged multi-arch manifest.
|
||||
# This is a registry-side operation — no building, no layer re-push —
|
||||
# so it runs in ~30 seconds.
|
||||
# so it runs in ~30 seconds. On main pushes it produces :main; on
|
||||
# releases it produces :<release_tag_name>.
|
||||
#
|
||||
# On main pushes: tags both :main and :latest.
|
||||
# On releases: tags :<release_tag_name>.
|
||||
# For main pushes the ancestor check runs BEFORE the manifest push so
|
||||
# we never overwrite :main with an older commit. The top-level
|
||||
# concurrency group (`docker-${{ github.ref }}` with
|
||||
# `cancel-in-progress: false`) already serialises runs per ref; the
|
||||
# ancestor check is defense-in-depth.
|
||||
# ---------------------------------------------------------------------------
|
||||
merge:
|
||||
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-amd64, build-arm64]
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
pushed_release_tag: ${{ steps.mark_release_pushed.outputs.pushed }}
|
||||
release_tag: ${{ steps.tag.outputs.tag }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1000
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
@@ -278,7 +298,86 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Read the git revision label off the current :main manifest, then
|
||||
# use `git merge-base --is-ancestor` to check whether our commit is
|
||||
# a descendant of it. If :main doesn't exist yet, or its label is
|
||||
# missing, we treat that as "safe to publish". If another run
|
||||
# already advanced :main past us (or diverged), we skip and leave
|
||||
# it alone.
|
||||
- name: Decide whether to move :main
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
id: main_check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image=nousresearch/hermes-agent
|
||||
|
||||
image_json=$(
|
||||
docker buildx imagetools inspect "${image}:main" \
|
||||
--format '{{ json (index .Image "linux/amd64") }}' \
|
||||
2>/dev/null || true
|
||||
)
|
||||
|
||||
if [ -z "${image_json}" ]; then
|
||||
echo "No existing :main (or inspect failed) — safe to publish."
|
||||
echo "push_main=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
current_sha=$(
|
||||
printf '%s' "${image_json}" \
|
||||
| jq -r '.config.Labels."org.opencontainers.image.revision" // ""'
|
||||
)
|
||||
|
||||
if [ -z "${current_sha}" ]; then
|
||||
echo "Registry :main has no revision label — safe to publish."
|
||||
echo "push_main=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Registry :main is at ${current_sha}"
|
||||
echo "This run is at ${GITHUB_SHA}"
|
||||
|
||||
if [ "${current_sha}" = "${GITHUB_SHA}" ]; then
|
||||
echo ":main already points at our SHA — nothing to do."
|
||||
echo "push_main=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then
|
||||
git fetch --no-tags --prune origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main" \
|
||||
|| true
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then
|
||||
echo "Registry :main points at an unknown commit (${current_sha}); refusing to overwrite."
|
||||
echo "push_main=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then
|
||||
echo "Our commit is a descendant of :main — safe to advance."
|
||||
echo "push_main=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Another run advanced :main past us (or diverged) — leaving it alone."
|
||||
echo "push_main=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Compute the tag for this run. Main pushes tag directly as :main
|
||||
# (no per-commit SHA tags); releases use the release tag name.
|
||||
- name: Compute tag
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
echo "tag=${{ github.event.release.tag_name }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=main" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Gate the manifest push on the ancestor check for main pushes.
|
||||
# For releases there is no gate — the check doesn't even run.
|
||||
- name: Create manifest list and push
|
||||
if: github.event_name != 'push' || steps.main_check.outputs.push_main == 'true'
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -286,26 +385,137 @@ jobs:
|
||||
for digest_file in *; do
|
||||
args+=("${IMAGE_NAME}@sha256:${digest_file}")
|
||||
done
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
TAG="${{ github.event.release.tag_name }}"
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE_NAME}:${TAG}" \
|
||||
"${args[@]}"
|
||||
else
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE_NAME}:main" \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
"${args[@]}"
|
||||
fi
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE_NAME}:${TAG}" \
|
||||
"${args[@]}"
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
|
||||
- name: Inspect image
|
||||
if: github.event_name != 'push' || steps.main_check.outputs.push_main == 'true'
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:${{ github.event.release.tag_name }}"
|
||||
else
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:main"
|
||||
fi
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:${TAG}"
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
TAG: ${{ steps.tag.outputs.tag }}
|
||||
|
||||
# Signal to move-latest that the release tag is live.
|
||||
- name: Mark release tag pushed
|
||||
id: mark_release_pushed
|
||||
if: github.event_name == 'release'
|
||||
run: echo "pushed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Move :latest to point at the release tag the merge job pushed.
|
||||
#
|
||||
# :latest is the floating tag that tracks the most recent stable release.
|
||||
# Only `release: published` events advance it — never main pushes.
|
||||
#
|
||||
# We still run an ancestor check against the existing :latest so that a
|
||||
# backport release on an older branch (e.g. patching v1.1.5 after v1.2.3
|
||||
# is out) doesn't drag :latest backwards. The check is the same shape
|
||||
# as the ancestor check in the merge job for :main: read the OCI
|
||||
# revision label off the current :latest, look up that commit in git,
|
||||
# and only advance if our release commit is a strict descendant.
|
||||
# ---------------------------------------------------------------------------
|
||||
move-latest:
|
||||
if: |
|
||||
github.repository == 'NousResearch/hermes-agent'
|
||||
&& github.event_name == 'release'
|
||||
&& needs.merge.outputs.pushed_release_tag == 'true'
|
||||
needs: merge
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
concurrency:
|
||||
group: docker-move-latest
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1000
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Decide whether to move :latest
|
||||
id: latest_check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image=nousresearch/hermes-agent
|
||||
|
||||
image_json=$(
|
||||
docker buildx imagetools inspect "${image}:latest" \
|
||||
--format '{{ json (index .Image "linux/amd64") }}' \
|
||||
2>/dev/null || true
|
||||
)
|
||||
|
||||
if [ -z "${image_json}" ]; then
|
||||
echo "No existing :latest (or inspect failed) — safe to publish."
|
||||
echo "push_latest=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
current_sha=$(
|
||||
printf '%s' "${image_json}" \
|
||||
| jq -r '.config.Labels."org.opencontainers.image.revision" // ""'
|
||||
)
|
||||
|
||||
if [ -z "${current_sha}" ]; then
|
||||
echo "Registry :latest has no revision label — safe to publish."
|
||||
echo "push_latest=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Registry :latest is at ${current_sha}"
|
||||
echo "This release is at ${GITHUB_SHA}"
|
||||
|
||||
if [ "${current_sha}" = "${GITHUB_SHA}" ]; then
|
||||
echo ":latest already points at our SHA — nothing to do."
|
||||
echo "push_latest=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Make sure we have the :latest commit locally for merge-base.
|
||||
# Releases can be cut from any branch, so fetch broadly.
|
||||
if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then
|
||||
git fetch --no-tags --prune origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main" \
|
||||
|| true
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "${current_sha}^{commit}" 2>/dev/null; then
|
||||
echo "Registry :latest points at an unknown commit (${current_sha}); refusing to overwrite."
|
||||
echo "push_latest=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Our release SHA must be a descendant of the current :latest.
|
||||
# Backport releases on older branches won't satisfy this and will
|
||||
# be left alone — :latest stays on the newer release.
|
||||
if git merge-base --is-ancestor "${current_sha}" "${GITHUB_SHA}"; then
|
||||
echo "Our release commit is a descendant of :latest — safe to advance."
|
||||
echo "push_latest=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Existing :latest is newer than this release (likely a backport) — leaving it alone."
|
||||
echo "push_latest=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# Retag the already-pushed release manifest as :latest.
|
||||
- name: Move :latest to this release tag
|
||||
if: steps.latest_check.outputs.push_latest == 'true'
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.merge.outputs.release_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
image=nousresearch/hermes-agent
|
||||
docker buildx imagetools create \
|
||||
--tag "${image}:latest" \
|
||||
"${image}:${RELEASE_TAG}"
|
||||
|
||||
8
.github/workflows/nix-lockfile-fix.yml
vendored
@@ -6,8 +6,8 @@ on:
|
||||
paths:
|
||||
- 'ui-tui/package-lock.json'
|
||||
- 'ui-tui/package.json'
|
||||
- 'apps/dashboard/package-lock.json'
|
||||
- 'apps/dashboard/package.json'
|
||||
- 'web/package-lock.json'
|
||||
- 'web/package.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
@@ -28,7 +28,7 @@ concurrency:
|
||||
jobs:
|
||||
# ── Auto-fix on main ───────────────────────────────────────────────
|
||||
# Fires when a push to main touches package.json or package-lock.json
|
||||
# in ui-tui/ or apps/dashboard/. Runs fix-lockfiles and pushes the hash
|
||||
# in ui-tui/ or web/. Runs fix-lockfiles and pushes the hash
|
||||
# update commit directly to main so Nix builds never stay broken.
|
||||
#
|
||||
# Safety invariants:
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
# run recompute from the correct package-lock state.
|
||||
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
|
||||
'ui-tui/package-lock.json' 'ui-tui/package.json' \
|
||||
'apps/dashboard/package-lock.json' 'apps/dashboard/package.json' || true)"
|
||||
'web/package-lock.json' 'web/package.json' || true)"
|
||||
if [ -n "$pkg_changed" ]; then
|
||||
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
|
||||
exit 0
|
||||
|
||||
149
.github/workflows/skills-index-freshness.yml
vendored
@@ -1,149 +0,0 @@
|
||||
name: Skills Index Freshness Check
|
||||
|
||||
# Belt-and-suspenders for the twice-daily build_skills_index pipeline.
|
||||
# If the live /docs/api/skills-index.json ever goes more than 26 hours
|
||||
# stale OR the file disappears entirely OR a major source has collapsed,
|
||||
# this workflow opens a GitHub issue so we hear about it before users do.
|
||||
#
|
||||
# Triggered every 4 hours so we catch a stuck cron within one tick.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 */4 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
check-freshness:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Probe live index
|
||||
id: probe
|
||||
run: |
|
||||
set -e
|
||||
URL="https://hermes-agent.nousresearch.com/docs/api/skills-index.json"
|
||||
echo "Probing $URL"
|
||||
# -L follows redirects; -f fails on HTTP errors; -s suppresses progress
|
||||
if ! curl -fsSL -o /tmp/skills-index.json "$URL"; then
|
||||
echo "status=fetch-failed" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=Could not download $URL" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Validate + extract generated_at and per-source counts
|
||||
python3 <<'PY' >> "$GITHUB_OUTPUT"
|
||||
import json, sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
try:
|
||||
with open("/tmp/skills-index.json") as f:
|
||||
data = json.load(f)
|
||||
except Exception as e:
|
||||
print(f"status=parse-failed")
|
||||
print(f"detail=JSON decode error: {e}")
|
||||
sys.exit(0)
|
||||
|
||||
generated_at = data.get("generated_at", "")
|
||||
total = data.get("skill_count", 0)
|
||||
skills = data.get("skills", [])
|
||||
if not isinstance(skills, list):
|
||||
print("status=invalid-shape")
|
||||
print(f"detail=skills field is not a list (got {type(skills).__name__})")
|
||||
sys.exit(0)
|
||||
|
||||
# Per-source counts
|
||||
from collections import Counter
|
||||
by_src = Counter(s.get("source", "") for s in skills)
|
||||
|
||||
# Freshness
|
||||
age_hours = None
|
||||
try:
|
||||
ts = datetime.fromisoformat(generated_at.replace("Z", "+00:00"))
|
||||
age_hours = (datetime.now(timezone.utc) - ts).total_seconds() / 3600
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Floors — same as build_skills_index.py EXPECTED_FLOORS.
|
||||
floors = {
|
||||
"skills.sh": 100,
|
||||
"lobehub": 100,
|
||||
"clawhub": 50,
|
||||
"official": 50,
|
||||
"github": 30,
|
||||
"browse-sh": 50,
|
||||
}
|
||||
issues = []
|
||||
if age_hours is not None and age_hours > 26:
|
||||
issues.append(f"Index is {age_hours:.1f}h old (limit 26h)")
|
||||
for src, floor in floors.items():
|
||||
count = by_src.get(src, 0)
|
||||
if src == "skills.sh":
|
||||
count = by_src.get("skills.sh", 0) + by_src.get("skills-sh", 0)
|
||||
if count < floor:
|
||||
issues.append(f"{src}: {count} < {floor}")
|
||||
if total < 1500:
|
||||
issues.append(f"total skills: {total} < 1500")
|
||||
|
||||
if issues:
|
||||
detail = "; ".join(issues)
|
||||
print("status=degraded")
|
||||
# GITHUB_OUTPUT doesn't allow newlines without explicit delimiter
|
||||
print(f"detail={detail}")
|
||||
else:
|
||||
print("status=ok")
|
||||
print(f"detail=Index OK — {total} skills, generated {generated_at}")
|
||||
by_summary = ", ".join(f"{k}={v}" for k, v in by_src.most_common(8))
|
||||
print(f"summary={by_summary}")
|
||||
PY
|
||||
|
||||
- name: Report status
|
||||
run: |
|
||||
echo "Probe status: ${{ steps.probe.outputs.status }}"
|
||||
echo "Detail: ${{ steps.probe.outputs.detail }}"
|
||||
if [ -n "${{ steps.probe.outputs.summary }}" ]; then
|
||||
echo "Summary: ${{ steps.probe.outputs.summary }}"
|
||||
fi
|
||||
|
||||
- name: Open issue on degraded / failed probe
|
||||
if: steps.probe.outputs.status != 'ok'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
STATUS: ${{ steps.probe.outputs.status }}
|
||||
DETAIL: ${{ steps.probe.outputs.detail }}
|
||||
run: |
|
||||
# Find existing open issue by title prefix so we don't spam — we
|
||||
# append a comment instead of opening a new one each tick.
|
||||
TITLE_PREFIX="[skills-index-watchdog]"
|
||||
existing=$(gh issue list \
|
||||
--repo "${{ github.repository }}" \
|
||||
--state open \
|
||||
--search "in:title \"$TITLE_PREFIX\"" \
|
||||
--json number,title \
|
||||
--jq '.[] | select(.title | startswith("'"$TITLE_PREFIX"'")) | .number' \
|
||||
| head -1)
|
||||
BODY="Automated freshness probe failed.
|
||||
|
||||
**Status:** \`$STATUS\`
|
||||
**Detail:** $DETAIL
|
||||
|
||||
The Skills Hub at /docs/skills depends on \`/docs/api/skills-index.json\`.
|
||||
The unified index is rebuilt by \`.github/workflows/skills-index.yml\` (cron 6/18 UTC)
|
||||
and \`.github/workflows/deploy-site.yml\` (on every push affecting website/skills).
|
||||
If this issue keeps reopening, check the latest runs:
|
||||
|
||||
- https://github.com/${{ github.repository }}/actions/workflows/skills-index.yml
|
||||
- https://github.com/${{ github.repository }}/actions/workflows/deploy-site.yml
|
||||
|
||||
This issue was opened by \`.github/workflows/skills-index-freshness.yml\`. Close it once the underlying problem is fixed; the next probe will reopen if it's still broken."
|
||||
if [ -n "$existing" ]; then
|
||||
echo "Appending to existing issue #$existing"
|
||||
gh issue comment "$existing" --repo "${{ github.repository }}" --body "Probe still failing at $(date -u +%FT%TZ): \`$STATUS\` — $DETAIL"
|
||||
else
|
||||
echo "Opening new watchdog issue"
|
||||
gh issue create --repo "${{ github.repository }}" \
|
||||
--title "$TITLE_PREFIX Skills index is stale or degraded ($STATUS)" \
|
||||
--body "$BODY"
|
||||
fi
|
||||
65
.github/workflows/skills-index.yml
vendored
@@ -13,7 +13,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # to trigger deploy-site.yml on schedule
|
||||
|
||||
jobs:
|
||||
build-index:
|
||||
@@ -42,15 +41,61 @@ jobs:
|
||||
path: website/static/api/skills-index.json
|
||||
retention-days: 7
|
||||
|
||||
# Re-trigger the docs deploy so the refreshed index lands on the live site.
|
||||
# The deploy itself is owned by deploy-site.yml (which crawls and deploys
|
||||
# everything in one pipeline); we just kick it on a schedule.
|
||||
trigger-deploy:
|
||||
deploy-with-index:
|
||||
needs: build-index
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
# Only deploy on schedule or manual trigger (not on every push to the script)
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- name: Trigger Deploy Site workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh workflow run deploy-site.yml --repo ${{ github.repository }}
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: skills-index
|
||||
path: website/static/api/
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: website/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install PyYAML for skill extraction
|
||||
run: pip install pyyaml==6.0.2
|
||||
|
||||
- name: Extract skill metadata for dashboard
|
||||
run: python3 website/scripts/extract-skills.py
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: website
|
||||
|
||||
- name: Build Docusaurus
|
||||
run: npm run build
|
||||
working-directory: website
|
||||
|
||||
- name: Stage deployment
|
||||
run: |
|
||||
mkdir -p _site/docs
|
||||
cp -r landingpage/* _site/
|
||||
cp -r website/build/* _site/docs/
|
||||
echo "hermes-agent.nousresearch.com" > _site/CNAME
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
|
||||
with:
|
||||
path: _site
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deploy
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
|
||||
|
||||
27
.gitignore
vendored
@@ -12,13 +12,6 @@ __pycache__/
|
||||
.env.production.local
|
||||
.env.development
|
||||
.env.test
|
||||
.hermes-docker/
|
||||
.notebooklm-home/
|
||||
.notebooklm-cli-venv/
|
||||
.notebooklm-playwright/
|
||||
.pip-cache/
|
||||
.uv-cache/
|
||||
compose.hermes.local.yml
|
||||
export*
|
||||
__pycache__/model_tools.cpython-310.pyc
|
||||
__pycache__/web_tools.cpython-310.pyc
|
||||
@@ -63,10 +56,6 @@ environments/benchmarks/evals/
|
||||
|
||||
# Web UI build output
|
||||
hermes_cli/web_dist/
|
||||
apps/desktop/build/
|
||||
apps/desktop/dist/
|
||||
apps/desktop/release/
|
||||
apps/desktop/*.tsbuildinfo
|
||||
|
||||
# Web UI assets — synced from @nous-research/ui at build time via
|
||||
# `npm run sync-assets` (see web/package.json).
|
||||
@@ -83,20 +72,6 @@ mini-swe-agent/
|
||||
result
|
||||
website/static/api/skills-index.json
|
||||
models-dev-upstream/
|
||||
|
||||
# Local editor / agent tooling (machine-specific; keep in global config, not the repo)
|
||||
.codex/
|
||||
.cursor/
|
||||
.gemini/
|
||||
.zed/
|
||||
.mcp.json
|
||||
opencode.json
|
||||
config/mcporter.json
|
||||
|
||||
hermes_cli/tui_dist/*
|
||||
hermes_cli/scripts/
|
||||
docs/superpowers/*
|
||||
# Working directory for the Hermes Agent's session state (~/.hermes/ at runtime;
|
||||
# also created in-repo when an agent operates in this checkout). Plans, audit
|
||||
# logs, and per-session caches are never artifacts of the codebase.
|
||||
.hermes/
|
||||
docs/superpowers/*
|
||||
27
AGENTS.md
@@ -2,8 +2,6 @@
|
||||
|
||||
Instructions for AI coding assistants and developers working on the hermes-agent codebase.
|
||||
|
||||
**Never give up on the right solution.**
|
||||
|
||||
## Development Environment
|
||||
|
||||
```bash
|
||||
@@ -68,29 +66,6 @@ hermes-agent/
|
||||
`gateway.log` when running the gateway. Profile-aware via `get_hermes_home()`.
|
||||
Browse with `hermes logs [--follow] [--level ...] [--session ...]`.
|
||||
|
||||
## TypeScript Style
|
||||
|
||||
Applies to TypeScript across Hermes: desktop, TUI, website, and future TS packages.
|
||||
|
||||
- Prefer small nanostores over component state when state is shared, reused, or read by distant UI.
|
||||
- Let each feature own its atoms. Chat state belongs near chat, shell state near shell, shared state in `src/store`.
|
||||
- Components that render from an atom should use `useStore`. Non-rendering actions should read with `$atom.get()`.
|
||||
- Do not pass state through three components when the leaf can subscribe to the atom.
|
||||
- Keep persistence beside the atom that owns it.
|
||||
- Keep route roots thin. They compose routes and shell; they should not become controllers.
|
||||
- No monolithic hooks. A hook should own one narrow job.
|
||||
- Prefer colocated action modules over hidden god hooks.
|
||||
- If a callback is pure side effect, use the terse void form:
|
||||
`onState={st => void setGatewayState(st)}`.
|
||||
- Async UI handlers should make intent explicit:
|
||||
`onClick={() => void save()}`.
|
||||
- Prefer interfaces for public props and shared object shapes. Avoid `type X = { ... }` for object props.
|
||||
- Extend React primitives for props: `React.ComponentProps<'button'>`, `React.ComponentProps<typeof Dialog>`, `Omit<...>`, `Pick<...>`.
|
||||
- Table-driven beats condition ladders when mapping ids, routes, or views.
|
||||
- `src/app` owns routes, pages, and page-specific components.
|
||||
- `src/store` owns shared atoms.
|
||||
- `src/lib` owns shared pure helpers.
|
||||
|
||||
## File Dependency Chain
|
||||
|
||||
```
|
||||
@@ -274,7 +249,7 @@ npm test # vitest
|
||||
|
||||
The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes_cli/pty_bridge.py` + the `@app.websocket("/api/pty")` endpoint in `hermes_cli/web_server.py`.
|
||||
|
||||
- Browser loads `apps/dashboard/src/pages/ChatPage.tsx`, which mounts xterm.js's `Terminal` with the WebGL renderer, `@xterm/addon-fit` for container-driven resize, and `@xterm/addon-unicode11` for modern wide-character widths.
|
||||
- Browser loads `web/src/pages/ChatPage.tsx`, which mounts xterm.js's `Terminal` with the WebGL renderer, `@xterm/addon-fit` for container-driven resize, and `@xterm/addon-unicode11` for modern wide-character widths.
|
||||
- `/api/pty?token=…` upgrades to a WebSocket; auth uses the same ephemeral `_SESSION_TOKEN` as REST, via query param (browsers can't set `Authorization` on WS upgrade).
|
||||
- The server spawns whatever `hermes --tui` would spawn, through `ptyprocess` (POSIX PTY — WSL works, native Windows does not).
|
||||
- Frames: raw PTY bytes each direction; resize via `\x1b[RESIZE:<cols>;<rows>]` intercepted on the server and applied with `TIOCSWINSZ`.
|
||||
|
||||
47
Dockerfile
@@ -1,12 +1,4 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
|
||||
# Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x
|
||||
# which reached EOL in April 2026 — we copy node + npm + corepack from the
|
||||
# upstream node:22 image instead so we can stay on a supported LTS without
|
||||
# waiting for Debian 14 (forky, ~mid-2027). Bookworm-based slim image used
|
||||
# so the produced binary links against glibc 2.36, which runs cleanly on
|
||||
# our Debian 13 (trixie, glibc 2.41) runtime. Bumping to a new Node major
|
||||
# is a one-line ARG change; see #4977.
|
||||
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
|
||||
FROM debian:13.4
|
||||
|
||||
# Disable Python stdout buffering to ensure logs are printed immediately
|
||||
@@ -25,7 +17,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 python3 ripgrep ffmpeg gcc python3-dev libffi-dev procps git openssh-client docker-cli xz-utils && \
|
||||
build-essential curl nodejs npm 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 ----------
|
||||
@@ -80,18 +72,6 @@ RUN useradd -u 10000 -m -d /opt/data hermes
|
||||
|
||||
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
|
||||
|
||||
# Node 22 LTS: copy the node binary plus the bundled npm + corepack JS
|
||||
# installs from the upstream image. npm and npx are recreated as symlinks
|
||||
# because they're symlinks in the source image (and need to live on PATH).
|
||||
# See node_source stage at the top of the file for the version-bump
|
||||
# rationale (#4977).
|
||||
COPY --chmod=0755 --from=node_source /usr/local/bin/node /usr/local/bin/
|
||||
COPY --from=node_source /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
|
||||
COPY --from=node_source /usr/local/lib/node_modules/corepack /usr/local/lib/node_modules/corepack
|
||||
RUN ln -sf /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
||||
ln -sf /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx && \
|
||||
ln -sf /usr/local/lib/node_modules/corepack/dist/corepack.js /usr/local/bin/corepack
|
||||
|
||||
WORKDIR /opt/hermes
|
||||
|
||||
# ---------- Layer-cached dependency install ----------
|
||||
@@ -108,15 +88,14 @@ COPY ui-tui/package.json ui-tui/package-lock.json ui-tui/
|
||||
COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
|
||||
|
||||
# `npm_config_install_links=false` forces npm to install `file:` deps as
|
||||
# symlinks instead of copies. This is the default since npm 10+, which is
|
||||
# what the image ships now (via the node:22 source stage). We set it
|
||||
# explicitly anyway as defense-in-depth: the previous Debian-bundled npm
|
||||
# 9.x defaulted to install-as-copy, which produced a hidden
|
||||
# node_modules/.package-lock.json that permanently disagreed with the root
|
||||
# lock on the @hermes/ink entry, tripped the TUI launcher's
|
||||
# `_tui_need_npm_install()` check on every startup, and triggered a
|
||||
# runtime `npm install` that then failed with EACCES. Keeping the env
|
||||
# guards against a future regression if the source npm version changes.
|
||||
# symlinks (the npm 10+ default) even on Debian's older bundled npm 9.x,
|
||||
# which defaults to `install-links=true` and installs file deps as *copies*.
|
||||
# The host-side package-lock.json is generated with a newer npm that uses
|
||||
# symlinks, so an install-as-copy produces a hidden node_modules/.package-lock.json
|
||||
# that permanently disagrees with the root lock on the @hermes/ink entry.
|
||||
# That disagreement trips the TUI launcher's `_tui_need_npm_install()`
|
||||
# check on every startup and triggers a runtime `npm install` that then
|
||||
# fails with EACCES (node_modules/ is root-owned from build time).
|
||||
ENV npm_config_install_links=false
|
||||
|
||||
RUN npm install --prefer-offline --no-audit && \
|
||||
@@ -145,14 +124,10 @@ RUN npm install --prefer-offline --no-audit && \
|
||||
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
|
||||
# redundancy), none of which belong in the published container.
|
||||
#
|
||||
# Provider packages (anthropic, bedrock, azure-identity) are included
|
||||
# so Docker users can use these providers without requiring runtime
|
||||
# lazy-install access to PyPI (often blocked in containerized envs).
|
||||
#
|
||||
# The editable link is created after the source copy below.
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN touch ./README.md
|
||||
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity
|
||||
RUN uv sync --frozen --no-install-project --extra all --extra messaging
|
||||
|
||||
# ---------- Source code ----------
|
||||
# .dockerignore excludes node_modules, so the installs above survive.
|
||||
@@ -204,7 +179,7 @@ COPY docker/s6-rc.d/ /etc/s6-overlay/s6-rc.d/
|
||||
# slots from $HERMES_HOME/profiles/<name>/ after a container restart
|
||||
# (the /run/service/ scandir is tmpfs and wiped on restart). Phase 4.
|
||||
RUN mkdir -p /etc/cont-init.d && \
|
||||
printf '#!/command/with-contenv sh\nexec /opt/hermes/docker/stage2-hook.sh\n' \
|
||||
printf '#!/bin/sh\nexec /opt/hermes/docker/stage2-hook.sh\n' \
|
||||
> /etc/cont-init.d/01-hermes-setup && \
|
||||
chmod +x /etc/cont-init.d/01-hermes-setup
|
||||
COPY --chmod=0755 docker/cont-init.d/015-supervise-perms /etc/cont-init.d/015-supervise-perms
|
||||
|
||||
@@ -22,7 +22,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
|
||||
<tr><td><b>A closed learning loop</b></td><td>Agent-curated memory with periodic nudges. Autonomous skill creation after complex tasks. Skills self-improve during use. FTS5 session search with LLM summarization for cross-session recall. <a href="https://github.com/plastic-labs/honcho">Honcho</a> dialectic user modeling. Compatible with the <a href="https://agentskills.io">agentskills.io</a> open standard.</td></tr>
|
||||
<tr><td><b>Scheduled automations</b></td><td>Built-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended.</td></tr>
|
||||
<tr><td><b>Delegates and parallelizes</b></td><td>Spawn isolated subagents for parallel workstreams. Write Python scripts that call tools via RPC, collapsing multi-step pipelines into zero-context-cost turns.</td></tr>
|
||||
<tr><td><b>Runs anywhere, not just your laptop</b></td><td>Six terminal backends — local, Docker, SSH, Singularity, Modal, and Daytona. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster.</td></tr>
|
||||
<tr><td><b>Runs anywhere, not just your laptop</b></td><td>Seven terminal backends — local, Docker, SSH, Singularity, Modal, Daytona, and Vercel Sandbox. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster.</td></tr>
|
||||
<tr><td><b>Research-ready</b></td><td>Batch trajectory generation, trajectory compression for training the next generation of tool-calling models.</td></tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -3,73 +3,75 @@
|
||||
**Release Date:** May 16, 2026
|
||||
**Since v0.13.0:** 808 commits · 633 merged PRs · 1393 files changed · 165,061 insertions · 545 issues closed (12 P0, 50 P1) · 215 community contributors (including co-authors)
|
||||
|
||||
> The Foundation Release — Hermes Agent installs and runs anywhere now. Native Windows ships in early beta with a full PowerShell installer story, a `pip install hermes-agent` wheel lands on PyPI, lazy-deps reshape what `pip install hermes-agent` actually pulls down, the supply-chain checker scans every install/upgrade for unsafe versions, and a new OpenAI-compatible local proxy lets Codex / Aider / Cline talk to OAuth-only providers (Claude Pro, ChatGPT Pro, SuperGrok). The cold-start wave shaves ~19 seconds off `hermes` launch, browser-tool CDP calls run 180x faster, and `hermes tools` All-Platforms drops from 14s to under 1.5s. Two new messaging platforms (LINE and SimpleX Chat) and a Microsoft Graph foundation (Teams pipeline + webhook adapter) land alongside `/handoff` that finally transfers sessions live, `vision_analyze` passing pixels through to vision-capable models, `x_search` as a first-class tool, LSP semantic diagnostics on every `write_file` / `patch`, a unified pluggable `video_generate`, a `computer_use` cua-driver backend, cross-session 1-hour Claude prompt caching, a per-turn file-mutation verifier, plus 9 new optional skills. 50+ P1 closures, 12 P0 closures.
|
||||
> The Foundation Release — Hermes installs and runs anywhere, ships with the things you actually want to use, and stops shipping the things you don't. xAI Grok lands as a SuperGrok OAuth provider with grok-4.3 bumped to a 1M context window. A new OpenAI-compatible local proxy turns any OAuth-authed Hermes provider — Claude Pro, ChatGPT Pro, SuperGrok — into an endpoint that Codex / Aider / Cline / Continue can hit. `x_search` lands as a first-class X (Twitter) search tool with OAuth-or-API-key auth. The Microsoft Teams stack is wired end-to-end (Graph auth + webhook listener + pipeline runtime + outbound delivery). A debloating wave makes installs dramatically lighter — heavyweight backends now lazy-install on first use, the `[all]` extras drop everything covered by lazy-deps, and a tiered install falls back when a wheel rejects on your platform. `pip install hermes-agent` works from PyPI. The cold-start wave shaves ~19 seconds off `hermes` launch. Browser CDP calls are 180x faster. Two new messaging platforms (LINE + SimpleX Chat) bring the total to 22. Cross-session 1-hour Claude prompt caching, `/handoff` that actually transfers sessions live, native button UI for `clarify` on Telegram and Discord, Discord channel history backfill, LSP semantic diagnostics on every write, a unified pluggable `video_generate`, a `computer_use` cua-driver backend that finally works with non-Anthropic providers, clickable URLs in any terminal, Zed ACP Registry integration via `uvx`, native Windows beta, 9 new optional skills, OpenRouter Pareto Code router, huggingface/skills as a trusted default tap. 12 P0 + 50 P1 closures.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **Native Windows support (early beta)** — full PowerShell installer, native subprocess/PTY paths, taskkill-based process management, MinGit auto-install, Microsoft Store python stub detection, foreground Ctrl+C preservation, taskkill+ps2 fallback, npm prefix handling, and ~40 follow-up Windows-only fixes across CLI / gateway / TUI / curator / tools. Hermes finally runs natively on `cmd.exe` and PowerShell, no WSL required. ([#21561](https://github.com/NousResearch/hermes-agent/pull/21561), [#22130](https://github.com/NousResearch/hermes-agent/pull/22130), [#22752](https://github.com/NousResearch/hermes-agent/pull/22752), [#26618](https://github.com/NousResearch/hermes-agent/pull/26618), and many more)
|
||||
- **xAI Grok via SuperGrok OAuth — and grok-4.3 jumps to a 1M context window** — If you pay for SuperGrok, you can now use Grok inside Hermes by signing in with your xAI account — no API key, no separate billing. The wire-through also bumps grok-4.3 to a 1M token context window, so you can drop whole codebases or research corpora into a single prompt. Includes proper handling for entitlement errors and an SSH-to-tunnel docs page for when you're SSH'd into a remote box and need to complete the OAuth flow. ([#26534](https://github.com/NousResearch/hermes-agent/pull/26534), [#26664](https://github.com/NousResearch/hermes-agent/pull/26664), [#26644](https://github.com/NousResearch/hermes-agent/pull/26644), [#26592](https://github.com/NousResearch/hermes-agent/pull/26592))
|
||||
|
||||
- **`pip install hermes-agent && hermes`** — Hermes Agent is now a real PyPI package. One command, no clone, no git, no shell installer. Wheel includes the Ink TUI bundle and shell launcher. (salvage of [#26350](https://github.com/NousResearch/hermes-agent/pull/26350)) ([#26593](https://github.com/NousResearch/hermes-agent/pull/26593))
|
||||
- **OpenAI-compatible local proxy for OAuth providers** — Run `hermes proxy` and you get a `http://localhost:port` endpoint that speaks the OpenAI API but is backed by whichever OAuth provider you're signed into — Claude Pro, ChatGPT Pro, SuperGrok. Now any tool that expects an OpenAI-compatible endpoint (Codex CLI, Aider, Cline, Continue, your custom scripts) just works with your existing subscription, no API key required. One subscription, every tool. ([#25969](https://github.com/NousResearch/hermes-agent/pull/25969))
|
||||
|
||||
- **Cold-start performance wave — ~19s off `hermes` launch** — skills cache, lazy Feishu import, no Nous HTTP at startup, plus PEP-562 lazy adapter imports (QQ, Yuanbao, Teams, Google Chat), deferred `fal_client` / `google-cloud` / `httpx` loads, models.dev disk-cache-first lookup, parallel doctor API checks, eager-skip plugin discovery on built-in subcommands, `hermes tools` All-Platforms drops from 14s to <1.5s, welcome banner skipped on `chat -q`. ([#22138](https://github.com/NousResearch/hermes-agent/pull/22138), [#22120](https://github.com/NousResearch/hermes-agent/pull/22120), [#22681](https://github.com/NousResearch/hermes-agent/pull/22681), [#22790](https://github.com/NousResearch/hermes-agent/pull/22790), [#22808](https://github.com/NousResearch/hermes-agent/pull/22808), [#22831](https://github.com/NousResearch/hermes-agent/pull/22831), [#22859](https://github.com/NousResearch/hermes-agent/pull/22859), [#22904](https://github.com/NousResearch/hermes-agent/pull/22904), [#22766](https://github.com/NousResearch/hermes-agent/pull/22766), [#25341](https://github.com/NousResearch/hermes-agent/pull/25341))
|
||||
- **`x_search` — first-class X (Twitter) search tool** — The agent can now search X directly without installing a skill or wiring up a custom integration. Search the timeline, find threads, surface specific posts — straight from the chat. Auth with either your X OAuth login or an API key, whichever you have. ([#26763](https://github.com/NousResearch/hermes-agent/pull/26763))
|
||||
|
||||
- **180x faster `browser_console` evaluations** — routed through the supervisor's persistent CDP WebSocket instead of spawning a fresh DevTools session per call. Real-world page interactions feel instant. ([#23226](https://github.com/NousResearch/hermes-agent/pull/23226))
|
||||
- **Microsoft Teams — end-to-end** — Hermes can now read messages from Teams and post back. The full Microsoft Graph stack lands together: auth + client foundation, a webhook listener that receives Teams events, a pipeline plugin runtime, and outbound delivery. Wire up the bot once, then chat to your agent from any Teams channel, DM, or group. (salvages of #21408–#21411) ([#21922](https://github.com/NousResearch/hermes-agent/pull/21922), [#21969](https://github.com/NousResearch/hermes-agent/pull/21969), [#22007](https://github.com/NousResearch/hermes-agent/pull/22007), [#22024](https://github.com/NousResearch/hermes-agent/pull/22024))
|
||||
|
||||
- **Supply-chain advisory checker + lazy-deps framework + tiered install fallback** — every `pip install` / `hermes update` scans dependencies against an advisory list, lazy-deps replace heavy import-time loads with first-use installs, and the installer falls back through extras tiers when a wheel rejects on the target platform. ([#24220](https://github.com/NousResearch/hermes-agent/pull/24220))
|
||||
- **Debloating wave — lighter installs, less you don't use** — A clean `pip install hermes-agent` used to pull down everything: every messaging adapter SDK, every image-gen SDK, every voice/TTS provider, whether you used them or not. Now those heavy backends (Slack / Matrix / Feishu / DingTalk adapters, hindsight client, codex app-server, Pixverse / Camofox / image-gen SDKs, voice/TTS providers) install automatically the first time you actually use them. The `[all]` extras drop everything covered by lazy-deps, the installer falls back through tiers when a wheel doesn't fit your platform, and a supply-chain advisory checker scans every install for unsafe versions. Faster installs, smaller disk footprint, fewer transitive vulnerabilities. ([#24220](https://github.com/NousResearch/hermes-agent/pull/24220), [#24515](https://github.com/NousResearch/hermes-agent/pull/24515), [#25014](https://github.com/NousResearch/hermes-agent/pull/25014), [#25038](https://github.com/NousResearch/hermes-agent/pull/25038), [#25766](https://github.com/NousResearch/hermes-agent/pull/25766), [#21818](https://github.com/NousResearch/hermes-agent/pull/21818))
|
||||
|
||||
- **OpenAI-compatible local proxy** — `hermes proxy` exposes any OAuth-authed provider (Claude Pro, ChatGPT Pro, SuperGrok) as an OpenAI-compatible endpoint that Codex / Aider / Cline / VS Code Continue can hit. Your subscription, your tools. ([#25969](https://github.com/NousResearch/hermes-agent/pull/25969))
|
||||
- **`pip install hermes-agent && hermes`** — Hermes Agent is now a real PyPI package. No more cloning the repo or running shell installers — one pip command and you're running. The wheel ships with the Ink TUI bundle and the shell launcher, so the full experience comes out of the box. (salvage of [#26350](https://github.com/NousResearch/hermes-agent/pull/26350)) ([#26593](https://github.com/NousResearch/hermes-agent/pull/26593), [#26148](https://github.com/NousResearch/hermes-agent/pull/26148))
|
||||
|
||||
- **Cross-session 1-hour Claude prompt cache** — Anthropic / OpenRouter / Nous Portal now share a 1h prefix cache across sessions for Claude models. Fast resume, fast `/new`, lower cost on repeat work. ([#23828](https://github.com/NousResearch/hermes-agent/pull/23828))
|
||||
- **Cross-session 1h Claude prompt cache** — When you use Claude through Anthropic, OpenRouter, or Nous Portal, the prompt prefix (system prompt, skills, memory) now caches for an hour across sessions. Start a `/new` session and the first response comes back faster and cheaper because the cache is still warm from your last session. Background memory review hits the cache too, so it's not paying full price every turn. ([#23828](https://github.com/NousResearch/hermes-agent/pull/23828), [#25434](https://github.com/NousResearch/hermes-agent/pull/25434), [#24778](https://github.com/NousResearch/hermes-agent/pull/24778))
|
||||
|
||||
- **Two new messaging platforms — LINE + SimpleX Chat** — LINE Messaging API lands as a first-class platform, SimpleX Chat salvages #2558 onto the modern adapter spec. Hermes is now on 22 platforms. ([#23197](https://github.com/NousResearch/hermes-agent/pull/23197), [#26232](https://github.com/NousResearch/hermes-agent/pull/26232))
|
||||
- **180x faster `browser_console` evaluations** — When the agent uses the browser tool to inspect a page or run JavaScript, those calls now share one persistent connection to Chrome instead of spinning up a new DevTools session every time. The difference is huge: things that used to take a couple of seconds per call return in milliseconds. Real-world page interactions feel instant. ([#23226](https://github.com/NousResearch/hermes-agent/pull/23226))
|
||||
|
||||
- **Microsoft Graph foundation — Teams pipeline + webhook adapter** — `msgraph` auth/client foundation, webhook listener platform, Teams pipeline plugin runtime, and Teams outbound delivery via the existing adapter — Hermes can now read and post to Teams. (salvages of #21408–#21411) ([#21922](https://github.com/NousResearch/hermes-agent/pull/21922), [#21969](https://github.com/NousResearch/hermes-agent/pull/21969), [#22007](https://github.com/NousResearch/hermes-agent/pull/22007), [#22024](https://github.com/NousResearch/hermes-agent/pull/22024))
|
||||
- **Cold-start performance wave — ~19 seconds off `hermes` launch** — Running `hermes` used to make you wait through a chunk of import overhead and network calls before you saw a prompt. Now the launch path is mostly deferred: heavy adapters only load when you use them, model catalogs come from disk cache first, doctor checks run in parallel, and `chat -q` skips the welcome banner entirely. The `hermes tools` All-Platforms screen alone dropped from 14 seconds to under 1.5 seconds. ([#22138](https://github.com/NousResearch/hermes-agent/pull/22138), [#22120](https://github.com/NousResearch/hermes-agent/pull/22120), [#22681](https://github.com/NousResearch/hermes-agent/pull/22681), [#22790](https://github.com/NousResearch/hermes-agent/pull/22790), [#22808](https://github.com/NousResearch/hermes-agent/pull/22808), [#22831](https://github.com/NousResearch/hermes-agent/pull/22831), [#22859](https://github.com/NousResearch/hermes-agent/pull/22859), [#22904](https://github.com/NousResearch/hermes-agent/pull/22904), [#22766](https://github.com/NousResearch/hermes-agent/pull/22766), [#25341](https://github.com/NousResearch/hermes-agent/pull/25341))
|
||||
|
||||
- **`/handoff` actually transfers the session live** — the agent's active session moves to a different model / persona / profile mid-conversation, with messages, tool history, and context preserved. ([#23395](https://github.com/NousResearch/hermes-agent/pull/23395))
|
||||
- **Two new messaging platforms — LINE + SimpleX Chat** — LINE is huge in Japan, Korea, and Taiwan, and now Hermes runs natively on the LINE Messaging API. SimpleX Chat is the privacy-focused decentralized messenger with no user IDs — also wired up as a first-class platform. That brings Hermes to 22 messaging platforms total, so wherever you and your team chat, the agent can be there. ([#23197](https://github.com/NousResearch/hermes-agent/pull/23197), [#26232](https://github.com/NousResearch/hermes-agent/pull/26232))
|
||||
|
||||
- **`x_search` — first-class X (Twitter) search tool** — gated tool with OAuth-or-API-key auth, no skill needed to query the timeline. ([#26763](https://github.com/NousResearch/hermes-agent/pull/26763))
|
||||
- **`/handoff` actually transfers the session live** — Switching models or personalities mid-conversation used to mean losing context or starting over. Now `/handoff` moves your active session — every message, every tool call, every piece of context — to the target model, persona, or profile, live, without dropping anything. Mid-debugging hand off from a fast model to a deep-reasoning one, or pass a session between profiles for different parts of a task. ([#23395](https://github.com/NousResearch/hermes-agent/pull/23395))
|
||||
|
||||
- **`vision_analyze` returns pixels to vision-capable models** — when the active model can see, `vision_analyze` now hands the image straight through instead of falling back to a text description. ([#22955](https://github.com/NousResearch/hermes-agent/pull/22955))
|
||||
- **Native button UI for `clarify` on Telegram and Discord** — When the agent uses the `clarify` tool to ask you a multiple-choice question, it now shows real platform-native buttons on Telegram and Discord instead of asking you to type back the option number. Tap the button, the agent gets your answer. Especially nice on mobile. ([#24199](https://github.com/NousResearch/hermes-agent/pull/24199), [#25485](https://github.com/NousResearch/hermes-agent/pull/25485))
|
||||
|
||||
- **LSP semantic diagnostics on every write** — `write_file` and `patch` now run real language-server diagnostics on the post-edit file (delta-only) and surface real errors before they ship downstream. ([#24168](https://github.com/NousResearch/hermes-agent/pull/24168), [#25978](https://github.com/NousResearch/hermes-agent/pull/25978))
|
||||
- **Discord channel history backfill (default on)** — When Hermes joins a Discord channel or thread for the first time, it now reads the recent message history so it knows what's been said before it responds. No more "what are we talking about?" — the agent has the context that's already on screen for everyone else. ([#25984](https://github.com/NousResearch/hermes-agent/pull/25984))
|
||||
|
||||
- **Per-turn file-mutation verifier footer** — after every turn that wrote files, the agent gets a verifier footer summarizing what actually changed on disk — catches silent overwrites and "wrote it but it didn't land" bugs. ([#24498](https://github.com/NousResearch/hermes-agent/pull/24498))
|
||||
- **`vision_analyze` returns pixels to vision-capable models** — When you point the agent at an image with `vision_analyze` and the active model can actually see (GPT-5, Claude, Gemini, Grok-vision), Hermes now passes the raw pixels straight to the model instead of converting them to a text description first. You get the model's actual visual reasoning instead of a degraded text-summary round-trip. ([#22955](https://github.com/NousResearch/hermes-agent/pull/22955))
|
||||
|
||||
- **Unified `video_generate` with pluggable provider backends** — single tool, any backend. Drop in a new video provider as a plugin, no core changes. ([#25126](https://github.com/NousResearch/hermes-agent/pull/25126))
|
||||
- **Per-turn file-mutation verifier footer** — After every turn that wrote or edited files, the agent now gets a short footer summarizing exactly what changed on disk — the file paths, the line counts, the actual delta. That means the agent catches its own mistakes when a write didn't land or got silently overwritten, instead of confidently telling you "I added the function" when the file wasn't actually saved. ([#24498](https://github.com/NousResearch/hermes-agent/pull/24498))
|
||||
|
||||
- **`computer_use` cua-driver backend** — proper focus-safe ops, non-Anthropic provider support, refresh on `hermes update`. Computer-use is no longer locked to a single SDK. (re-salvage of #16936) ([#21967](https://github.com/NousResearch/hermes-agent/pull/21967), [#24063](https://github.com/NousResearch/hermes-agent/pull/24063))
|
||||
- **LSP semantic diagnostics on every write** — When the agent uses `write_file` or `patch`, Hermes now runs a real language server against the edited file and surfaces any new errors back to the agent before the next turn. Type errors, undefined symbols, missing imports — caught immediately. Goes way beyond v0.13.0's basic Python/JSON/YAML/TOML linting because it's actual semantic analysis. ([#24168](https://github.com/NousResearch/hermes-agent/pull/24168), [#25978](https://github.com/NousResearch/hermes-agent/pull/25978))
|
||||
|
||||
- **xAI Grok OAuth provider — SuperGrok via subscription** — sign in with your xAI account, talk to Grok models from Hermes. ([#26534](https://github.com/NousResearch/hermes-agent/pull/26534))
|
||||
- **Unified `video_generate` with pluggable provider backends** — One tool, any video model. Hermes ships with the obvious backends already, but you can drop in a new video provider as a plugin without touching core. So when a new video model lands next month, it can be a one-file plugin instead of a fork. ([#25126](https://github.com/NousResearch/hermes-agent/pull/25126))
|
||||
|
||||
- **Clarify with buttons — native inline keyboards on Telegram + Discord** — the `clarify` tool renders multi-choice prompts as platform-native buttons instead of typed responses. ([#24199](https://github.com/NousResearch/hermes-agent/pull/24199), [#25485](https://github.com/NousResearch/hermes-agent/pull/25485))
|
||||
- **`computer_use` cua-driver backend — works with non-Anthropic models now** — Computer-use (the agent controlling your mouse and keyboard to drive GUI apps) used to be locked to Anthropic's SDK. The new cua-driver backend works with non-Anthropic providers too, has proper focus-safe operations, and refreshes itself on `hermes update`. Now any vision-capable model can drive your desktop. (re-salvage of #16936) ([#21967](https://github.com/NousResearch/hermes-agent/pull/21967), [#24063](https://github.com/NousResearch/hermes-agent/pull/24063))
|
||||
|
||||
- **Discord channel history backfill (default on)** — Hermes reads recent channel history when joining a thread so it actually knows what's been said. ([#25984](https://github.com/NousResearch/hermes-agent/pull/25984))
|
||||
- **Clickable URLs in any terminal** — Links in agent output are now real OSC8 hyperlinks with hover-highlight in any terminal that supports them. Click to open in your browser — no more copy-paste-trim of long URLs from the transcript. Just works in iTerm2, Kitty, Ghostty, modern Windows Terminal, etc. (@OutThisLife) ([#25071](https://github.com/NousResearch/hermes-agent/pull/25071), [#24013](https://github.com/NousResearch/hermes-agent/pull/24013))
|
||||
|
||||
- **Watchers skill — RSS / HTTP JSON / GitHub polling via cron `no_agent` mode** — skill recipes that wire change-detection sources directly into cron's script-only watchdog mode. ([#21881](https://github.com/NousResearch/hermes-agent/pull/21881))
|
||||
- **Zed ACP Registry — `uvx` install in one click** — Hermes is now listed in Zed's Agent Client Protocol registry, so Zed users can install it with one click. The install path uses `uvx` so there's no npm dependency. `hermes acp --setup-browser` bootstraps the browser tools for registry-driven installs. (salvage of [#25908](https://github.com/NousResearch/hermes-agent/pull/25908)) ([#26079](https://github.com/NousResearch/hermes-agent/pull/26079), [#26120](https://github.com/NousResearch/hermes-agent/pull/26120), [#26234](https://github.com/NousResearch/hermes-agent/pull/26234))
|
||||
|
||||
- **Zed ACP Registry integration + uvx distribution** — Hermes is in the Zed registry, installable via `uvx` (no npm). Plus `hermes acp --setup-browser` bootstraps browser tools for registry installs. (salvage of [#25908](https://github.com/NousResearch/hermes-agent/pull/25908)) ([#26079](https://github.com/NousResearch/hermes-agent/pull/26079), [#26120](https://github.com/NousResearch/hermes-agent/pull/26120), [#26234](https://github.com/NousResearch/hermes-agent/pull/26234))
|
||||
- **OpenRouter Pareto Code router with `min_coding_score` knob** — OpenRouter's "Pareto" router automatically picks the cheapest model that meets a minimum quality bar. The new `min_coding_score` config lets you set that bar for coding tasks specifically — Hermes routes to the most affordable model that's at least that good at code. Stop paying for top-tier models when a mid-tier one would do. ([#22838](https://github.com/NousResearch/hermes-agent/pull/22838))
|
||||
|
||||
- **OpenRouter Pareto Code router** — wire a new OpenRouter router with `min_coding_score` knob. Pick the cheapest model that meets your quality bar. ([#22838](https://github.com/NousResearch/hermes-agent/pull/22838))
|
||||
- **NovitaAI as a new model provider** — NovitaAI joins the provider lineup, giving you another option for open-source model hosting (Llama, Qwen, DeepSeek, etc.) with their pricing and rate limits. (salvage #7219) (@kshitijk4poor) ([#25507](https://github.com/NousResearch/hermes-agent/pull/25507))
|
||||
|
||||
- **Optional codex app-server runtime for OpenAI/Codex models** — drives the OpenAI Codex CLI under the hood for OpenAI/Codex paths, with session reuse, wedge retirement, and OAuth refresh classification. ([#24182](https://github.com/NousResearch/hermes-agent/pull/24182), [#25769](https://github.com/NousResearch/hermes-agent/pull/25769))
|
||||
- **Codex app-server runtime for OpenAI/Codex models** — An optional runtime that drives OpenAI's Codex CLI under the hood when you're using OpenAI or Codex paths. You get session reuse, automatic retirement of wedged sessions, and proper OAuth refresh classification — the kind of plumbing that makes long agentic runs not fall over. ([#24182](https://github.com/NousResearch/hermes-agent/pull/24182), [#25769](https://github.com/NousResearch/hermes-agent/pull/25769))
|
||||
|
||||
- **`hermes-skills/huggingface` as a trusted default tap** — community skills index from huggingface.co/skills is available by default in the Skills Hub. ([#26219](https://github.com/NousResearch/hermes-agent/pull/26219))
|
||||
- **`huggingface/skills` as a trusted default tap** — The community skills index hosted at huggingface.co/skills is now wired into the Skills Hub by default. So when somebody publishes a useful skill there, you can install it from your own `hermes skills` browser without any extra config. (closes #2549) ([#26219](https://github.com/NousResearch/hermes-agent/pull/26219))
|
||||
|
||||
- **9 new optional skills** — Hyperliquid (perp/spot trading via SDK + REST) (@kshitijk4poor & Hermes), Yahoo Finance market data, api-testing (REST/GraphQL debug), unified EVM multi-chain skill (folds #25291 + #2010 + base/), darwinian-evolver, osint-investigation (closes #355), pinggy-tunnel, watchers (RSS/HTTP/GitHub via cron), Notion overhaul for the Developer Platform (May 2026). ([#23582](https://github.com/NousResearch/hermes-agent/pull/23582), [#23583](https://github.com/NousResearch/hermes-agent/pull/23583), [#23590](https://github.com/NousResearch/hermes-agent/pull/23590), [#25299](https://github.com/NousResearch/hermes-agent/pull/25299), [#26760](https://github.com/NousResearch/hermes-agent/pull/26760), [#26729](https://github.com/NousResearch/hermes-agent/pull/26729), [#26765](https://github.com/NousResearch/hermes-agent/pull/26765), [#21881](https://github.com/NousResearch/hermes-agent/pull/21881), [#26612](https://github.com/NousResearch/hermes-agent/pull/26612))
|
||||
- **9 new optional skills** — Hyperliquid (perp + spot trading via the SDK and REST API), Yahoo Finance (live market data, fundamentals, historicals), api-testing (REST + GraphQL debug recipes), unified EVM multi-chain (one skill covers Ethereum + L2s + Base), darwinian-evolver (evolutionary prompt/skill tuning), osint-investigation (OSINT recipes for people / domains / orgs), pinggy-tunnel (expose local services to the public internet), watchers (polls RSS / HTTP JSON / GitHub via cron `no_agent` mode for change detection), and a full Notion overhaul for the May 2026 Developer Platform. ([#23582](https://github.com/NousResearch/hermes-agent/pull/23582), [#23583](https://github.com/NousResearch/hermes-agent/pull/23583), [#23590](https://github.com/NousResearch/hermes-agent/pull/23590), [#25299](https://github.com/NousResearch/hermes-agent/pull/25299), [#26760](https://github.com/NousResearch/hermes-agent/pull/26760), [#26729](https://github.com/NousResearch/hermes-agent/pull/26729), [#26765](https://github.com/NousResearch/hermes-agent/pull/26765), [#21881](https://github.com/NousResearch/hermes-agent/pull/21881), [#26612](https://github.com/NousResearch/hermes-agent/pull/26612))
|
||||
|
||||
- **API server exposes run approval events** — long-running runs surface approval requests over the API stream, no more silent stalls. (salvage of [#20311](https://github.com/NousResearch/hermes-agent/pull/20311)) ([#21899](https://github.com/NousResearch/hermes-agent/pull/21899))
|
||||
- **API server exposes run approval events** — If you're driving Hermes programmatically through the HTTP API, long-running runs no longer silently hang when the agent hits an approval-required command. The approval request now surfaces on the API stream so your client can prompt the user and reply — no more silent stalls. (salvage of [#20311](https://github.com/NousResearch/hermes-agent/pull/20311)) ([#21899](https://github.com/NousResearch/hermes-agent/pull/21899))
|
||||
|
||||
- **`/subgoal` — user-added criteria appended to active `/goal`** — layer extra success criteria onto a running goal loop. The judge sees them in the prompt, no behavior change when subgoals are empty. ([#25449](https://github.com/NousResearch/hermes-agent/pull/25449))
|
||||
- **Plugins can run any LLM call via `ctx.llm` + replace built-in tools via `tool_override`** — If you're writing a Hermes plugin, you now get first-class access to make LLM calls through the active provider and credentials — no manual client wiring. The new `tool_override` flag lets a plugin swap out a built-in tool with its own implementation cleanly. Plugin authors get the same model-routing and auth plumbing the core agent uses. (closes #11049) ([#23194](https://github.com/NousResearch/hermes-agent/pull/23194), [#26759](https://github.com/NousResearch/hermes-agent/pull/26759))
|
||||
|
||||
- **Plugins can run any LLM call via `ctx.llm`** — plugins get a first-class hook to make their own LLM requests through the active provider/credentials, no manual wiring. Plus `tool_override` flag for replacing built-in tools. ([#23194](https://github.com/NousResearch/hermes-agent/pull/23194), [#26759](https://github.com/NousResearch/hermes-agent/pull/26759))
|
||||
- **Brave Search (free tier) + DuckDuckGo (DDGS) as web-search providers** — Two new free web-search backends join Tavily, SearXNG, and Exa. Brave Search has a generous free tier; DDGS is the DuckDuckGo scraper that needs no key at all. Pick whichever fits your budget and rate-limit needs. ([#21337](https://github.com/NousResearch/hermes-agent/pull/21337))
|
||||
|
||||
- **Brave Search (free tier) + DuckDuckGo (DDGS) as web-search providers** — two new free search backends alongside Tavily / SearXNG / Exa. ([#21337](https://github.com/NousResearch/hermes-agent/pull/21337))
|
||||
- **Sudo brute-force block + 3 dangerous-command bypasses closed + tool-error sanitization** — The approval gate now blocks `sudo -S` brute-force attempts and classifies stdin-fed or askpass-stripped sudo invocations as DANGEROUS. Three known bypasses of dangerous-command detection are closed (inspired by Claude Code's command-detection work). And tool error strings are now sanitized before being re-injected into the model context, so a malicious file or remote service can't pass instructions to your agent through error output. ([#23736](https://github.com/NousResearch/hermes-agent/pull/23736), [#26829](https://github.com/NousResearch/hermes-agent/pull/26829), [#26823](https://github.com/NousResearch/hermes-agent/pull/26823))
|
||||
|
||||
- **Sudo brute-force block + sudo-stdin/askpass DANGEROUS classification** — closes the `sudo -S` brute-force avenue; approval gates classify stdin-fed and askpass-stripped sudo invocations as dangerous. (salvages of #22194 + #21128) ([#23736](https://github.com/NousResearch/hermes-agent/pull/23736))
|
||||
- **`/subgoal` — user-added criteria appended to an active `/goal`** — When you've got a `/goal` running (the persistent Ralph-loop goal where the agent keeps going until criteria are met), you can now use `/subgoal <text>` to layer extra success criteria onto it mid-run. The judge factors your new criteria into the done-or-keep-going decision without restarting the loop. ([#25449](https://github.com/NousResearch/hermes-agent/pull/25449))
|
||||
|
||||
- **Provider rename — Alibaba Cloud → Qwen Cloud, picker reorder** — matches what the world calls it. Existing config keys still work. ([#24835](https://github.com/NousResearch/hermes-agent/pull/24835))
|
||||
- **Provider rename — Alibaba Cloud → Qwen Cloud** — The Alibaba Cloud provider is renamed to Qwen Cloud in the picker and config to match what the rest of the world calls it. Existing config keys still work — no breaking changes — but the UI matches the actual brand now. ([#24835](https://github.com/NousResearch/hermes-agent/pull/24835))
|
||||
|
||||
- **Native Windows support (early beta)** — Hermes now runs natively on `cmd.exe` and PowerShell without WSL. A full PowerShell installer handles MinGit auto-install, Microsoft Store python stub detection, and the foreground Ctrl+C dance. There's still rough edges (this is the "early beta" stamp) — ~40 follow-up Windows-only fixes already landed in the window — but the basic loop works end-to-end on a clean Windows box. ([#21561](https://github.com/NousResearch/hermes-agent/pull/21561))
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -183,7 +183,6 @@ def init_agent(
|
||||
prefill_messages: List[Dict[str, Any]] = None,
|
||||
platform: str = None,
|
||||
user_id: str = None,
|
||||
user_id_alt: str = None,
|
||||
user_name: str = None,
|
||||
chat_id: str = None,
|
||||
chat_name: str = None,
|
||||
@@ -266,7 +265,6 @@ def init_agent(
|
||||
agent.ephemeral_system_prompt = ephemeral_system_prompt
|
||||
agent.platform = platform # "cli", "telegram", "discord", "whatsapp", etc.
|
||||
agent._user_id = user_id # Platform user identifier (gateway sessions)
|
||||
agent._user_id_alt = user_id_alt # Optional stable alternate platform identifier
|
||||
agent._user_name = user_name
|
||||
agent._chat_id = chat_id
|
||||
agent._chat_name = chat_name
|
||||
@@ -738,8 +736,8 @@ def init_agent(
|
||||
client_kwargs["default_headers"] = _codex_cloudflare_headers(api_key)
|
||||
elif "default_headers" not in client_kwargs:
|
||||
# Fall back to profile.default_headers for providers that
|
||||
# declare custom headers (e.g. Kimi User-Agent on non-kimi.com
|
||||
# endpoints).
|
||||
# declare custom headers (e.g. Vercel AI Gateway attribution,
|
||||
# Kimi User-Agent on non-kimi.com endpoints).
|
||||
try:
|
||||
from providers import get_provider_profile as _gpf
|
||||
_ph = _gpf(agent.provider)
|
||||
@@ -1007,13 +1005,6 @@ def init_agent(
|
||||
|
||||
# Track conversation messages for session logging
|
||||
agent._session_messages: List[Dict[str, Any]] = []
|
||||
# Responses encrypted reasoning replay state. Some OpenAI-compatible
|
||||
# routes accept GPT-5 Responses requests but later reject replayed
|
||||
# encrypted reasoning blobs (HTTP 400 ``invalid_encrypted_content``).
|
||||
# When that happens we disable replay for the rest of the session and
|
||||
# fall back to stateless continuity. See
|
||||
# agent/conversation_loop.py's invalid_encrypted_content retry branch.
|
||||
agent._codex_reasoning_replay_enabled = True
|
||||
agent._memory_write_origin = "assistant_tool"
|
||||
agent._memory_write_context = "foreground"
|
||||
|
||||
@@ -1121,8 +1112,6 @@ def init_agent(
|
||||
# Thread gateway user identity for per-user memory scoping
|
||||
if agent._user_id:
|
||||
_init_kwargs["user_id"] = agent._user_id
|
||||
if agent._user_id_alt:
|
||||
_init_kwargs["user_id_alt"] = agent._user_id_alt
|
||||
if agent._user_name:
|
||||
_init_kwargs["user_name"] = agent._user_name
|
||||
if agent._chat_id:
|
||||
|
||||
@@ -560,24 +560,6 @@ def recover_with_credential_pool(
|
||||
if pool is None:
|
||||
return False, has_retried_429
|
||||
|
||||
# Defensive guard: if a fallback provider is active and its provider name
|
||||
# doesn't match the pool's provider, the pool belongs to the PRIMARY
|
||||
# provider. Mutating it based on fallback errors would corrupt the
|
||||
# primary's credential state (see #33088) and, via _swap_credential,
|
||||
# overwrite the agent's base_url back to the primary's endpoint — every
|
||||
# subsequent request then goes to the wrong host and 404s (see #33163).
|
||||
# The pool should only act when the agent is still on the same provider
|
||||
# that seeded the pool.
|
||||
current_provider = (getattr(agent, "provider", "") or "").strip().lower()
|
||||
pool_provider = (getattr(pool, "provider", "") or "").strip().lower()
|
||||
if current_provider and pool_provider and current_provider != pool_provider:
|
||||
_ra().logger.warning(
|
||||
"Credential pool provider mismatch: pool=%s, agent=%s — "
|
||||
"skipping pool mutation to avoid cross-provider contamination",
|
||||
pool_provider, current_provider,
|
||||
)
|
||||
return False, has_retried_429
|
||||
|
||||
effective_reason = classified_reason
|
||||
if effective_reason is None:
|
||||
if status_code == 402:
|
||||
@@ -1379,129 +1361,81 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
|
||||
old_model = agent.model
|
||||
old_provider = agent.provider
|
||||
|
||||
# ── Snapshot all fields the swap+rebuild can mutate ──
|
||||
# If the rebuild raises (bad API key, network error, build_anthropic_client
|
||||
# failure, etc.) we restore these atomically so the agent isn't left with a
|
||||
# new model/provider name paired with the OLD client — that mismatch causes
|
||||
# HTTP 400s like "claude-sonnet-4-6 is not supported on openai-codex" on the
|
||||
# next turn. Callers in cli.py / gateway/run.py / tui_gateway/server.py
|
||||
# catch the re-raised exception and show the user a warning; without this
|
||||
# rollback the warning is misleading because the swap partially succeeded.
|
||||
# Use a sentinel so we can distinguish "attribute was unset" from
|
||||
# "attribute was None" and skip the restore for genuinely-missing
|
||||
# attributes (tests construct bare agents via __new__ without all fields).
|
||||
_MISSING = object()
|
||||
_snapshot = {
|
||||
name: getattr(agent, name, _MISSING)
|
||||
for name in (
|
||||
"model",
|
||||
"provider",
|
||||
"base_url",
|
||||
"api_mode",
|
||||
"api_key",
|
||||
"client",
|
||||
"_anthropic_client",
|
||||
"_anthropic_api_key",
|
||||
"_anthropic_base_url",
|
||||
"_is_anthropic_oauth",
|
||||
"_config_context_length",
|
||||
# Clear the per-config context_length override so the new model's
|
||||
# actual context window is resolved via get_model_context_length()
|
||||
# instead of inheriting the stale value from the previous model.
|
||||
agent._config_context_length = None
|
||||
|
||||
# ── Swap core runtime fields ──
|
||||
agent.model = new_model
|
||||
agent.provider = new_provider
|
||||
# Use new base_url when provided; only fall back to current when the
|
||||
# new provider genuinely has no endpoint (e.g. native SDK providers).
|
||||
# Without this guard the old provider's URL (e.g. Ollama's localhost
|
||||
# address) would persist silently after switching to a cloud provider
|
||||
# that returns an empty base_url string.
|
||||
if base_url:
|
||||
agent.base_url = base_url
|
||||
agent.api_mode = api_mode
|
||||
# Invalidate transport cache — new api_mode may need a different transport
|
||||
if hasattr(agent, "_transport_cache"):
|
||||
agent._transport_cache.clear()
|
||||
if api_key:
|
||||
agent.api_key = api_key
|
||||
|
||||
# ── Build new client ──
|
||||
if api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import (
|
||||
build_anthropic_client,
|
||||
resolve_anthropic_token,
|
||||
_is_oauth_token,
|
||||
)
|
||||
}
|
||||
# _client_kwargs is a dict — snapshot a shallow copy so mutating the
|
||||
# live dict doesn't poison the rollback target.
|
||||
_snapshot["_client_kwargs"] = dict(getattr(agent, "_client_kwargs", {}) or {})
|
||||
# Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic.
|
||||
# Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own
|
||||
# API key — falling back would send Anthropic credentials to third-party endpoints.
|
||||
_is_native_anthropic = new_provider == "anthropic"
|
||||
effective_key = (api_key or agent.api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or agent.api_key or "")
|
||||
|
||||
try:
|
||||
# Clear the per-config context_length override so the new model's
|
||||
# actual context window is resolved via get_model_context_length()
|
||||
# instead of inheriting the stale value from the previous model.
|
||||
agent._config_context_length = None
|
||||
|
||||
# ── Swap core runtime fields ──
|
||||
agent.model = new_model
|
||||
agent.provider = new_provider
|
||||
# Use new base_url when provided; only fall back to current when the
|
||||
# new provider genuinely has no endpoint (e.g. native SDK providers).
|
||||
# Without this guard the old provider's URL (e.g. Ollama's localhost
|
||||
# address) would persist silently after switching to a cloud provider
|
||||
# that returns an empty base_url string.
|
||||
if base_url:
|
||||
agent.base_url = base_url
|
||||
agent.api_mode = api_mode
|
||||
# Invalidate transport cache — new api_mode may need a different transport
|
||||
if hasattr(agent, "_transport_cache"):
|
||||
agent._transport_cache.clear()
|
||||
if api_key:
|
||||
agent.api_key = api_key
|
||||
|
||||
# ── Build new client ──
|
||||
if api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import (
|
||||
build_anthropic_client,
|
||||
resolve_anthropic_token,
|
||||
_is_oauth_token,
|
||||
)
|
||||
# Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic.
|
||||
# Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own
|
||||
# API key — falling back would send Anthropic credentials to third-party endpoints.
|
||||
_is_native_anthropic = new_provider == "anthropic"
|
||||
effective_key = (api_key or agent.api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or agent.api_key or "")
|
||||
|
||||
# MiniMax OAuth: swap static string for a per-request callable token
|
||||
# provider so the rebuilt client survives 15-min token expiry. See
|
||||
# the matching block in agent_init.py for the full rationale.
|
||||
if new_provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key:
|
||||
try:
|
||||
from hermes_cli.auth import build_minimax_oauth_token_provider
|
||||
effective_key = build_minimax_oauth_token_provider()
|
||||
except Exception as _mm_exc: # noqa: BLE001
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning(
|
||||
"MiniMax OAuth: failed to install per-request token provider "
|
||||
"on switch (%s); using static bearer.",
|
||||
_mm_exc,
|
||||
)
|
||||
|
||||
agent.api_key = effective_key
|
||||
agent._anthropic_api_key = effective_key
|
||||
agent._anthropic_base_url = base_url or getattr(agent, "_anthropic_base_url", None)
|
||||
agent._anthropic_client = build_anthropic_client(
|
||||
effective_key, agent._anthropic_base_url,
|
||||
timeout=get_provider_request_timeout(agent.provider, agent.model),
|
||||
)
|
||||
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False
|
||||
agent.client = None
|
||||
agent._client_kwargs = {}
|
||||
else:
|
||||
effective_key = api_key or agent.api_key
|
||||
effective_base = base_url or agent.base_url
|
||||
agent._client_kwargs = {
|
||||
"api_key": effective_key,
|
||||
"base_url": effective_base,
|
||||
}
|
||||
_sm_timeout = get_provider_request_timeout(agent.provider, agent.model)
|
||||
if _sm_timeout is not None:
|
||||
agent._client_kwargs["timeout"] = _sm_timeout
|
||||
agent.client = agent._create_openai_client(
|
||||
dict(agent._client_kwargs),
|
||||
reason="switch_model",
|
||||
shared=True,
|
||||
)
|
||||
except Exception:
|
||||
# Rollback every mutated field to the pre-swap snapshot so the agent
|
||||
# is left consistent (old model + old provider + old client) and the
|
||||
# caller's exception handler can surface a meaningful warning. The
|
||||
# exception is re-raised; cli.py / gateway/run.py / tui_gateway catch
|
||||
# it and print "Agent swap failed; change applied to next session".
|
||||
for _name, _value in _snapshot.items():
|
||||
if _value is _MISSING:
|
||||
# Attribute did not exist before the swap — don't fabricate it.
|
||||
continue
|
||||
# MiniMax OAuth: swap static string for a per-request callable token
|
||||
# provider so the rebuilt client survives 15-min token expiry. See
|
||||
# the matching block in agent_init.py for the full rationale.
|
||||
if new_provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key:
|
||||
try:
|
||||
setattr(agent, _name, _value)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
raise
|
||||
from hermes_cli.auth import build_minimax_oauth_token_provider
|
||||
effective_key = build_minimax_oauth_token_provider()
|
||||
except Exception as _mm_exc: # noqa: BLE001
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).warning(
|
||||
"MiniMax OAuth: failed to install per-request token provider "
|
||||
"on switch (%s); using static bearer.",
|
||||
_mm_exc,
|
||||
)
|
||||
|
||||
agent.api_key = effective_key
|
||||
agent._anthropic_api_key = effective_key
|
||||
agent._anthropic_base_url = base_url or getattr(agent, "_anthropic_base_url", None)
|
||||
agent._anthropic_client = build_anthropic_client(
|
||||
effective_key, agent._anthropic_base_url,
|
||||
timeout=get_provider_request_timeout(agent.provider, agent.model),
|
||||
)
|
||||
agent._is_anthropic_oauth = _is_oauth_token(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False
|
||||
agent.client = None
|
||||
agent._client_kwargs = {}
|
||||
else:
|
||||
effective_key = api_key or agent.api_key
|
||||
effective_base = base_url or agent.base_url
|
||||
agent._client_kwargs = {
|
||||
"api_key": effective_key,
|
||||
"base_url": effective_base,
|
||||
}
|
||||
_sm_timeout = get_provider_request_timeout(agent.provider, agent.model)
|
||||
if _sm_timeout is not None:
|
||||
agent._client_kwargs["timeout"] = _sm_timeout
|
||||
agent.client = agent._create_openai_client(
|
||||
dict(agent._client_kwargs),
|
||||
reason="switch_model",
|
||||
shared=True,
|
||||
)
|
||||
|
||||
# ── Re-evaluate prompt caching ──
|
||||
agent._use_prompt_caching, agent._use_native_cache_layout = (
|
||||
|
||||
@@ -269,6 +269,7 @@ _API_KEY_PROVIDER_AUX_MODELS_FALLBACK: Dict[str, str] = {
|
||||
"minimax-oauth": "MiniMax-M2.7-highspeed",
|
||||
"minimax-cn": "MiniMax-M2.7",
|
||||
"anthropic": "claude-haiku-4-5-20251001",
|
||||
"ai-gateway": "google/gemini-3-flash",
|
||||
"opencode-zen": "gemini-3-flash",
|
||||
"opencode-go": "glm-5",
|
||||
"kilocode": "google/gemini-3-flash-preview",
|
||||
@@ -383,6 +384,15 @@ def build_nvidia_nim_headers(base_url: str | None) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
# Vercel AI Gateway app attribution headers. HTTP-Referer maps to
|
||||
# referrerUrl and X-Title maps to appName in the gateway's analytics.
|
||||
from hermes_cli import __version__ as _HERMES_VERSION
|
||||
|
||||
_AI_GATEWAY_HEADERS = {
|
||||
"HTTP-Referer": "https://hermes-agent.nousresearch.com",
|
||||
"X-Title": "Hermes Agent",
|
||||
"User-Agent": f"HermesAgent/{_HERMES_VERSION}",
|
||||
}
|
||||
|
||||
# Nous Portal extra_body for product attribution.
|
||||
# Callers should pass this as extra_body in chat.completions.create()
|
||||
@@ -775,60 +785,67 @@ class _CodexCompletionsAdapter:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Collect output items and text deltas during streaming —
|
||||
# the Codex backend can return empty response.output from
|
||||
# get_final_response() even when items were streamed.
|
||||
collected_output_items: List[Any] = []
|
||||
collected_text_deltas: List[str] = []
|
||||
has_function_calls = False
|
||||
if total_timeout:
|
||||
timeout_timer = threading.Timer(float(total_timeout), _close_client_on_timeout)
|
||||
timeout_timer.daemon = True
|
||||
timeout_timer.start()
|
||||
_check_cancelled()
|
||||
|
||||
# Event-driven Responses streaming via the low-level
|
||||
# ``responses.create(stream=True)`` path. The high-level
|
||||
# ``responses.stream(...)`` helper does post-hoc typed
|
||||
# reconstruction from ``response.completed.response.output``,
|
||||
# which the chatgpt.com Codex backend has been observed to
|
||||
# return as ``null`` (gpt-5.5, May 2026) — that crashes the SDK
|
||||
# with ``TypeError: 'NoneType' object is not iterable``.
|
||||
# Consuming raw events and assembling the final response
|
||||
# ourselves from ``response.output_item.done`` makes us
|
||||
# structurally immune to that drift.
|
||||
from agent.codex_runtime import _consume_codex_event_stream
|
||||
|
||||
stream_kwargs = dict(resp_kwargs)
|
||||
stream_kwargs["stream"] = True
|
||||
|
||||
def _on_each_event(_event: Any) -> None:
|
||||
# Re-check timeout/cancellation per event, matching the
|
||||
# cadence the old in-line ``_check_cancelled()`` used.
|
||||
with self._client.responses.stream(**resp_kwargs) as stream:
|
||||
for _event in stream:
|
||||
_check_cancelled()
|
||||
_etype = getattr(_event, "type", "")
|
||||
if _etype == "response.output_item.done":
|
||||
_done = getattr(_event, "item", None)
|
||||
if _done is not None:
|
||||
collected_output_items.append(_done)
|
||||
elif "output_text.delta" in _etype:
|
||||
_delta = getattr(_event, "delta", "")
|
||||
if _delta:
|
||||
collected_text_deltas.append(_delta)
|
||||
elif "function_call" in _etype:
|
||||
has_function_calls = True
|
||||
_check_cancelled()
|
||||
final = stream.get_final_response()
|
||||
|
||||
event_stream = self._client.responses.create(**stream_kwargs)
|
||||
try:
|
||||
final = _consume_codex_event_stream(
|
||||
event_stream,
|
||||
model=resp_kwargs.get("model"),
|
||||
on_event=_on_each_event,
|
||||
)
|
||||
finally:
|
||||
close_fn = getattr(event_stream, "close", None)
|
||||
if callable(close_fn):
|
||||
try:
|
||||
close_fn()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if final is None:
|
||||
raise RuntimeError("Codex auxiliary Responses stream did not return a final response")
|
||||
# Backfill empty output from collected stream events
|
||||
_output = getattr(final, "output", None)
|
||||
if isinstance(_output, list) and not _output:
|
||||
if collected_output_items:
|
||||
final.output = list(collected_output_items)
|
||||
logger.debug(
|
||||
"Codex auxiliary: backfilled %d output items from stream events",
|
||||
len(collected_output_items),
|
||||
)
|
||||
elif collected_text_deltas and not has_function_calls:
|
||||
# Only synthesize text when no tool calls were streamed —
|
||||
# a function_call response with incidental text should not
|
||||
# be collapsed into a plain-text message.
|
||||
assembled = "".join(collected_text_deltas)
|
||||
final.output = [SimpleNamespace(
|
||||
type="message", role="assistant", status="completed",
|
||||
content=[SimpleNamespace(type="output_text", text=assembled)],
|
||||
)]
|
||||
logger.debug(
|
||||
"Codex auxiliary: synthesized from %d deltas (%d chars)",
|
||||
len(collected_text_deltas), len(assembled),
|
||||
)
|
||||
|
||||
# Extract text and tool calls from the Responses output.
|
||||
# Items may be SimpleNamespace (raw-event path) or dicts
|
||||
# (some legacy fallback paths), so handle both shapes.
|
||||
# Items may be SDK objects (attrs) or dicts (raw/fallback paths),
|
||||
# so use a helper that handles both shapes.
|
||||
def _item_get(obj: Any, key: str, default: Any = None) -> Any:
|
||||
val = getattr(obj, key, None)
|
||||
if val is None and isinstance(obj, dict):
|
||||
val = obj.get(key, default)
|
||||
return val if val is not None else default
|
||||
|
||||
for item in (getattr(final, "output", None) or []):
|
||||
for item in getattr(final, "output", []):
|
||||
item_type = _item_get(item, "type")
|
||||
if item_type == "message":
|
||||
for part in (_item_get(item, "content") or []):
|
||||
@@ -848,12 +865,9 @@ class _CodexCompletionsAdapter:
|
||||
resp_usage = getattr(final, "usage", None)
|
||||
if resp_usage:
|
||||
usage = SimpleNamespace(
|
||||
prompt_tokens=getattr(resp_usage, "input_tokens", 0)
|
||||
or (resp_usage.get("input_tokens", 0) if isinstance(resp_usage, dict) else 0),
|
||||
completion_tokens=getattr(resp_usage, "output_tokens", 0)
|
||||
or (resp_usage.get("output_tokens", 0) if isinstance(resp_usage, dict) else 0),
|
||||
total_tokens=getattr(resp_usage, "total_tokens", 0)
|
||||
or (resp_usage.get("total_tokens", 0) if isinstance(resp_usage, dict) else 0),
|
||||
prompt_tokens=getattr(resp_usage, "input_tokens", 0),
|
||||
completion_tokens=getattr(resp_usage, "output_tokens", 0),
|
||||
total_tokens=getattr(resp_usage, "total_tokens", 0),
|
||||
)
|
||||
except Exception as exc:
|
||||
if timed_out.is_set():
|
||||
@@ -3599,7 +3613,8 @@ def resolve_provider_client(
|
||||
else:
|
||||
# Fall back to profile.default_headers for providers that declare
|
||||
# client-level attribution headers on their profile (e.g. GMI
|
||||
# User-Agent for traffic identification).
|
||||
# User-Agent for traffic identification, Vercel AI Gateway
|
||||
# Referer/Title for analytics).
|
||||
try:
|
||||
from providers import get_provider_profile as _gpf_main
|
||||
_ph_main = _gpf_main(provider)
|
||||
|
||||
@@ -129,24 +129,6 @@ def estimate_request_context_tokens(api_payload: Any) -> int:
|
||||
return _chars(api_payload) // 4
|
||||
|
||||
|
||||
def _is_openai_codex_backend(agent) -> bool:
|
||||
base_url_lower = str(getattr(agent, "_base_url_lower", "") or "")
|
||||
base_url_hostname = str(getattr(agent, "_base_url_hostname", "") or "")
|
||||
return (
|
||||
getattr(agent, "provider", None) == "openai-codex"
|
||||
or (
|
||||
base_url_hostname == "chatgpt.com"
|
||||
and "/backend-api/codex" in base_url_lower
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _env_float(name: str, default: float) -> float:
|
||||
try:
|
||||
return float(os.getenv(name, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def interruptible_api_call(agent, api_kwargs: dict):
|
||||
"""
|
||||
@@ -274,89 +256,32 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
# apply richer recovery (credential rotation, provider fallback).
|
||||
_stale_timeout = agent._compute_non_stream_stale_timeout(api_kwargs)
|
||||
|
||||
# ── Codex Responses stream watchdogs ────────────────────────────────
|
||||
# ── Time-to-first-byte (TTFB) watchdog for the Codex Responses stream ──
|
||||
# The chatgpt.com/backend-api/codex endpoint has an intermittent failure
|
||||
# mode where it accepts the connection but never emits a single stream
|
||||
# event (observed directly: 0 events, no HTTP status, the socket just
|
||||
# hangs). A fresh reconnect succeeds in ~2s, but the wall-clock stale
|
||||
# timeout (often 180–900s) makes us wait minutes before retrying. While no
|
||||
# stream event has arrived yet we apply a much shorter TTFB cutoff so the
|
||||
# main retry loop can reconnect promptly. Large subscription-backed Codex
|
||||
# requests can legitimately spend tens of seconds in backend admission /
|
||||
# prompt prefill before the first SSE event, so the no-byte TTFB watchdog
|
||||
# is disabled for large chatgpt.com/backend-api/codex requests. A second
|
||||
# failure mode emits an opening SSE frame and then stalls forever in SSL
|
||||
# read; for that we watch the gap since the last Codex stream event. This
|
||||
# matches Codex CLI's stream_idle_timeout model: any valid SSE event is
|
||||
# activity. Operators can tune via HERMES_CODEX_TTFB_TIMEOUT_SECONDS and
|
||||
# HERMES_CODEX_EVENT_STALE_TIMEOUT_SECONDS (0 disables each).
|
||||
_codex_watchdog_enabled = agent.api_mode == "codex_responses"
|
||||
_openai_codex_backend = _is_openai_codex_backend(agent)
|
||||
_est_tokens_for_codex_watchdog = estimate_request_context_tokens(api_kwargs)
|
||||
if _codex_watchdog_enabled and _openai_codex_backend:
|
||||
if _est_tokens_for_codex_watchdog > 100_000:
|
||||
_stale_timeout = max(_stale_timeout, 1200.0)
|
||||
elif _est_tokens_for_codex_watchdog > 50_000:
|
||||
_stale_timeout = max(_stale_timeout, 900.0)
|
||||
elif _est_tokens_for_codex_watchdog > 25_000:
|
||||
_stale_timeout = max(_stale_timeout, 600.0)
|
||||
|
||||
if _est_tokens_for_codex_watchdog > 100_000:
|
||||
_codex_idle_timeout_default = 180.0
|
||||
elif _est_tokens_for_codex_watchdog > 50_000:
|
||||
_codex_idle_timeout_default = 120.0
|
||||
elif _est_tokens_for_codex_watchdog > 10_000:
|
||||
_codex_idle_timeout_default = 60.0
|
||||
else:
|
||||
_codex_idle_timeout_default = 12.0
|
||||
|
||||
_ttfb_enabled = _codex_watchdog_enabled
|
||||
_ttfb_timeout = _env_float("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", 12.0)
|
||||
# main retry loop can reconnect promptly. Once the first event arrives the
|
||||
# stream is healthy, so we fall back to the wall-clock stale timeout and
|
||||
# never interrupt a legitimate long generation. Gated to codex_responses:
|
||||
# only that path streams events incrementally (the chat_completions
|
||||
# non-stream, anthropic and bedrock branches here have no first-event
|
||||
# signal). The marker advances on *any* event (see codex_runtime), so
|
||||
# reasoning-only / tool-call-only turns are not mistaken for a stall.
|
||||
# Operators can tune via HERMES_CODEX_TTFB_TIMEOUT_SECONDS (0 disables).
|
||||
_ttfb_enabled = agent.api_mode == "codex_responses"
|
||||
try:
|
||||
_ttfb_timeout = float(os.getenv("HERMES_CODEX_TTFB_TIMEOUT_SECONDS", "45"))
|
||||
except (TypeError, ValueError):
|
||||
_ttfb_timeout = 45.0
|
||||
if _ttfb_timeout <= 0:
|
||||
_ttfb_enabled = False
|
||||
elif _openai_codex_backend:
|
||||
_ttfb_disable_above = _env_float("HERMES_CODEX_TTFB_DISABLE_ABOVE_TOKENS", 25_000.0)
|
||||
_ttfb_strict = os.environ.get("HERMES_CODEX_TTFB_STRICT", "").strip().lower() in {
|
||||
"1", "true", "yes", "on"
|
||||
}
|
||||
if (
|
||||
not _ttfb_strict
|
||||
and _ttfb_disable_above > 0
|
||||
and _est_tokens_for_codex_watchdog >= _ttfb_disable_above
|
||||
):
|
||||
_ttfb_enabled = False
|
||||
logger.info(
|
||||
"Disabling openai-codex no-byte TTFB watchdog for large request "
|
||||
"(context=~%s tokens >= %.0f). Waiting for backend response instead. "
|
||||
"Set HERMES_CODEX_TTFB_STRICT=1 to force early reconnects.",
|
||||
f"{_est_tokens_for_codex_watchdog:,}",
|
||||
_ttfb_disable_above,
|
||||
)
|
||||
else:
|
||||
_ttfb_cap = _env_float("HERMES_CODEX_TTFB_MAX_SECONDS", 20.0)
|
||||
if _ttfb_cap > 0 and _ttfb_timeout > _ttfb_cap:
|
||||
logger.info(
|
||||
"Capping openai-codex no-byte TTFB timeout from %.0fs to %.0fs "
|
||||
"(context=~%s tokens). Set HERMES_CODEX_TTFB_MAX_SECONDS to tune.",
|
||||
_ttfb_timeout,
|
||||
_ttfb_cap,
|
||||
f"{_est_tokens_for_codex_watchdog:,}",
|
||||
)
|
||||
_ttfb_timeout = _ttfb_cap
|
||||
|
||||
_codex_idle_enabled = _codex_watchdog_enabled
|
||||
_codex_idle_timeout = _env_float(
|
||||
"HERMES_CODEX_EVENT_STALE_TIMEOUT_SECONDS",
|
||||
_codex_idle_timeout_default,
|
||||
)
|
||||
if _codex_idle_timeout <= 0:
|
||||
_codex_idle_enabled = False
|
||||
|
||||
if _codex_watchdog_enabled:
|
||||
if _ttfb_enabled:
|
||||
# Reset before the worker starts so a marker left over from a previous
|
||||
# call on this agent can't be misread as first-byte for this one.
|
||||
agent._codex_stream_last_event_ts = None
|
||||
agent._codex_stream_last_progress_ts = None
|
||||
|
||||
_call_start = time.time()
|
||||
agent._touch_activity("waiting for non-streaming API response")
|
||||
@@ -388,13 +313,6 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
and _elapsed > _ttfb_timeout
|
||||
and getattr(agent, "_codex_stream_last_event_ts", None) is None
|
||||
):
|
||||
_silent_hint: Optional[str] = None
|
||||
_hint_fn = getattr(agent, "_codex_silent_hang_hint", None)
|
||||
if callable(_hint_fn):
|
||||
try:
|
||||
_silent_hint = _hint_fn(model=api_kwargs.get("model"))
|
||||
except Exception:
|
||||
_silent_hint = None
|
||||
logger.warning(
|
||||
"Codex stream produced no bytes within TTFB cutoff "
|
||||
"(%.0fs > %.0fs, model=%s). Backend accepted the connection "
|
||||
@@ -402,18 +320,11 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
"loop can reconnect.",
|
||||
_elapsed, _ttfb_timeout, api_kwargs.get("model", "unknown"),
|
||||
)
|
||||
if _silent_hint:
|
||||
agent._emit_status(
|
||||
f"⚠️ No first byte from provider in {int(_elapsed)}s "
|
||||
f"(codex stream, model: {api_kwargs.get('model', 'unknown')}). "
|
||||
f"Reconnecting. {_silent_hint}"
|
||||
)
|
||||
else:
|
||||
agent._emit_status(
|
||||
f"⚠️ No first byte from provider in {int(_elapsed)}s "
|
||||
f"(codex stream, model: {api_kwargs.get('model', 'unknown')}). "
|
||||
f"Reconnecting."
|
||||
)
|
||||
agent._emit_status(
|
||||
f"⚠️ No first byte from provider in {int(_elapsed)}s "
|
||||
f"(codex stream, model: {api_kwargs.get('model', 'unknown')}). "
|
||||
f"Reconnecting."
|
||||
)
|
||||
try:
|
||||
_close_request_client_once("codex_ttfb_kill")
|
||||
except Exception:
|
||||
@@ -423,55 +334,10 @@ def interruptible_api_call(agent, api_kwargs: dict):
|
||||
)
|
||||
# Wait briefly for the worker to notice the closed connection.
|
||||
t.join(timeout=2.0)
|
||||
if result["error"] is None and result["response"] is None:
|
||||
if _silent_hint:
|
||||
result["error"] = TimeoutError(
|
||||
f"Codex stream produced no bytes within {int(_elapsed)}s "
|
||||
f"(TTFB threshold: {int(_ttfb_timeout)}s). {_silent_hint}"
|
||||
)
|
||||
else:
|
||||
result["error"] = TimeoutError(
|
||||
f"Codex stream produced no bytes within {int(_elapsed)}s "
|
||||
f"(TTFB threshold: {int(_ttfb_timeout)}s)"
|
||||
)
|
||||
break
|
||||
|
||||
# Stream-idle detector: the Codex backend emitted at least one SSE
|
||||
# frame, then stopped emitting events. Valid keepalive / in_progress
|
||||
# frames refresh _codex_stream_last_event_ts and should not be killed.
|
||||
_last_codex_event_ts = getattr(agent, "_codex_stream_last_event_ts", None)
|
||||
if (
|
||||
_codex_idle_enabled
|
||||
and _last_codex_event_ts is not None
|
||||
and (time.time() - _last_codex_event_ts) > _codex_idle_timeout
|
||||
):
|
||||
_event_stale_elapsed = time.time() - _last_codex_event_ts
|
||||
logger.warning(
|
||||
"Codex stream produced no SSE events for %.0fs after first byte "
|
||||
"(threshold %.0fs, model=%s, context=~%s tokens). Killing "
|
||||
"connection so the retry loop can reconnect.",
|
||||
_event_stale_elapsed,
|
||||
_codex_idle_timeout,
|
||||
api_kwargs.get("model", "unknown"),
|
||||
f"{_est_tokens_for_codex_watchdog:,}",
|
||||
)
|
||||
agent._emit_status(
|
||||
f"⚠️ Codex stream sent no events for {int(_event_stale_elapsed)}s "
|
||||
f"after first byte (model: {api_kwargs.get('model', 'unknown')}). "
|
||||
f"Reconnecting."
|
||||
)
|
||||
try:
|
||||
_close_request_client_once("codex_stream_idle_kill")
|
||||
except Exception:
|
||||
pass
|
||||
agent._touch_activity(
|
||||
f"codex stream killed after {int(_event_stale_elapsed)}s with no SSE events"
|
||||
)
|
||||
t.join(timeout=2.0)
|
||||
if result["error"] is None and result["response"] is None:
|
||||
result["error"] = TimeoutError(
|
||||
f"Codex stream produced no SSE events for {int(_event_stale_elapsed)}s "
|
||||
f"after first byte (threshold: {int(_codex_idle_timeout)}s)"
|
||||
f"Codex stream produced no bytes within {int(_elapsed)}s "
|
||||
f"(TTFB threshold: {int(_ttfb_timeout)}s)"
|
||||
)
|
||||
break
|
||||
|
||||
@@ -641,9 +507,6 @@ def build_api_kwargs(agent, api_messages: list) -> dict:
|
||||
is_codex_backend=is_codex_backend,
|
||||
is_xai_responses=is_xai_responses,
|
||||
github_reasoning_extra=agent._github_models_reasoning_extra_body() if is_github_responses else None,
|
||||
replay_encrypted_reasoning=bool(
|
||||
getattr(agent, "_codex_reasoning_replay_enabled", True)
|
||||
),
|
||||
)
|
||||
|
||||
# ── chat_completions (default) ─────────────────────────────────────
|
||||
@@ -1156,25 +1019,6 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
|
||||
agent._transport_cache.clear()
|
||||
agent._fallback_activated = True
|
||||
|
||||
# Clear the credential pool when the fallback provider doesn't match
|
||||
# the pool's provider. The pool was seeded for the primary provider;
|
||||
# leaving it attached means downstream recovery (rate_limit / billing /
|
||||
# auth) calls ``_swap_credential`` with a primary entry which overwrites
|
||||
# the agent's ``base_url`` back to the primary's endpoint — every
|
||||
# fallback request then 404s against the wrong host. See #33163.
|
||||
# When the fallback shares the pool's provider (e.g. both openrouter
|
||||
# entries with different routing) the pool is preserved.
|
||||
_existing_pool = getattr(agent, "_credential_pool", None)
|
||||
if _existing_pool is not None:
|
||||
_pool_provider = (getattr(_existing_pool, "provider", "") or "").strip().lower()
|
||||
if _pool_provider and _pool_provider != fb_provider:
|
||||
logger.info(
|
||||
"Fallback to %s/%s: clearing primary credential pool "
|
||||
"(pool_provider=%s) to prevent cross-provider contamination",
|
||||
fb_provider, fb_model, _pool_provider,
|
||||
)
|
||||
agent._credential_pool = None
|
||||
|
||||
# Honor per-provider / per-model request_timeout_seconds for the
|
||||
# fallback target (same knob the primary client uses). None = use
|
||||
# SDK default.
|
||||
|
||||
@@ -23,38 +23,6 @@ from agent.prompt_builder import DEFAULT_AGENT_IDENTITY
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _classify_responses_issuer(
|
||||
*,
|
||||
is_xai_responses: bool = False,
|
||||
is_github_responses: bool = False,
|
||||
is_codex_backend: bool = False,
|
||||
base_url: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Stable identifier for the Responses endpoint that mints encrypted_content.
|
||||
|
||||
``reasoning.encrypted_content`` is sealed to the endpoint that issued it:
|
||||
replaying a Codex-minted blob against xAI (or vice versa) deterministically
|
||||
returns HTTP 400 ``invalid_encrypted_content``. Stamping the issuer on
|
||||
persisted reasoning items and filtering at replay time lets a single
|
||||
conversation switch models without poisoning history with un-decryptable
|
||||
reasoning blocks.
|
||||
"""
|
||||
if is_xai_responses:
|
||||
return "xai_responses"
|
||||
if is_github_responses:
|
||||
return "github_responses"
|
||||
if is_codex_backend:
|
||||
return "codex_backend"
|
||||
if base_url:
|
||||
return f"other:{base_url}"
|
||||
return "other"
|
||||
|
||||
|
||||
# Throttle the per-process cross-issuer skip warning so we don't flood logs
|
||||
# when a long history contains many stale-issuer reasoning blocks.
|
||||
_CROSS_ISSUER_WARN_EMITTED = False
|
||||
|
||||
|
||||
# Matches Codex/Harmony tool-call serialization that occasionally leaks into
|
||||
# assistant-message content when the model fails to emit a structured
|
||||
# ``function_call`` item. Accepts the common forms:
|
||||
@@ -280,8 +248,6 @@ def _chat_messages_to_responses_input(
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
is_xai_responses: bool = False,
|
||||
replay_encrypted_reasoning: bool = True,
|
||||
current_issuer_kind: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Convert internal chat-style messages to Responses input items.
|
||||
|
||||
@@ -295,27 +261,6 @@ def _chat_messages_to_responses_input(
|
||||
integration). We now replay encrypted reasoning on every Responses
|
||||
transport (xAI, native Codex, custom relays) and let xAI tell us
|
||||
explicitly if a specific surface ever rejects a payload.
|
||||
|
||||
``replay_encrypted_reasoning`` is the per-session kill switch. Some
|
||||
OpenAI-compatible relays accept the request but later reject the
|
||||
replayed encrypted blob with HTTP 400 ``invalid_encrypted_content``;
|
||||
when that happens the retry loop calls
|
||||
``AIAgent._disable_codex_reasoning_replay`` which both strips cached
|
||||
items from the conversation history and threads ``replay_enabled=False``
|
||||
through this converter so subsequent turns send no reasoning items.
|
||||
|
||||
``current_issuer_kind`` enables a per-item cross-issuer guard. The
|
||||
Responses API's ``encrypted_content`` blob is decryptable only by the
|
||||
endpoint that minted it — replaying a Codex-issued blob against xAI
|
||||
(or vice versa) always yields HTTP 400 ``invalid_encrypted_content``
|
||||
and breaks every subsequent turn in the same session. When this
|
||||
argument is provided and a reasoning item carries an ``_issuer_kind``
|
||||
stamp from a different endpoint, the item is dropped from the replayed
|
||||
input. Legacy items without a stamp are still replayed
|
||||
(backwards-compatible). The two guards compose:
|
||||
``replay_encrypted_reasoning=False`` is the session-wide kill switch
|
||||
(drops ALL replay); ``current_issuer_kind`` is the per-item filter
|
||||
that runs only when replay is still enabled.
|
||||
"""
|
||||
items: List[Dict[str, Any]] = []
|
||||
seen_item_ids: set = set()
|
||||
@@ -345,11 +290,7 @@ def _chat_messages_to_responses_input(
|
||||
# This applies to every Responses transport including
|
||||
# xAI — see _chat_messages_to_responses_input docstring
|
||||
# for the May 2026 reversal of the earlier xAI gate.
|
||||
codex_reasoning = (
|
||||
msg.get("codex_reasoning_items")
|
||||
if replay_encrypted_reasoning
|
||||
else None
|
||||
)
|
||||
codex_reasoning = msg.get("codex_reasoning_items")
|
||||
has_codex_reasoning = False
|
||||
if isinstance(codex_reasoning, list):
|
||||
for ri in codex_reasoning:
|
||||
@@ -357,40 +298,11 @@ def _chat_messages_to_responses_input(
|
||||
item_id = ri.get("id")
|
||||
if item_id and item_id in seen_item_ids:
|
||||
continue
|
||||
# Cross-issuer guard: drop reasoning blocks that
|
||||
# were minted by a different Responses endpoint.
|
||||
# The current endpoint cannot decrypt foreign
|
||||
# encrypted_content and would reject the whole
|
||||
# request with HTTP 400 invalid_encrypted_content.
|
||||
# Unstamped (legacy) items pass through.
|
||||
item_issuer = ri.get("_issuer_kind")
|
||||
if (
|
||||
current_issuer_kind is not None
|
||||
and item_issuer is not None
|
||||
and item_issuer != current_issuer_kind
|
||||
):
|
||||
global _CROSS_ISSUER_WARN_EMITTED
|
||||
if not _CROSS_ISSUER_WARN_EMITTED:
|
||||
logger.warning(
|
||||
"Dropping reasoning item minted by %s while "
|
||||
"calling %s — encrypted_content is sealed to "
|
||||
"its issuer. This happens when a session "
|
||||
"switches model providers mid-conversation.",
|
||||
item_issuer, current_issuer_kind,
|
||||
)
|
||||
_CROSS_ISSUER_WARN_EMITTED = True
|
||||
continue
|
||||
# Strip the "id" field — with store=False the
|
||||
# Responses API cannot look up items by ID and
|
||||
# returns 404. The encrypted_content blob is
|
||||
# self-contained for reasoning chain continuity.
|
||||
# Also strip the internal "_issuer_kind" stamp;
|
||||
# it is a Hermes-side metadata key and not part
|
||||
# of the Responses API schema.
|
||||
replay_item = {
|
||||
k: v for k, v in ri.items()
|
||||
if k not in ("id", "_issuer_kind")
|
||||
}
|
||||
replay_item = {k: v for k, v in ri.items() if k != "id"}
|
||||
items.append(replay_item)
|
||||
if item_id:
|
||||
seen_item_ids.add(item_id)
|
||||
@@ -913,26 +825,6 @@ def _preflight_codex_api_kwargs(
|
||||
elif "stream" in api_kwargs:
|
||||
raise ValueError("Codex Responses stream flag is only allowed in fallback streaming requests.")
|
||||
|
||||
# Safety-net sanitization for xAI Responses (#28490): defense-in-depth
|
||||
# for the same slash-enum strip that ``chat_completion_helpers`` and
|
||||
# ``auxiliary_client`` apply at request-build time. If a future code
|
||||
# path forgets to sanitize before calling us, this catches the bypass
|
||||
# so xAI doesn't 400 with ``Invalid arguments passed to the model``
|
||||
# (HuggingFace IDs like ``Qwen/Qwen3.5-0.8B`` from MCP tool schemas).
|
||||
#
|
||||
# Gated on the model name pattern because native Codex (OpenAI) DOES
|
||||
# accept slash-containing enum values — stripping them there would
|
||||
# silently degrade tool-schema constraints. xAI is the only
|
||||
# Responses-API surface that rejects the shape.
|
||||
model_name_for_provider_check = str(api_kwargs.get("model") or "").lower()
|
||||
is_xai_model = model_name_for_provider_check.startswith(("grok-", "x-ai/grok-"))
|
||||
if is_xai_model and normalized.get("tools"):
|
||||
try:
|
||||
from tools.schema_sanitizer import strip_slash_enum
|
||||
normalized["tools"], _ = strip_slash_enum(normalized["tools"])
|
||||
except Exception:
|
||||
pass # Best-effort — the caller-level sanitization should have handled it
|
||||
|
||||
unexpected = sorted(key for key in api_kwargs if key not in allowed_keys)
|
||||
if unexpected:
|
||||
raise ValueError(
|
||||
@@ -984,18 +876,8 @@ def _extract_responses_reasoning_text(item: Any) -> str:
|
||||
# Full response normalization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _normalize_codex_response(
|
||||
response: Any,
|
||||
*,
|
||||
issuer_kind: Optional[str] = None,
|
||||
) -> tuple[Any, str]:
|
||||
"""Normalize a Responses API object to an assistant_message-like object.
|
||||
|
||||
``issuer_kind`` (when provided) is stamped onto each reasoning item the
|
||||
response yields, so future replays can detect when the active endpoint
|
||||
differs from the one that minted the encrypted_content blob and drop
|
||||
the item instead of triggering HTTP 400 invalid_encrypted_content.
|
||||
"""
|
||||
def _normalize_codex_response(response: Any) -> tuple[Any, str]:
|
||||
"""Normalize a Responses API object to an assistant_message-like object."""
|
||||
output = getattr(response, "output", None)
|
||||
if not isinstance(output, list) or not output:
|
||||
# The Codex backend can return empty output when the answer was
|
||||
@@ -1037,7 +919,6 @@ def _normalize_codex_response(
|
||||
has_incomplete_items = response_status in {"queued", "in_progress", "incomplete"}
|
||||
saw_commentary_phase = False
|
||||
saw_final_answer_phase = False
|
||||
saw_reasoning_item = False
|
||||
|
||||
for item in output:
|
||||
item_type = getattr(item, "type", None)
|
||||
@@ -1075,7 +956,6 @@ def _normalize_codex_response(
|
||||
raw_message_item["phase"] = normalized_phase
|
||||
message_items_raw.append(raw_message_item)
|
||||
elif item_type == "reasoning":
|
||||
saw_reasoning_item = True
|
||||
reasoning_text = _extract_responses_reasoning_text(item)
|
||||
if reasoning_text:
|
||||
reasoning_parts.append(reasoning_text)
|
||||
@@ -1085,19 +965,7 @@ def _normalize_codex_response(
|
||||
encrypted = getattr(item, "encrypted_content", None)
|
||||
if isinstance(encrypted, str) and encrypted:
|
||||
raw_item = {"type": "reasoning", "encrypted_content": encrypted}
|
||||
# Stamp the issuer so future turns can detect when a
|
||||
# model swap moved the conversation to an endpoint that
|
||||
# cannot decrypt this blob — see _chat_messages_to_responses_input
|
||||
# cross-issuer guard.
|
||||
if issuer_kind:
|
||||
raw_item["_issuer_kind"] = issuer_kind
|
||||
item_id = getattr(item, "id", None)
|
||||
if isinstance(item_id, str) and item_id.startswith("rs_tmp_"):
|
||||
logger.debug(
|
||||
"Skipping transient Codex reasoning item during normalization: %s",
|
||||
item_id,
|
||||
)
|
||||
continue
|
||||
if isinstance(item_id, str) and item_id:
|
||||
raw_item["id"] = item_id
|
||||
# Capture summary — required by the API when replaying reasoning items
|
||||
@@ -1208,13 +1076,13 @@ def _normalize_codex_response(
|
||||
finish_reason = "incomplete"
|
||||
elif has_incomplete_items or (saw_commentary_phase and not saw_final_answer_phase):
|
||||
finish_reason = "incomplete"
|
||||
elif (reasoning_items_raw or reasoning_parts or saw_reasoning_item) and not final_text:
|
||||
# Response contains only reasoning (encrypted thinking state and/or
|
||||
# human-readable summary) with no visible content or tool calls. The
|
||||
# model is still thinking and needs another turn to produce the actual
|
||||
# answer. Marking this as "stop" would send it into the empty-content
|
||||
# retry loop which burns retries then fails — treat it as incomplete so
|
||||
# the Codex continuation path handles it correctly.
|
||||
elif reasoning_items_raw and not final_text:
|
||||
# Response contains only reasoning (encrypted thinking state) with
|
||||
# no visible content or tool calls. The model is still thinking and
|
||||
# needs another turn to produce the actual answer. Marking this as
|
||||
# "stop" would send it into the empty-content retry loop which burns
|
||||
# 3 retries then fails — treat it as incomplete instead so the Codex
|
||||
# continuation path handles it correctly.
|
||||
finish_reason = "incomplete"
|
||||
else:
|
||||
finish_reason = "stop"
|
||||
|
||||
@@ -174,363 +174,281 @@ def run_codex_app_server_turn(
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event-driven Responses streaming
|
||||
#
|
||||
# OpenAI ships its consumer Codex backend (chatgpt.com/backend-api/codex) on
|
||||
# a different schedule from the openai Python SDK. The high-level
|
||||
# ``client.responses.stream(...)`` helper reconstructs a typed Response from
|
||||
# the terminal ``response.completed`` event's ``response.output`` field, and
|
||||
# when that field drifts to ``null`` (gpt-5.5, May 2026) the SDK raises
|
||||
# ``TypeError: 'NoneType' object is not iterable`` mid-iteration.
|
||||
#
|
||||
# We sidestep the whole class of failure by going one level lower:
|
||||
# ``client.responses.create(stream=True)`` returns the raw AsyncIterable of
|
||||
# SSE events, and we assemble the final response object purely from
|
||||
# ``response.output_item.done`` events as they arrive. We never read
|
||||
# ``response.completed.response.output`` for content reconstruction, so the
|
||||
# backend can return ``null``, ``[]``, a string, or omit the field entirely
|
||||
# and we don't care.
|
||||
#
|
||||
# This mirrors what the OpenClaw TS implementation does for the same backend
|
||||
# and is structurally immune to the bug class rather than patched.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_TERMINAL_EVENT_TYPES = frozenset({
|
||||
"response.completed",
|
||||
"response.incomplete",
|
||||
"response.failed",
|
||||
})
|
||||
|
||||
|
||||
def _event_field(event: Any, name: str, default: Any = None) -> Any:
|
||||
"""Field access that handles both attr-style (SDK objects) and dict (raw JSON) events."""
|
||||
value = getattr(event, name, None)
|
||||
if value is None and isinstance(event, dict):
|
||||
value = event.get(name, default)
|
||||
return value if value is not None else default
|
||||
|
||||
|
||||
def _raise_stream_error(event: Any) -> None:
|
||||
"""Raise a ``_StreamErrorEvent`` from a ``type=error`` SSE frame.
|
||||
|
||||
Imported lazily so this module stays importable from places that don't
|
||||
pull in ``run_agent`` (e.g. plugin code, doc tools).
|
||||
"""
|
||||
from run_agent import _StreamErrorEvent
|
||||
message = (_event_field(event, "message", "") or "stream emitted error event").strip()
|
||||
raise _StreamErrorEvent(
|
||||
message,
|
||||
code=_event_field(event, "code"),
|
||||
param=_event_field(event, "param"),
|
||||
)
|
||||
|
||||
|
||||
def _consume_codex_event_stream(
|
||||
event_iter: Any,
|
||||
*,
|
||||
model: str,
|
||||
on_text_delta=None,
|
||||
on_reasoning_delta=None,
|
||||
on_first_delta=None,
|
||||
on_event=None,
|
||||
interrupt_check=None,
|
||||
) -> SimpleNamespace:
|
||||
"""Consume a Codex Responses SSE event stream and return a final response.
|
||||
|
||||
The returned object is a ``SimpleNamespace`` shaped like the SDK's typed
|
||||
``Response`` for the fields downstream code actually reads:
|
||||
|
||||
* ``output``: list of output items, assembled from ``response.output_item.done``.
|
||||
For tool-call turns this contains the function_call items; for plain-text
|
||||
turns it contains a synthesized ``message`` item built from streamed deltas
|
||||
if no message item was emitted directly.
|
||||
* ``output_text``: assembled text from ``response.output_text.delta`` deltas.
|
||||
* ``usage``: copied from the terminal event's ``response.usage`` (when present).
|
||||
* ``status``: ``completed`` / ``incomplete`` / ``failed`` (or ``completed`` if
|
||||
the stream ended without a terminal frame but produced content).
|
||||
* ``id``: ``response.id`` when present.
|
||||
* ``incomplete_details``: passed through for ``response.incomplete`` frames.
|
||||
* ``error``: passed through for ``response.failed`` frames.
|
||||
* ``model``: from kwargs (the wire model name is not authoritative).
|
||||
|
||||
Critically, we never read ``response.output`` from the terminal event for
|
||||
content reconstruction — only ``usage``, ``status``, ``id``. That field
|
||||
being ``null`` / ``[]`` / missing is fine.
|
||||
|
||||
Callbacks:
|
||||
|
||||
* ``on_text_delta(str)`` — fires per ``response.output_text.delta``, suppressed
|
||||
once a function_call event is seen (so tool-call turns don't bleed text
|
||||
into the chat).
|
||||
* ``on_reasoning_delta(str)`` — fires per ``response.reasoning.*.delta``.
|
||||
* ``on_first_delta()`` — one-shot, fires on the first text delta only.
|
||||
* ``on_event(event)`` — fires for every event before any other processing.
|
||||
Used for watchdog activity, debug logging, anything wire-shape-agnostic.
|
||||
* ``interrupt_check()`` — returns True to break the loop early.
|
||||
"""
|
||||
collected_output_items: List[Any] = []
|
||||
collected_text_deltas: List[str] = []
|
||||
has_tool_calls = False
|
||||
first_delta_fired = False
|
||||
terminal_status: str = "completed"
|
||||
terminal_usage: Any = None
|
||||
terminal_response_id: str = None
|
||||
terminal_incomplete_details: Any = None
|
||||
terminal_error: Any = None
|
||||
saw_terminal = False
|
||||
|
||||
for event in event_iter:
|
||||
if on_event is not None:
|
||||
try:
|
||||
on_event(event)
|
||||
except (TimeoutError, InterruptedError):
|
||||
# Control-flow signals from watchdog/cancellation hooks must
|
||||
# propagate, not get swallowed as "debug noise".
|
||||
raise
|
||||
except Exception:
|
||||
# Genuine bugs in third-party debug/log hooks shouldn't break
|
||||
# stream consumption.
|
||||
logger.debug("Codex stream on_event hook raised", exc_info=True)
|
||||
if interrupt_check is not None and interrupt_check():
|
||||
break
|
||||
|
||||
event_type = _event_field(event, "type", "")
|
||||
if not isinstance(event_type, str):
|
||||
event_type = ""
|
||||
|
||||
# ``error`` SSE frames carry the provider's real failure reason
|
||||
# (subscription / quota / model-not-available / rejected-reasoning-replay)
|
||||
# but never appear in the terminal set. Surface them as a structured
|
||||
# exception so the credential pool + error classifier see the body.
|
||||
if event_type == "error":
|
||||
_raise_stream_error(event)
|
||||
|
||||
if "output_text.delta" in event_type or event_type == "response.output_text.delta":
|
||||
delta_text = _event_field(event, "delta", "")
|
||||
if delta_text:
|
||||
collected_text_deltas.append(delta_text)
|
||||
if not has_tool_calls:
|
||||
if not first_delta_fired:
|
||||
first_delta_fired = True
|
||||
if on_first_delta is not None:
|
||||
try:
|
||||
on_first_delta()
|
||||
except Exception:
|
||||
logger.debug("Codex stream on_first_delta raised", exc_info=True)
|
||||
if on_text_delta is not None:
|
||||
try:
|
||||
on_text_delta(delta_text)
|
||||
except Exception:
|
||||
logger.debug("Codex stream on_text_delta raised", exc_info=True)
|
||||
continue
|
||||
|
||||
if "function_call" in event_type:
|
||||
has_tool_calls = True
|
||||
# fall through — function_call items still get added on output_item.done
|
||||
|
||||
if "reasoning" in event_type and "delta" in event_type:
|
||||
reasoning_text = _event_field(event, "delta", "")
|
||||
if reasoning_text and on_reasoning_delta is not None:
|
||||
try:
|
||||
on_reasoning_delta(reasoning_text)
|
||||
except Exception:
|
||||
logger.debug("Codex stream on_reasoning_delta raised", exc_info=True)
|
||||
continue
|
||||
|
||||
if event_type == "response.output_item.done":
|
||||
done_item = _event_field(event, "item")
|
||||
if done_item is not None:
|
||||
collected_output_items.append(done_item)
|
||||
continue
|
||||
|
||||
if event_type in _TERMINAL_EVENT_TYPES:
|
||||
saw_terminal = True
|
||||
resp_obj = _event_field(event, "response")
|
||||
if resp_obj is not None:
|
||||
terminal_usage = getattr(resp_obj, "usage", None)
|
||||
if terminal_usage is None and isinstance(resp_obj, dict):
|
||||
terminal_usage = resp_obj.get("usage")
|
||||
rid = getattr(resp_obj, "id", None)
|
||||
if rid is None and isinstance(resp_obj, dict):
|
||||
rid = resp_obj.get("id")
|
||||
terminal_response_id = rid
|
||||
rstatus = getattr(resp_obj, "status", None)
|
||||
if rstatus is None and isinstance(resp_obj, dict):
|
||||
rstatus = resp_obj.get("status")
|
||||
if isinstance(rstatus, str):
|
||||
terminal_status = rstatus
|
||||
if event_type == "response.incomplete":
|
||||
terminal_incomplete_details = getattr(resp_obj, "incomplete_details", None)
|
||||
if terminal_incomplete_details is None and isinstance(resp_obj, dict):
|
||||
terminal_incomplete_details = resp_obj.get("incomplete_details")
|
||||
if event_type == "response.failed":
|
||||
terminal_error = getattr(resp_obj, "error", None)
|
||||
if terminal_error is None and isinstance(resp_obj, dict):
|
||||
terminal_error = resp_obj.get("error")
|
||||
if event_type == "response.completed":
|
||||
terminal_status = terminal_status or "completed"
|
||||
elif event_type == "response.incomplete":
|
||||
terminal_status = terminal_status or "incomplete"
|
||||
elif event_type == "response.failed":
|
||||
terminal_status = terminal_status or "failed"
|
||||
# Stop on terminal event.
|
||||
break
|
||||
|
||||
# Build the final output list. Prefer items observed via output_item.done;
|
||||
# if none arrived but we streamed plain text deltas (no tool calls), synthesize
|
||||
# a single message item so downstream normalization has something to work with.
|
||||
if collected_output_items:
|
||||
output = list(collected_output_items)
|
||||
elif collected_text_deltas and not has_tool_calls:
|
||||
assembled = "".join(collected_text_deltas)
|
||||
output = [SimpleNamespace(
|
||||
type="message",
|
||||
role="assistant",
|
||||
status="completed",
|
||||
content=[SimpleNamespace(type="output_text", text=assembled)],
|
||||
)]
|
||||
else:
|
||||
output = []
|
||||
|
||||
# If the stream ended without any terminal event AND produced no usable
|
||||
# content (no items, no text deltas), surface that as a RuntimeError so
|
||||
# callers can distinguish "stream truncated mid-flight / provider rejected
|
||||
# the call" from "stream completed with empty body". This preserves the
|
||||
# signal the SDK's high-level helper used to raise as
|
||||
# ``RuntimeError("Didn't receive a `response.completed` event.")``.
|
||||
if not saw_terminal and not output:
|
||||
raise RuntimeError(
|
||||
"Codex Responses stream did not emit a terminal response"
|
||||
)
|
||||
|
||||
assembled_text = "".join(collected_text_deltas)
|
||||
|
||||
final = SimpleNamespace(
|
||||
output=output,
|
||||
output_text=assembled_text,
|
||||
usage=terminal_usage,
|
||||
status=terminal_status,
|
||||
id=terminal_response_id,
|
||||
model=model,
|
||||
incomplete_details=terminal_incomplete_details,
|
||||
error=terminal_error,
|
||||
)
|
||||
return final
|
||||
|
||||
|
||||
def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta=None):
|
||||
"""Execute one streaming Responses API request and return the final response.
|
||||
|
||||
Uses ``responses.create(stream=True)`` (low-level raw event iteration)
|
||||
rather than the high-level ``responses.stream(...)`` helper. This makes
|
||||
us structurally immune to backend drift in the ``response.completed``
|
||||
payload shape — we never let the SDK reconstruct a typed object from
|
||||
the terminal event's ``output`` field.
|
||||
"""
|
||||
def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta: callable = None):
|
||||
"""Execute one streaming Responses API request and return the final response."""
|
||||
import httpx as _httpx
|
||||
|
||||
active_client = client or agent._ensure_primary_openai_client(reason="codex_stream_direct")
|
||||
max_stream_retries = 1
|
||||
# Accumulate streamed text so callers / compat shims can read it.
|
||||
has_tool_calls = False
|
||||
first_delta_fired = False
|
||||
# Accumulate streamed text so we can recover if get_final_response()
|
||||
# returns empty output (e.g. chatgpt.com backend-api sends
|
||||
# response.incomplete instead of response.completed).
|
||||
agent._codex_streamed_text_parts: list = []
|
||||
|
||||
def _on_text_delta(text: str) -> None:
|
||||
agent._codex_streamed_text_parts.append(text)
|
||||
agent._fire_stream_delta(text)
|
||||
|
||||
def _on_reasoning_delta(text: str) -> None:
|
||||
agent._fire_reasoning_delta(text)
|
||||
|
||||
def _on_event(event: Any) -> None:
|
||||
# TTFB watchdog and activity touch — runs once per SSE event.
|
||||
agent._codex_stream_last_event_ts = time.time()
|
||||
agent._touch_activity("receiving stream response")
|
||||
|
||||
def _interrupt_check() -> bool:
|
||||
return bool(agent._interrupt_requested)
|
||||
|
||||
for attempt in range(max_stream_retries + 1):
|
||||
if agent._interrupt_requested:
|
||||
raise InterruptedError("Agent interrupted before Codex stream retry")
|
||||
|
||||
stream_kwargs = dict(api_kwargs)
|
||||
stream_kwargs["stream"] = True
|
||||
|
||||
collected_output_items: list = []
|
||||
try:
|
||||
event_stream = active_client.responses.create(**stream_kwargs)
|
||||
with active_client.responses.stream(**api_kwargs) as stream:
|
||||
for event in stream:
|
||||
# Mark stream activity for the TTFB watchdog in
|
||||
# interruptible_api_call. The Codex backend can accept the
|
||||
# connection but never emit a single event; this timestamp
|
||||
# staying None tells the watchdog no bytes are flowing.
|
||||
agent._codex_stream_last_event_ts = time.time()
|
||||
agent._touch_activity("receiving stream response")
|
||||
if agent._interrupt_requested:
|
||||
break
|
||||
event_type = getattr(event, "type", "")
|
||||
# Fire callbacks on text content deltas (suppress during tool calls)
|
||||
if "output_text.delta" in event_type or event_type == "response.output_text.delta":
|
||||
delta_text = getattr(event, "delta", "")
|
||||
if delta_text:
|
||||
agent._codex_streamed_text_parts.append(delta_text)
|
||||
if delta_text and not has_tool_calls:
|
||||
if not first_delta_fired:
|
||||
first_delta_fired = True
|
||||
if on_first_delta:
|
||||
try:
|
||||
on_first_delta()
|
||||
except Exception:
|
||||
pass
|
||||
agent._fire_stream_delta(delta_text)
|
||||
# Track tool calls to suppress text streaming
|
||||
elif "function_call" in event_type:
|
||||
has_tool_calls = True
|
||||
# Fire reasoning callbacks
|
||||
elif "reasoning" in event_type and "delta" in event_type:
|
||||
reasoning_text = getattr(event, "delta", "")
|
||||
if reasoning_text:
|
||||
agent._fire_reasoning_delta(reasoning_text)
|
||||
# Collect completed output items — some backends
|
||||
# (chatgpt.com/backend-api/codex) stream valid items
|
||||
# via response.output_item.done but the SDK's
|
||||
# get_final_response() returns an empty output list.
|
||||
elif event_type == "response.output_item.done":
|
||||
done_item = getattr(event, "item", None)
|
||||
if done_item is not None:
|
||||
collected_output_items.append(done_item)
|
||||
# Log non-completed terminal events for diagnostics
|
||||
elif event_type in {"response.incomplete", "response.failed"}:
|
||||
resp_obj = getattr(event, "response", None)
|
||||
status = getattr(resp_obj, "status", None) if resp_obj else None
|
||||
incomplete_details = getattr(resp_obj, "incomplete_details", None) if resp_obj else None
|
||||
logger.warning(
|
||||
"Codex Responses stream received terminal event %s "
|
||||
"(status=%s, incomplete_details=%s, streamed_chars=%d). %s",
|
||||
event_type, status, incomplete_details,
|
||||
sum(len(p) for p in agent._codex_streamed_text_parts),
|
||||
agent._client_log_context(),
|
||||
)
|
||||
final_response = stream.get_final_response()
|
||||
# PATCH: ChatGPT Codex backend streams valid output items
|
||||
# but get_final_response() can return an empty output list.
|
||||
# Backfill from collected items or synthesize from deltas.
|
||||
_out = getattr(final_response, "output", None)
|
||||
if isinstance(_out, list) and not _out:
|
||||
if collected_output_items:
|
||||
final_response.output = list(collected_output_items)
|
||||
logger.debug(
|
||||
"Codex stream: backfilled %d output items from stream events",
|
||||
len(collected_output_items),
|
||||
)
|
||||
elif agent._codex_streamed_text_parts and not has_tool_calls:
|
||||
assembled = "".join(agent._codex_streamed_text_parts)
|
||||
final_response.output = [SimpleNamespace(
|
||||
type="message",
|
||||
role="assistant",
|
||||
status="completed",
|
||||
content=[SimpleNamespace(type="output_text", text=assembled)],
|
||||
)]
|
||||
logger.debug(
|
||||
"Codex stream: synthesized output from %d text deltas (%d chars)",
|
||||
len(agent._codex_streamed_text_parts), len(assembled),
|
||||
)
|
||||
return final_response
|
||||
except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc:
|
||||
if attempt < max_stream_retries:
|
||||
logger.debug(
|
||||
"Codex Responses stream connect failed (attempt %s/%s); retrying. %s error=%s",
|
||||
attempt + 1, max_stream_retries + 1,
|
||||
agent._client_log_context(), exc,
|
||||
"Codex Responses stream transport failed (attempt %s/%s); retrying. %s error=%s",
|
||||
attempt + 1,
|
||||
max_stream_retries + 1,
|
||||
agent._client_log_context(),
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
raise
|
||||
|
||||
try:
|
||||
# Compatibility: some mocks/providers return a concrete response
|
||||
# instead of an iterable. Pass it straight through.
|
||||
if hasattr(event_stream, "output") and not hasattr(event_stream, "__iter__"):
|
||||
return event_stream
|
||||
|
||||
try:
|
||||
final = _consume_codex_event_stream(
|
||||
event_stream,
|
||||
model=api_kwargs.get("model"),
|
||||
on_text_delta=_on_text_delta,
|
||||
on_reasoning_delta=_on_reasoning_delta,
|
||||
on_first_delta=on_first_delta,
|
||||
on_event=_on_event,
|
||||
interrupt_check=_interrupt_check,
|
||||
)
|
||||
except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc:
|
||||
if attempt < max_stream_retries:
|
||||
logger.debug(
|
||||
"Codex Responses stream transport failed mid-iteration "
|
||||
"(attempt %s/%s); retrying. %s error=%s",
|
||||
attempt + 1, max_stream_retries + 1,
|
||||
agent._client_log_context(), exc,
|
||||
)
|
||||
continue
|
||||
raise
|
||||
|
||||
if final.status in {"incomplete", "failed"}:
|
||||
logger.warning(
|
||||
"Codex Responses stream terminal status=%s "
|
||||
"(incomplete_details=%s, error=%s, streamed_chars=%d). %s",
|
||||
final.status, final.incomplete_details, final.error,
|
||||
sum(len(p) for p in agent._codex_streamed_text_parts),
|
||||
logger.debug(
|
||||
"Codex Responses stream transport failed; falling back to create(stream=True). %s error=%s",
|
||||
agent._client_log_context(),
|
||||
exc,
|
||||
)
|
||||
return agent._run_codex_create_stream_fallback(api_kwargs, client=active_client)
|
||||
except RuntimeError as exc:
|
||||
err_text = str(exc)
|
||||
missing_completed = "response.completed" in err_text
|
||||
# The OpenAI SDK's Responses streaming state machine raises
|
||||
# ``RuntimeError("Expected to have received `response.created`
|
||||
# before `<event-type>`")`` when the first SSE event from the
|
||||
# server is anything other than ``response.created`` — and it
|
||||
# discards the event's payload before we can read it. Three
|
||||
# real-world backends emit a different first frame:
|
||||
#
|
||||
# * xAI on grok-4.x OAuth — sends ``error`` (issues
|
||||
# reported around the May 2026 SuperGrok rollout when
|
||||
# multi-turn conversations replay encrypted reasoning
|
||||
# content the OAuth tier rejects)
|
||||
# * codex-lb relays — send ``codex.rate_limits`` (#14634)
|
||||
# * custom Responses relays — send ``response.in_progress``
|
||||
# (#8133)
|
||||
#
|
||||
# In all three cases the underlying byte stream is still
|
||||
# readable: a non-stream ``responses.create(stream=True)``
|
||||
# fallback succeeds and surfaces the real provider error as
|
||||
# a normal exception with body+status_code attached, which
|
||||
# ``_summarize_api_error`` can then translate into a useful
|
||||
# user-facing line. Treat ``response.created`` prelude
|
||||
# errors the same way we already treat ``response.completed``
|
||||
# postlude errors.
|
||||
prelude_error = (
|
||||
"Expected to have received `response.created`" in err_text
|
||||
or "Expected to have received \"response.created\"" in err_text
|
||||
)
|
||||
if (missing_completed or prelude_error) and attempt < max_stream_retries:
|
||||
logger.debug(
|
||||
"Responses stream %s (attempt %s/%s); retrying. %s",
|
||||
"prelude rejected" if prelude_error else "closed before completion",
|
||||
attempt + 1,
|
||||
max_stream_retries + 1,
|
||||
agent._client_log_context(),
|
||||
)
|
||||
continue
|
||||
if missing_completed or prelude_error:
|
||||
logger.debug(
|
||||
"Responses stream %s; falling back to create(stream=True). %s err=%s",
|
||||
"rejected before response.created" if prelude_error else "did not emit response.completed",
|
||||
agent._client_log_context(),
|
||||
err_text,
|
||||
)
|
||||
return agent._run_codex_create_stream_fallback(api_kwargs, client=active_client)
|
||||
raise
|
||||
|
||||
return final
|
||||
finally:
|
||||
close_fn = getattr(event_stream, "close", None)
|
||||
if callable(close_fn):
|
||||
try:
|
||||
close_fn()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_codex_create_stream_fallback(agent, api_kwargs: dict, client: Any = None):
|
||||
"""Backward-compatible alias for the unified event-driven path.
|
||||
"""Fallback path for stream completion edge cases on Codex-style Responses backends."""
|
||||
active_client = client or agent._ensure_primary_openai_client(reason="codex_create_stream_fallback")
|
||||
fallback_kwargs = dict(api_kwargs)
|
||||
fallback_kwargs["stream"] = True
|
||||
fallback_kwargs = agent._get_transport().preflight_kwargs(fallback_kwargs, allow_stream=True)
|
||||
stream_or_response = active_client.responses.create(**fallback_kwargs)
|
||||
|
||||
# Compatibility shim for mocks or providers that still return a concrete response.
|
||||
if hasattr(stream_or_response, "output"):
|
||||
return stream_or_response
|
||||
if not hasattr(stream_or_response, "__iter__"):
|
||||
return stream_or_response
|
||||
|
||||
terminal_response = None
|
||||
collected_output_items: list = []
|
||||
collected_text_deltas: list = []
|
||||
try:
|
||||
for event in stream_or_response:
|
||||
agent._touch_activity("receiving stream response")
|
||||
event_type = getattr(event, "type", None)
|
||||
if not event_type and isinstance(event, dict):
|
||||
event_type = event.get("type")
|
||||
|
||||
# ``error`` SSE frames carry the provider's real failure
|
||||
# reason (subscription / quota / model-not-available /
|
||||
# rejected-reasoning-replay) but never appear in the
|
||||
# ``{completed, incomplete, failed}`` terminal set, so the
|
||||
# raw loop below would silently consume them and end with
|
||||
# "did not emit a terminal response". xAI in particular
|
||||
# emits ``type=error`` as the FIRST frame for OAuth
|
||||
# accounts whose Grok subscription is missing/exhausted —
|
||||
# the SDK's stream helper raises ``RuntimeError(Expected
|
||||
# to have received response.created before error)`` which
|
||||
# the caller catches and routes here, expecting this
|
||||
# fallback to surface the message. Synthesize an
|
||||
# APIError-shaped exception so ``_summarize_api_error``
|
||||
# and the credential-pool entitlement detector see the
|
||||
# real text instead of a generic RuntimeError.
|
||||
if event_type == "error":
|
||||
err_message = getattr(event, "message", None)
|
||||
if not err_message and isinstance(event, dict):
|
||||
err_message = event.get("message")
|
||||
err_code = getattr(event, "code", None)
|
||||
if not err_code and isinstance(event, dict):
|
||||
err_code = event.get("code")
|
||||
err_param = getattr(event, "param", None)
|
||||
if not err_param and isinstance(event, dict):
|
||||
err_param = event.get("param")
|
||||
err_message = (err_message or "stream emitted error event").strip()
|
||||
from run_agent import _StreamErrorEvent
|
||||
raise _StreamErrorEvent(err_message, code=err_code, param=err_param)
|
||||
|
||||
# Collect output items and text deltas for backfill
|
||||
if event_type == "response.output_item.done":
|
||||
done_item = getattr(event, "item", None)
|
||||
if done_item is None and isinstance(event, dict):
|
||||
done_item = event.get("item")
|
||||
if done_item is not None:
|
||||
collected_output_items.append(done_item)
|
||||
elif event_type in {"response.output_text.delta",}:
|
||||
delta = getattr(event, "delta", "")
|
||||
if not delta and isinstance(event, dict):
|
||||
delta = event.get("delta", "")
|
||||
if delta:
|
||||
collected_text_deltas.append(delta)
|
||||
|
||||
if event_type not in {"response.completed", "response.incomplete", "response.failed"}:
|
||||
continue
|
||||
|
||||
terminal_response = getattr(event, "response", None)
|
||||
if terminal_response is None and isinstance(event, dict):
|
||||
terminal_response = event.get("response")
|
||||
if terminal_response is not None:
|
||||
# Backfill empty output from collected stream events
|
||||
_out = getattr(terminal_response, "output", None)
|
||||
if isinstance(_out, list) and not _out:
|
||||
if collected_output_items:
|
||||
terminal_response.output = list(collected_output_items)
|
||||
logger.debug(
|
||||
"Codex fallback stream: backfilled %d output items",
|
||||
len(collected_output_items),
|
||||
)
|
||||
elif collected_text_deltas:
|
||||
assembled = "".join(collected_text_deltas)
|
||||
terminal_response.output = [SimpleNamespace(
|
||||
type="message", role="assistant",
|
||||
status="completed",
|
||||
content=[SimpleNamespace(type="output_text", text=assembled)],
|
||||
)]
|
||||
logger.debug(
|
||||
"Codex fallback stream: synthesized from %d deltas (%d chars)",
|
||||
len(collected_text_deltas), len(assembled),
|
||||
)
|
||||
return terminal_response
|
||||
finally:
|
||||
close_fn = getattr(stream_or_response, "close", None)
|
||||
if callable(close_fn):
|
||||
try:
|
||||
close_fn()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if terminal_response is not None:
|
||||
return terminal_response
|
||||
raise RuntimeError("Responses create(stream=True) fallback did not emit a terminal response.")
|
||||
|
||||
Historically this was the fallback when the SDK's high-level
|
||||
``responses.stream(...)`` helper raised on shape drift. The primary
|
||||
path now does exactly what the fallback did, so this just forwards.
|
||||
Kept as a public symbol because tests and a small number of call sites
|
||||
still reference it by name.
|
||||
"""
|
||||
return run_codex_stream(agent, api_kwargs, client=client)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"run_codex_app_server_turn",
|
||||
"run_codex_stream",
|
||||
"run_codex_create_stream_fallback",
|
||||
"_consume_codex_event_stream",
|
||||
]
|
||||
|
||||
@@ -221,6 +221,114 @@ def _truncate_tool_call_args_json(args: str, head_chars: int = 200) -> str:
|
||||
return json.dumps(shrunken, ensure_ascii=False)
|
||||
|
||||
|
||||
_IMAGE_PART_TYPES = frozenset({"image_url", "input_image", "image"})
|
||||
|
||||
|
||||
def _is_image_part(part: Any) -> bool:
|
||||
"""True if ``part`` is a multimodal image content block.
|
||||
|
||||
Recognizes all three shapes the agent handles:
|
||||
- OpenAI chat.completions: ``{"type": "image_url", "image_url": ...}``
|
||||
- OpenAI Responses API: ``{"type": "input_image", "image_url": "..."}``
|
||||
- Anthropic native: ``{"type": "image", "source": {...}}``
|
||||
"""
|
||||
if not isinstance(part, dict):
|
||||
return False
|
||||
return part.get("type") in _IMAGE_PART_TYPES
|
||||
|
||||
|
||||
def _content_has_images(content: Any) -> bool:
|
||||
"""True if a message's ``content`` is a multimodal list with image parts."""
|
||||
if not isinstance(content, list):
|
||||
return False
|
||||
return any(_is_image_part(p) for p in content)
|
||||
|
||||
|
||||
def _strip_images_from_content(content: Any) -> Any:
|
||||
"""Return a copy of ``content`` with every image part replaced by a
|
||||
short text placeholder.
|
||||
|
||||
- String content is returned unchanged.
|
||||
- Non-list, non-string content is returned unchanged.
|
||||
- List content: image parts become ``{"type": "text", "text": "[Attached
|
||||
image — stripped after compression]"}``; other parts are preserved as-is.
|
||||
|
||||
Input is never mutated.
|
||||
"""
|
||||
if not isinstance(content, list):
|
||||
return content
|
||||
if not any(_is_image_part(p) for p in content):
|
||||
return content
|
||||
|
||||
new_parts: List[Any] = []
|
||||
for p in content:
|
||||
if _is_image_part(p):
|
||||
new_parts.append({
|
||||
"type": "text",
|
||||
"text": "[Attached image — stripped after compression]",
|
||||
})
|
||||
else:
|
||||
new_parts.append(p)
|
||||
return new_parts
|
||||
|
||||
|
||||
def _strip_historical_media(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Replace image parts in older messages with placeholder text.
|
||||
|
||||
The anchor is the *last* user message that has any image content. Every
|
||||
message before that anchor gets its image parts replaced with a short
|
||||
placeholder so the outgoing request stops re-shipping the same multi-MB
|
||||
base-64 image blobs on every turn.
|
||||
|
||||
If no user message carries images, the list is returned unchanged.
|
||||
If the only user message with images is the very first one (nothing
|
||||
earlier to strip), the list is returned unchanged.
|
||||
|
||||
Shallow copies of touched messages only; input is never mutated.
|
||||
Port of Kilo-Org/kilocode#9434 (adapted for the OpenAI-style message
|
||||
shape the hermes compressor emits).
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
# Find the newest user message that carries at least one image part.
|
||||
# We anchor on image-bearing user messages (not all user messages) so
|
||||
# a plain text follow-up after a big-image turn still strips the old
|
||||
# image — matching the problem kilocode#9434 set out to solve.
|
||||
anchor = -1
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
msg = messages[i]
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if msg.get("role") != "user":
|
||||
continue
|
||||
if _content_has_images(msg.get("content")):
|
||||
anchor = i
|
||||
break
|
||||
|
||||
if anchor <= 0:
|
||||
# No image-bearing user message, or it's the very first message —
|
||||
# nothing before it to strip.
|
||||
return messages
|
||||
|
||||
changed = False
|
||||
result: List[Dict[str, Any]] = []
|
||||
for i, msg in enumerate(messages):
|
||||
if i >= anchor or not isinstance(msg, dict):
|
||||
result.append(msg)
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if not _content_has_images(content):
|
||||
result.append(msg)
|
||||
continue
|
||||
new_msg = msg.copy()
|
||||
new_msg["content"] = _strip_images_from_content(content)
|
||||
result.append(new_msg)
|
||||
changed = True
|
||||
|
||||
return result if changed else messages
|
||||
|
||||
|
||||
def _summarize_tool_result(tool_name: str, tool_args: str, tool_content: str) -> str:
|
||||
"""Create an informative 1-line summary of a tool call + result.
|
||||
|
||||
@@ -1609,6 +1717,14 @@ The user has requested that this compaction PRIORITISE preserving all informatio
|
||||
|
||||
compressed = self._sanitize_tool_pairs(compressed)
|
||||
|
||||
# Replace image parts in all compressed messages before the newest
|
||||
# image-bearing user turn with a short text placeholder. Without
|
||||
# this, tail messages keep their original multi-MB base-64 image
|
||||
# payloads forever, which can push every subsequent API request
|
||||
# past the provider's body-size limit and wedge the session.
|
||||
# Port of Kilo-Org/kilocode#9434.
|
||||
compressed = _strip_historical_media(compressed)
|
||||
|
||||
new_estimate = estimate_messages_tokens_rough(compressed)
|
||||
saved_estimate = display_tokens - new_estimate
|
||||
|
||||
|
||||
@@ -1019,7 +1019,6 @@ def run_conversation(
|
||||
nous_auth_retry_attempted=False
|
||||
copilot_auth_retry_attempted=False
|
||||
thinking_sig_retry_attempted = False
|
||||
invalid_encrypted_content_retry_attempted = False
|
||||
image_shrink_retry_attempted = False
|
||||
multimodal_tool_content_retry_attempted = False
|
||||
oauth_1m_beta_retry_attempted = False
|
||||
@@ -2219,7 +2218,7 @@ def run_conversation(
|
||||
print(f"{agent.log_prefix} Response: {_body_text}")
|
||||
print(f"{agent.log_prefix} Most likely: Portal OAuth expired, account out of credits, or agent key revoked.")
|
||||
print(f"{agent.log_prefix} Troubleshooting:")
|
||||
print(f"{agent.log_prefix} • Re-authenticate: hermes auth add nous")
|
||||
print(f"{agent.log_prefix} • Re-authenticate: hermes login --provider nous")
|
||||
print(f"{agent.log_prefix} • Check credits / billing: https://portal.nousresearch.com")
|
||||
print(f"{agent.log_prefix} • Verify stored credentials: {_dhh}/auth.json")
|
||||
print(f"{agent.log_prefix} • Switch providers temporarily: /model <model> --provider openrouter")
|
||||
@@ -2297,49 +2296,6 @@ def run_conversation(
|
||||
)
|
||||
continue
|
||||
|
||||
# ── Invalid encrypted reasoning replay recovery ───────
|
||||
# OpenAI Responses API surfaces (and some compatible relays)
|
||||
# return HTTP 400 ``invalid_encrypted_content`` when a
|
||||
# replayed ``codex_reasoning_items`` blob from a previous
|
||||
# turn fails verification (provider rotated the encryption
|
||||
# key, the route doesn't actually persist reasoning state,
|
||||
# etc.). Recovery: disable replay for the rest of the
|
||||
# session, strip cached items from history, retry once.
|
||||
# One-shot — if a second 400 fires we fall through to the
|
||||
# normal retry/backoff path. Only fires for codex_responses
|
||||
# mode with at least one assistant message that has cached
|
||||
# ``codex_reasoning_items``; without replay state, the
|
||||
# error is unrelated to our cache so the normal retry path
|
||||
# handles it (the provider is rejecting something else).
|
||||
if (
|
||||
classified.reason == FailoverReason.invalid_encrypted_content
|
||||
and not invalid_encrypted_content_retry_attempted
|
||||
and agent.api_mode == "codex_responses"
|
||||
and bool(getattr(agent, "_codex_reasoning_replay_enabled", True))
|
||||
and any(
|
||||
isinstance(_m, dict)
|
||||
and _m.get("role") == "assistant"
|
||||
and isinstance(_m.get("codex_reasoning_items"), list)
|
||||
and _m.get("codex_reasoning_items")
|
||||
for _m in messages
|
||||
)
|
||||
):
|
||||
invalid_encrypted_content_retry_attempted = True
|
||||
replay_stats = agent._disable_codex_reasoning_replay(messages)
|
||||
agent._vprint(
|
||||
f"{agent.log_prefix}⚠️ Encrypted reasoning replay was rejected by the provider — "
|
||||
f"disabled replay and stripped {replay_stats['items']} item(s) from "
|
||||
f"{replay_stats['messages']} message(s), retrying...",
|
||||
force=True,
|
||||
)
|
||||
logger.warning(
|
||||
"%sInvalid encrypted reasoning recovery: disabled replay and stripped %d items from %d messages",
|
||||
agent.log_prefix,
|
||||
replay_stats["items"],
|
||||
replay_stats["messages"],
|
||||
)
|
||||
continue
|
||||
|
||||
# ── llama.cpp grammar-parse recovery ──────────────────
|
||||
# llama.cpp's ``json-schema-to-grammar`` converter rejects
|
||||
# regex escape classes (``\d``, ``\w``, ``\s``) and most
|
||||
@@ -2879,21 +2835,6 @@ def run_conversation(
|
||||
# ssl.SSLError explicitly so the error classifier's
|
||||
# retryable=True mapping takes effect instead.
|
||||
and not isinstance(api_error, ssl.SSLError)
|
||||
# Provider/SDK "NoneType is not iterable" failures are
|
||||
# shape mismatches from upstream (e.g. chatgpt.com Codex
|
||||
# backend response.completed.output=null) — not local
|
||||
# programming bugs. Even after #33042 made our own
|
||||
# consumer immune, third-party shims and mocked clients
|
||||
# can still surface this shape via TypeError. Treat
|
||||
# them as retryable so the error classifier's normal
|
||||
# retry/fallback path runs instead of killing the turn
|
||||
# as non-retryable (which left Telegram users staring
|
||||
# at a bare "Non-retryable error" with no recovery).
|
||||
and not (
|
||||
isinstance(api_error, TypeError)
|
||||
and "nonetype" in str(api_error).lower()
|
||||
and "not iterable" in str(api_error).lower()
|
||||
)
|
||||
)
|
||||
# ``FailoverReason.billing`` (HTTP 402) is NOT in this
|
||||
# exclusion set. By the time we reach this block:
|
||||
|
||||
@@ -240,11 +240,11 @@ def _clear_auth_store_provider(provider: str) -> bool:
|
||||
def _remove_nous_device_code(provider: str, removed) -> RemovalResult:
|
||||
"""Nous OAuth lives in auth.json providers.nous — clear it and suppress.
|
||||
|
||||
We suppress in addition to clearing because nothing else stops a future
|
||||
`hermes auth add nous` (or any other path that writes providers.nous)
|
||||
from re-seeding before the user has decided to. Suppression forces
|
||||
them to go through `hermes auth add nous` to re-engage, which is the
|
||||
documented re-add path and clears the suppression atomically.
|
||||
We suppress in addition to clearing because nothing else stops the
|
||||
user's next `hermes login` run from writing providers.nous again
|
||||
before they decide to. Suppression forces them to go through
|
||||
`hermes auth add nous` to re-engage, which is the documented re-add
|
||||
path and clears the suppression atomically.
|
||||
"""
|
||||
result = RemovalResult()
|
||||
if _clear_auth_store_provider(provider):
|
||||
|
||||
@@ -390,26 +390,7 @@ CURATOR_REVIEW_PROMPT = (
|
||||
"(verification scripts, fixture generators, probes)\n"
|
||||
" Then archive the old sibling. Use `terminal` with `mkdir -p "
|
||||
"~/.hermes/skills/<umbrella>/references/ && mv ... <umbrella>/"
|
||||
"references/<topic>.md` (or templates/ / scripts/).\n\n"
|
||||
"Package integrity — not optional:\n"
|
||||
"Before demoting or archiving a skill, inspect it as a COMPLETE "
|
||||
"directory package, not just SKILL.md. A skill root may include "
|
||||
"`references/`, `templates/`, `scripts/`, and `assets/`; `skill_view` "
|
||||
"discovers those relative to the skill root. A reference markdown file "
|
||||
"inside another skill is NOT a new skill root and does not get its own "
|
||||
"linked-file discovery.\n"
|
||||
"If the source skill has support files OR SKILL.md contains relative "
|
||||
"links such as `references/...`, `templates/...`, `scripts/...`, or "
|
||||
"`assets/...`, DO NOT flatten only SKILL.md into "
|
||||
"`<umbrella>/references/<old>.md`. Choose one safe path instead:\n"
|
||||
" • keep it as a standalone skill, OR\n"
|
||||
" • fully merge it by re-homing every needed support file into the "
|
||||
"umbrella's canonical `references/`, `templates/`, `scripts/`, or "
|
||||
"`assets/` directories AND rewrite the destination instructions to "
|
||||
"the new paths, OR\n"
|
||||
" • archive the entire original skill package unchanged.\n"
|
||||
"Never leave archived/demoted instructions pointing at files that were "
|
||||
"left behind under the old skill directory.\n"
|
||||
"references/<topic>.md` (or templates/ / scripts/).\n"
|
||||
"4. Also flag skills whose NAME is too narrow (contains a PR number, "
|
||||
"a feature codename, a specific error string, an 'audit' / "
|
||||
"'diagnosis' / 'salvage' session artifact). These almost always "
|
||||
|
||||
@@ -50,7 +50,6 @@ class FailoverReason(enum.Enum):
|
||||
|
||||
# Request format
|
||||
format_error = "format_error" # 400 bad request — abort or strip + retry
|
||||
invalid_encrypted_content = "invalid_encrypted_content" # Responses replay blob rejected — strip replay state and retry
|
||||
multimodal_tool_content_unsupported = "multimodal_tool_content_unsupported" # Provider rejected list-type content in tool messages (e.g. Xiaomi MiMo) — downgrade to text and retry
|
||||
|
||||
# Provider-specific
|
||||
@@ -866,26 +865,6 @@ def _classify_400(
|
||||
retryable=True,
|
||||
)
|
||||
|
||||
# Invalid encrypted reasoning replay blob (OpenAI Responses API). Must be
|
||||
# checked BEFORE context_overflow because some surfaces emit messages that
|
||||
# contain context-like phrasing ("encrypted content … could not be
|
||||
# verified") which could otherwise trip the context_overflow heuristics.
|
||||
# ``error_msg`` is lowercased upstream — match accordingly.
|
||||
error_code_lower = (error_code or "").lower()
|
||||
if (
|
||||
error_code_lower == "invalid_encrypted_content"
|
||||
or "invalid_encrypted_content" in error_msg
|
||||
or (
|
||||
"encrypted content for item" in error_msg
|
||||
and "could not be verified" in error_msg
|
||||
)
|
||||
):
|
||||
return result_fn(
|
||||
FailoverReason.invalid_encrypted_content,
|
||||
retryable=True,
|
||||
should_fallback=False,
|
||||
)
|
||||
|
||||
# Context overflow from 400
|
||||
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
|
||||
return result_fn(
|
||||
@@ -995,13 +974,6 @@ def _classify_by_error_code(
|
||||
should_compress=True,
|
||||
)
|
||||
|
||||
if code_lower == "invalid_encrypted_content":
|
||||
return result_fn(
|
||||
FailoverReason.invalid_encrypted_content,
|
||||
retryable=True,
|
||||
should_fallback=False,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -1169,49 +1141,15 @@ def _extract_error_code(body: dict) -> str:
|
||||
"""Extract an error code string from the response body."""
|
||||
if not body:
|
||||
return ""
|
||||
|
||||
def _code_from_payload(payload) -> str:
|
||||
"""Extract a code/type from a nested error payload dict (defensive)."""
|
||||
if not isinstance(payload, dict):
|
||||
return ""
|
||||
payload_error = payload.get("error", {})
|
||||
if isinstance(payload_error, dict):
|
||||
nested = payload_error.get("code") or payload_error.get("type") or ""
|
||||
if isinstance(nested, str) and nested.strip() and nested.strip() != "400":
|
||||
return nested.strip()
|
||||
code = payload.get("code") or payload.get("error_code") or ""
|
||||
if isinstance(code, (str, int)):
|
||||
text = str(code).strip()
|
||||
if text and text != "400":
|
||||
return text
|
||||
return ""
|
||||
|
||||
error_obj = body.get("error", {})
|
||||
if isinstance(error_obj, dict):
|
||||
code = error_obj.get("code") or error_obj.get("type") or ""
|
||||
if isinstance(code, str) and code.strip() and code.strip() != "400":
|
||||
if isinstance(code, str) and code.strip():
|
||||
return code.strip()
|
||||
|
||||
# Some providers wrap the real JSON error body as a string inside
|
||||
# error.message — peek into it for a nested code (e.g. Responses API
|
||||
# surfaces ``invalid_encrypted_content`` this way).
|
||||
message = error_obj.get("message")
|
||||
if isinstance(message, str) and message.strip().startswith("{"):
|
||||
import json
|
||||
try:
|
||||
inner = json.loads(message)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
inner = None
|
||||
nested_code = _code_from_payload(inner)
|
||||
if nested_code:
|
||||
return nested_code
|
||||
|
||||
# Top-level code
|
||||
code = body.get("code") or body.get("error_code") or ""
|
||||
if isinstance(code, (str, int)):
|
||||
text = str(code).strip()
|
||||
if text and text != "400":
|
||||
return text
|
||||
return str(code).strip()
|
||||
return ""
|
||||
|
||||
|
||||
|
||||
@@ -656,7 +656,7 @@ def get_valid_access_token(*, force_refresh: bool = False) -> str:
|
||||
creds = load_credentials()
|
||||
if creds is None:
|
||||
raise GoogleOAuthError(
|
||||
"No Google OAuth credentials found. Run `hermes auth add google-gemini-cli` first.",
|
||||
"No Google OAuth credentials found. Run `hermes login --provider google-gemini-cli` first.",
|
||||
code="google_oauth_not_logged_in",
|
||||
)
|
||||
|
||||
|
||||
@@ -78,7 +78,6 @@ class MemoryProvider(ABC):
|
||||
- agent_workspace (str): Shared workspace name (e.g. "hermes").
|
||||
- parent_session_id (str): For subagents, the parent's session_id.
|
||||
- user_id (str): Platform user identifier (gateway sessions).
|
||||
- user_id_alt (str): Optional alternate stable platform user identifier.
|
||||
"""
|
||||
|
||||
def system_prompt_block(self) -> str:
|
||||
|
||||
@@ -47,7 +47,7 @@ def _resolve_requests_verify() -> bool | str:
|
||||
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
|
||||
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
|
||||
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek",
|
||||
"opencode-zen", "opencode-go", "kilocode", "alibaba", "novita",
|
||||
"opencode-zen", "opencode-go", "ai-gateway", "kilocode", "alibaba", "novita",
|
||||
"qwen-oauth",
|
||||
"xiaomi",
|
||||
"arcee",
|
||||
@@ -59,7 +59,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
|
||||
"glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot",
|
||||
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek",
|
||||
"ollama",
|
||||
"stepfun", "opencode", "zen", "go", "kilo", "dashscope", "aliyun", "qwen",
|
||||
"stepfun", "opencode", "zen", "go", "vercel", "kilo", "dashscope", "aliyun", "qwen",
|
||||
"mimo", "xiaomi-mimo",
|
||||
"tencent", "tokenhub", "tencent-cloud", "tencentmaas",
|
||||
"arcee-ai", "arceeai",
|
||||
|
||||
@@ -158,6 +158,7 @@ PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
|
||||
"alibaba": "alibaba",
|
||||
"qwen-oauth": "alibaba",
|
||||
"copilot": "github-copilot",
|
||||
"ai-gateway": "vercel",
|
||||
"opencode-zen": "opencode",
|
||||
"opencode-go": "opencode-go",
|
||||
"kilocode": "kilo",
|
||||
|
||||
@@ -15,6 +15,18 @@ and MoonshotAI/kimi-cli#1595:
|
||||
2. When ``anyOf`` is used, ``type`` must be on the ``anyOf`` children, not
|
||||
the parent. Presence of both causes "type should be defined in anyOf
|
||||
items instead of the parent schema".
|
||||
3. ``enum`` arrays on scalar-typed nodes may not contain ``null`` or empty
|
||||
strings. Strip those entries (drop the enum entirely if it becomes empty).
|
||||
4. ``$ref`` nodes may not carry sibling keywords. Moonshot expands the
|
||||
reference before validation and then rejects the node if sibling keys
|
||||
like ``description`` remain on the same node as ``$ref``. Strip every
|
||||
sibling from ``$ref`` nodes so only ``{"$ref": "..."}`` survives.
|
||||
(Ported from anomalyco/opencode#24730.)
|
||||
5. ``items`` may not be a tuple-style array (``items: [schemaA, schemaB]``
|
||||
for positional element schemas). Moonshot's schema engine requires a
|
||||
single object schema applied to every array element. Collapse tuple
|
||||
``items`` to the first element schema (or ``{}`` if the tuple is empty).
|
||||
(Ported from anomalyco/opencode#24730.)
|
||||
|
||||
The ``#/definitions/...`` → ``#/$defs/...`` rewrite for draft-07 refs is
|
||||
handled separately in ``tools/mcp_tool._normalize_mcp_input_schema`` so it
|
||||
@@ -66,6 +78,16 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
||||
}
|
||||
elif key in _SCHEMA_LIST_KEYS and isinstance(value, list):
|
||||
repaired[key] = [_repair_schema(v, is_schema=True) for v in value]
|
||||
elif key == "items" and isinstance(value, list):
|
||||
# Rule 5: tuple-style ``items`` arrays (positional element
|
||||
# schemas) are not accepted by Moonshot. Collapse to the
|
||||
# first element schema if present, else to ``{}``. This
|
||||
# matches opencode's behaviour for moonshotai / kimi models.
|
||||
first = value[0] if value else {}
|
||||
if isinstance(first, dict):
|
||||
repaired[key] = _repair_schema(first, is_schema=True)
|
||||
else:
|
||||
repaired[key] = first
|
||||
elif key in _SCHEMA_NODE_KEYS:
|
||||
# items / not / additionalProperties: single nested schema.
|
||||
# additionalProperties can also be a bool — leave those alone.
|
||||
@@ -130,6 +152,15 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
||||
else:
|
||||
repaired.pop("enum")
|
||||
|
||||
# Rule 4: $ref nodes must not have sibling keywords. Moonshot expands
|
||||
# the reference before validation and then rejects the node if siblings
|
||||
# like ``description`` / ``type`` / ``default`` appear alongside $ref.
|
||||
# The referenced definition still carries its own description on the
|
||||
# target node, which Moonshot accepts.
|
||||
# (Ported from anomalyco/opencode#24730.)
|
||||
if "$ref" in repaired:
|
||||
return {"$ref": repaired["$ref"]}
|
||||
|
||||
return repaired
|
||||
|
||||
|
||||
|
||||
@@ -610,7 +610,7 @@ WSL_ENVIRONMENT_HINT = (
|
||||
# misleading — the agent should only see the machine it can actually touch.
|
||||
_REMOTE_TERMINAL_BACKENDS = frozenset({
|
||||
"docker", "singularity", "modal", "daytona", "ssh",
|
||||
"managed_modal",
|
||||
"vercel_sandbox", "managed_modal",
|
||||
})
|
||||
|
||||
|
||||
@@ -624,6 +624,7 @@ _BACKEND_FALLBACK_DESCRIPTIONS: dict[str, str] = {
|
||||
"modal": "a Modal sandbox (Linux)",
|
||||
"managed_modal": "a managed Modal sandbox (Linux)",
|
||||
"daytona": "a Daytona workspace (Linux)",
|
||||
"vercel_sandbox": "a Vercel sandbox (Linux)",
|
||||
"ssh": "a remote host reached over SSH (likely Linux)",
|
||||
}
|
||||
|
||||
@@ -737,7 +738,7 @@ def build_environment_hints() -> str:
|
||||
and a Windows-only note that `terminal` shells out to bash, not
|
||||
PowerShell).
|
||||
- For **remote / sandbox** terminal backends (docker, singularity,
|
||||
modal, daytona, ssh): host info is **suppressed**
|
||||
modal, daytona, ssh, vercel_sandbox): host info is **suppressed**
|
||||
because the agent's tools can't touch the host — only the backend
|
||||
matters. A live probe inside the backend reports its OS, user, $HOME,
|
||||
and cwd. Falls back to a static summary if the probe fails.
|
||||
|
||||
8
agent/proxy_sources/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Egress proxy integrations.
|
||||
|
||||
Currently ships an iron-proxy (ironsh/iron-proxy) wrapper that intercepts
|
||||
outbound traffic from remote terminal sandboxes and swaps proxy tokens
|
||||
for real upstream credentials at the network edge.
|
||||
|
||||
Design notes live in :mod:`agent.proxy_sources.iron_proxy`.
|
||||
"""
|
||||
1995
agent/proxy_sources/iron_proxy.py
Normal file
@@ -45,15 +45,6 @@ _COMMAND_TOOLS = {"terminal"}
|
||||
# Prevents scanning all the way to / for deeply nested paths.
|
||||
_MAX_ANCESTOR_WALK = 5
|
||||
|
||||
|
||||
def _is_ancestor_or_same(a: Path, b: Path) -> bool:
|
||||
"""Check if *a* is the same as or an ancestor of *b* (parent directory check)."""
|
||||
try:
|
||||
b.relative_to(a)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
class SubdirectoryHintTracker:
|
||||
"""Track which directories the agent visits and load hints on first access.
|
||||
|
||||
@@ -167,13 +158,7 @@ class SubdirectoryHintTracker:
|
||||
self._add_path_candidate(token, candidates)
|
||||
|
||||
def _is_valid_subdir(self, path: Path) -> bool:
|
||||
"""Check if path is a valid directory to scan for hints.
|
||||
|
||||
Only allow subdirectories within the working directory tree.
|
||||
This prevents loading AGENTS.md from outside the active workspace
|
||||
(e.g. ~/.codex/AGENTS.md, ~/.claude/CLAUDE.md), which causes
|
||||
cross-agent context contamination and instruction mixup.
|
||||
"""
|
||||
"""Check if path is a valid directory to scan for hints."""
|
||||
try:
|
||||
if not path.is_dir():
|
||||
return False
|
||||
@@ -181,43 +166,12 @@ class SubdirectoryHintTracker:
|
||||
return False
|
||||
if path in self._loaded_dirs:
|
||||
return False
|
||||
# Reject paths outside the working directory tree.
|
||||
# path.resolve() may differ from working_dir.resolve() due to symlinks,
|
||||
# but path.is_relative_to(working_dir) handles both absolute and
|
||||
# symlinked paths correctly on Python 3.9+.
|
||||
try:
|
||||
if not path.is_relative_to(self.working_dir):
|
||||
return False
|
||||
except (OSError, ValueError):
|
||||
# Older Python or path resolution error — fall back to parent
|
||||
# check as a best-effort safeguard.
|
||||
if not _is_ancestor_or_same(self.working_dir, path):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _load_hints_for_directory(self, directory: Path) -> Optional[str]:
|
||||
"""Load hint files from a directory. Returns formatted text or None.
|
||||
|
||||
Only loads hints from directories within the working directory tree.
|
||||
"""
|
||||
"""Load hint files from a directory. Returns formatted text or None."""
|
||||
self._loaded_dirs.add(directory)
|
||||
|
||||
# Reject paths outside the working directory tree.
|
||||
try:
|
||||
if not directory.is_relative_to(self.working_dir):
|
||||
logger.debug(
|
||||
"Skipping hint files in %s — outside working_dir %s",
|
||||
directory, self.working_dir,
|
||||
)
|
||||
return None
|
||||
except (OSError, ValueError):
|
||||
if not _is_ancestor_or_same(self.working_dir, directory):
|
||||
logger.debug(
|
||||
"Skipping hint files in %s — outside working_dir %s",
|
||||
directory, self.working_dir,
|
||||
)
|
||||
return None
|
||||
|
||||
found_hints = []
|
||||
for filename in _HINT_FILENAMES:
|
||||
hint_path = directory / filename
|
||||
|
||||
@@ -17,39 +17,16 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
Wraps the functions extracted into codex_responses_adapter.py (PR 1).
|
||||
"""
|
||||
|
||||
# Issuer kind of the most recent build_kwargs / convert_messages call.
|
||||
# Used as a fallback when normalize_response is invoked without an
|
||||
# explicit ``issuer_kind`` kwarg, so reasoning items captured from a
|
||||
# response are stamped with the endpoint that minted them. Plain class
|
||||
# attribute default; mutated on the instance, not the class.
|
||||
_last_issuer_kind: Optional[str] = None
|
||||
|
||||
@property
|
||||
def api_mode(self) -> str:
|
||||
return "codex_responses"
|
||||
|
||||
def _resolve_issuer_kind(self, params: Dict[str, Any]) -> str:
|
||||
"""Classify the current Responses endpoint from transport params."""
|
||||
from agent.codex_responses_adapter import _classify_responses_issuer
|
||||
return _classify_responses_issuer(
|
||||
is_xai_responses=bool(params.get("is_xai_responses")),
|
||||
is_github_responses=bool(params.get("is_github_responses")),
|
||||
is_codex_backend=bool(params.get("is_codex_backend")),
|
||||
base_url=params.get("base_url"),
|
||||
)
|
||||
|
||||
def convert_messages(self, messages: List[Dict[str, Any]], **kwargs) -> Any:
|
||||
"""Convert OpenAI chat messages to Responses API input items."""
|
||||
from agent.codex_responses_adapter import _chat_messages_to_responses_input
|
||||
issuer = self._resolve_issuer_kind(kwargs)
|
||||
self._last_issuer_kind = issuer
|
||||
return _chat_messages_to_responses_input(
|
||||
messages,
|
||||
is_xai_responses=bool(kwargs.get("is_xai_responses")),
|
||||
replay_encrypted_reasoning=bool(
|
||||
kwargs.get("replay_encrypted_reasoning", True)
|
||||
),
|
||||
current_issuer_kind=issuer,
|
||||
)
|
||||
|
||||
def convert_tools(self, tools: List[Dict[str, Any]]) -> Any:
|
||||
@@ -102,17 +79,6 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
is_github_responses = params.get("is_github_responses", False)
|
||||
is_codex_backend = params.get("is_codex_backend", False)
|
||||
is_xai_responses = params.get("is_xai_responses", False)
|
||||
replay_encrypted_reasoning = bool(
|
||||
params.get("replay_encrypted_reasoning", True)
|
||||
)
|
||||
|
||||
# Resolve the issuing endpoint for this call. Stashed on the
|
||||
# transport so normalize_response can stamp it onto reasoning
|
||||
# items captured from the response, and passed to the input
|
||||
# converter so foreign-issuer reasoning blocks in history are
|
||||
# dropped before the API rejects them.
|
||||
issuer_kind = self._resolve_issuer_kind(params)
|
||||
self._last_issuer_kind = issuer_kind
|
||||
|
||||
# Resolve reasoning effort
|
||||
reasoning_effort = "medium"
|
||||
@@ -128,27 +94,17 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
reasoning_effort = _effort_clamp.get(reasoning_effort, reasoning_effort)
|
||||
|
||||
response_tools = _responses_tools(tools)
|
||||
# ``tools`` MUST be omitted entirely when there are no functions to
|
||||
# expose: the openai SDK's ``responses.stream()`` / ``responses.parse()``
|
||||
# eagerly call ``_make_tools(tools)`` which does ``for tool in tools``
|
||||
# without a None guard, so passing ``tools=None`` raises
|
||||
# ``TypeError: 'NoneType' object is not iterable`` before any HTTP
|
||||
# request is issued (openai==2.24.0). Reported for the
|
||||
# ``openai-codex`` / ``gpt-5.5`` combo on chatgpt.com/backend-api/codex
|
||||
# (#32892) when the agent runs without external tools registered.
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"instructions": instructions,
|
||||
"input": _chat_messages_to_responses_input(
|
||||
payload_messages,
|
||||
is_xai_responses=is_xai_responses,
|
||||
replay_encrypted_reasoning=replay_encrypted_reasoning,
|
||||
current_issuer_kind=issuer_kind,
|
||||
),
|
||||
"tools": response_tools,
|
||||
"store": False,
|
||||
}
|
||||
if response_tools:
|
||||
kwargs["tools"] = response_tools
|
||||
kwargs["tool_choice"] = "auto"
|
||||
kwargs["parallel_tool_calls"] = True
|
||||
|
||||
@@ -165,9 +121,7 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
# replay them on subsequent turns for cross-turn coherence.
|
||||
# See agent/codex_responses_adapter._chat_messages_to_responses_input
|
||||
# for the May 2026 reversal of the earlier suppression gate.
|
||||
kwargs["include"] = (
|
||||
["reasoning.encrypted_content"] if replay_encrypted_reasoning else []
|
||||
)
|
||||
kwargs["include"] = ["reasoning.encrypted_content"]
|
||||
# xAI rejects `reasoning.effort` on grok-4 / grok-4-fast / grok-3
|
||||
# / grok-code-fast / grok-4.20-0309-* with HTTP 400 even though
|
||||
# those models reason natively. Only send the effort dial when
|
||||
@@ -182,9 +136,7 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
kwargs["reasoning"] = github_reasoning
|
||||
else:
|
||||
kwargs["reasoning"] = {"effort": reasoning_effort, "summary": "auto"}
|
||||
kwargs["include"] = (
|
||||
["reasoning.encrypted_content"] if replay_encrypted_reasoning else []
|
||||
)
|
||||
kwargs["include"] = ["reasoning.encrypted_content"]
|
||||
elif not is_github_responses and not is_xai_responses:
|
||||
kwargs["include"] = []
|
||||
|
||||
@@ -192,17 +144,6 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
if request_overrides:
|
||||
kwargs.update(request_overrides)
|
||||
|
||||
# xAI Responses API rejects ``service_tier`` (HTTP 400 "Argument not
|
||||
# supported: service_tier") — hit when ``/fast`` priority-processing
|
||||
# mode lingers from a prior model in the same session, or when a
|
||||
# user explicitly sets ``agent.service_tier`` in config.yaml. The
|
||||
# main-loop guard (``resolve_fast_mode_overrides`` only returns
|
||||
# ``service_tier`` for OpenAI fast-eligible models) doesn't cover
|
||||
# those leak paths, so strip defensively when targeting xAI. See
|
||||
# #28490 for the original report.
|
||||
if is_xai_responses:
|
||||
kwargs.pop("service_tier", None)
|
||||
|
||||
# Forward per-request timeout to the SDK so OpenAI/Anthropic clients
|
||||
# honor it. Without this, ``providers.<id>.request_timeout_seconds``
|
||||
# is silently dropped on the main agent Codex path while the
|
||||
@@ -272,13 +213,8 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
_normalize_codex_response,
|
||||
)
|
||||
|
||||
# Issuer for this response = explicit kwarg if the caller knows it,
|
||||
# otherwise the stash from the matching build_kwargs/convert_messages
|
||||
# call. Either way it gets stamped onto reasoning items so future
|
||||
# turns can detect a model swap and drop foreign-issuer blobs.
|
||||
issuer_kind = kwargs.get("issuer_kind") or self._last_issuer_kind
|
||||
# _normalize_codex_response returns (SimpleNamespace, finish_reason_str)
|
||||
msg, finish_reason = _normalize_codex_response(response, issuer_kind=issuer_kind)
|
||||
msg, finish_reason = _normalize_codex_response(response)
|
||||
|
||||
tool_calls = None
|
||||
if msg and msg.tool_calls:
|
||||
|
||||
@@ -711,8 +711,8 @@ def normalize_usage(
|
||||
output_tokens = _to_int(getattr(response_usage, "completion_tokens", 0))
|
||||
details = getattr(response_usage, "prompt_tokens_details", None)
|
||||
# Primary: OpenAI-style prompt_tokens_details. Fallback: Anthropic-style
|
||||
# top-level fields that some OpenAI-compatible proxies (OpenRouter, Cline)
|
||||
# expose when routing Claude models — without this
|
||||
# top-level fields that some OpenAI-compatible proxies (OpenRouter, Vercel
|
||||
# AI Gateway, Cline) expose when routing Claude models — without this
|
||||
# fallback, cache writes are undercounted as 0 and cache reads can be
|
||||
# missed when the proxy only surfaces them at the top level.
|
||||
# Port of cline/cline#10266.
|
||||
|
||||
40
apps/bootstrap-installer/.gitignore
vendored
@@ -1,40 +0,0 @@
|
||||
# Rust / Cargo
|
||||
/src-tauri/target/
|
||||
/src-tauri/Cargo.lock
|
||||
|
||||
# Vite / build output
|
||||
/dist/
|
||||
/dist-ssr/
|
||||
*.local
|
||||
|
||||
# TypeScript build info + tsc emit (we don't ship .js for the
|
||||
# vite.config.ts; Vite reads it directly via ts-node-style loader).
|
||||
*.tsbuildinfo
|
||||
vite.config.d.ts
|
||||
vite.config.js
|
||||
|
||||
# Tauri generated artifacts (regenerated on each build)
|
||||
/src-tauri/gen/schemas/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Internal placeholder (re-create if needed)
|
||||
.tauri-note
|
||||
@@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hermes Setup</title>
|
||||
</head>
|
||||
<body class="h-full antialiased">
|
||||
<div id="root" class="h-full"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"name": "@hermes/bootstrap-installer",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"description": "Hermes Setup — signed installer that drives scripts/install.ps1 with a polished native UI.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 5175",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"tauri:build:debug": "tauri build --debug"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "0.16.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
"@tauri-apps/plugin-opener": "^2.0.0",
|
||||
"@tauri-apps/plugin-process": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"@vscode/codicons": "^0.0.45",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"katex": "^0.16.45",
|
||||
"lucide-react": "^0.577.0",
|
||||
"nanostores": "^1.3.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-shimmer": "^0.4.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
[package]
|
||||
name = "hermes-bootstrap"
|
||||
version = "0.0.1"
|
||||
description = "Hermes Setup — signed installer that drives scripts/install.ps1"
|
||||
authors = ["Nous Research <info@nousresearch.com>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
|
||||
# Rename the output binary so the distributed artifact is literally
|
||||
# `Hermes-Setup.exe` on disk — not `hermes-bootstrap.exe`. Grandma sees
|
||||
# what we hand her, period. Tauri honors [[bin]] over [package].name
|
||||
# for the produced executable name.
|
||||
[[bin]]
|
||||
name = "Hermes-Setup"
|
||||
path = "src/main.rs"
|
||||
|
||||
# The library target name MUST match the `withGlobalTauri` binding name that
|
||||
# tauri.conf.json's `app.windows[].label` references. We don't ship a separate
|
||||
# lib for now; everything is in src/.
|
||||
[lib]
|
||||
name = "hermes_bootstrap_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
# Tauri runtime + plugins
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
|
||||
# Async + IO
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures = "0.3"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# HTTP — rustls so we don't need OpenSSL on the build box
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
|
||||
|
||||
# Logging — emitted to a file under HERMES_HOME/logs/ and (optionally) the
|
||||
# webview console via Tauri's event channel.
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
tracing-appender = "0.2"
|
||||
|
||||
# Paths + utils
|
||||
dirs = "5"
|
||||
which = "6"
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
once_cell = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
# Process control on Windows (CREATE_NO_WINDOW etc.)
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Console",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[profile.release]
|
||||
# A 5-10MB signed installer is the goal. LTO + size-opt + single codegen unit.
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
strip = true
|
||||
@@ -1,150 +0,0 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
// -----------------------------------------------------------------
|
||||
// Bake the install.ps1 pin into the binary at compile time.
|
||||
//
|
||||
// BUILD_PIN_COMMIT and BUILD_PIN_BRANCH are read by bootstrap.rs's
|
||||
// `option_env!()` macro to default the install-script reference.
|
||||
// Precedence (matches install.ps1's own arg precedence): commit > branch.
|
||||
//
|
||||
// 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`.
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
let commit = resolve_commit_pin();
|
||||
let branch = resolve_branch_pin();
|
||||
|
||||
if let Some(c) = &commit {
|
||||
println!("cargo:rustc-env=BUILD_PIN_COMMIT={c}");
|
||||
println!("cargo:warning=hermes-bootstrap: pinning to commit {}", short(c));
|
||||
}
|
||||
if let Some(b) = &branch {
|
||||
println!("cargo:rustc-env=BUILD_PIN_BRANCH={b}");
|
||||
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
|
||||
// at runtime with "no install-script pin supplied". A build that
|
||||
// can't resolve a pin almost certainly indicates a misconfigured
|
||||
// build environment.
|
||||
println!(
|
||||
"cargo:warning=hermes-bootstrap: no pin resolved at build time; binary will fail at runtime without HERMES_SETUP_DEV_REPO_ROOT or runtime args"
|
||||
);
|
||||
}
|
||||
|
||||
// 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 {
|
||||
println!("cargo:rerun-if-changed={}/HEAD", gd.display());
|
||||
// .git/HEAD often points at a ref (e.g. `ref: refs/heads/bb/gui`);
|
||||
// also watch the ref itself so a new commit on the same branch
|
||||
// re-triggers.
|
||||
if let Ok(head) = std::fs::read_to_string(gd.join("HEAD")) {
|
||||
if let Some(rest) = head.trim().strip_prefix("ref: ") {
|
||||
println!("cargo:rerun-if-changed={}/{}", gd.display(), rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("cargo:rerun-if-env-changed=HERMES_BUILD_PIN_COMMIT");
|
||||
println!("cargo:rerun-if-env-changed=HERMES_BUILD_PIN_BRANCH");
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Tauri windows manifest. See hermes-setup.manifest for rationale —
|
||||
// declares level="asInvoker" so Windows's installer-detection
|
||||
// heuristic doesn't refuse to launch us without UAC elevation.
|
||||
// -----------------------------------------------------------------
|
||||
#[cfg(target_os = "windows")]
|
||||
let attrs = {
|
||||
let manifest = include_str!("hermes-setup.manifest");
|
||||
let win = tauri_build::WindowsAttributes::new().app_manifest(manifest);
|
||||
tauri_build::Attributes::new().windows_attributes(win)
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let attrs = tauri_build::Attributes::new();
|
||||
|
||||
tauri_build::try_build(attrs).expect("failed to run tauri-build");
|
||||
}
|
||||
|
||||
fn resolve_commit_pin() -> Option<String> {
|
||||
if let Ok(v) = std::env::var("HERMES_BUILD_PIN_COMMIT") {
|
||||
if !v.trim().is_empty() {
|
||||
return Some(v.trim().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)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_branch_pin() -> Option<String> {
|
||||
if let Ok(v) = std::env::var("HERMES_BUILD_PIN_BRANCH") {
|
||||
if !v.trim().is_empty() {
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
let out = Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
|
||||
// "HEAD" is what you get on a detached checkout — no meaningful branch
|
||||
// to pin to. The commit pin still applies; just don't emit a branch.
|
||||
if s.is_empty() || s == "HEAD" {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
|
||||
fn locate_git_dir() -> Option<std::path::PathBuf> {
|
||||
let out = Command::new("git")
|
||||
.args(["rev-parse", "--git-dir"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(std::path::PathBuf::from(s))
|
||||
}
|
||||
|
||||
fn short(commit: &str) -> &str {
|
||||
if commit.len() >= 12 {
|
||||
&commit[..12]
|
||||
} else {
|
||||
commit
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2/capability",
|
||||
"identifier": "default",
|
||||
"description": "Capabilities required by Hermes Setup. Narrowly scoped: we don't write user files outside HERMES_HOME, we don't read arbitrary paths, and the only external network call goes through reqwest (Rust side, not exposed to the webview).",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:event:default",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"process:default",
|
||||
"shell:default"
|
||||
]
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<!--
|
||||
Hermes Setup application manifest.
|
||||
|
||||
The TL;DR: tell Windows we are NOT an installer in the classic "needs
|
||||
UAC elevation" sense, despite the product name. We provision into
|
||||
%LOCALAPPDATA%\hermes which is user-scoped and never touch HKLM or
|
||||
Program Files. install.ps1 runs as a child process and elevates
|
||||
itself only if a future stage explicitly needs HKLM access.
|
||||
|
||||
Without this manifest, the "Hermes Setup" productName embedded in
|
||||
the binary's resource trips Windows's installer-detection heuristic
|
||||
(https://learn.microsoft.com/en-us/windows/security/identity-protection/
|
||||
user-account-control/how-user-account-control-works#installer-detection)
|
||||
and CreateProcess fails with ERROR_ELEVATION_REQUIRED (740) when the
|
||||
user double-clicks. asInvoker disables that.
|
||||
-->
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<assemblyIdentity
|
||||
version="0.0.1.0"
|
||||
processorArchitecture="*"
|
||||
name="NousResearch.Hermes.Setup"
|
||||
type="win32"
|
||||
/>
|
||||
<description>Hermes Setup</description>
|
||||
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
|
||||
<!-- Tell Windows we know about all supported OSes (10 + 11) so it
|
||||
doesn't shim us into Vista-compat mode. -->
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 / 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
<!-- Windows 8.1 -->
|
||||
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
|
||||
<!-- Windows 8 -->
|
||||
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
|
||||
<!-- Windows 7 -->
|
||||
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
|
||||
<!-- Windows Vista -->
|
||||
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
<!-- Per-monitor v2 DPI awareness so the installer doesn't go blurry
|
||||
on high-DPI displays when dragged between monitors. -->
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
|
||||
<!-- Use the modern common controls (v6 themes). Without this, our
|
||||
file picker / shell dialogs fall back to 1990s-era visuals. -->
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity
|
||||
type="win32"
|
||||
name="Microsoft.Windows.Common-Controls"
|
||||
version="6.0.0.0"
|
||||
processorArchitecture="*"
|
||||
publicKeyToken="6595b64144ccf1df"
|
||||
language="*"
|
||||
/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
</assembly>
|
||||
|
Before Width: | Height: | Size: 674 KiB |
|
Before Width: | Height: | Size: 674 KiB |
|
Before Width: | Height: | Size: 674 KiB |
|
Before Width: | Height: | Size: 78 KiB |
@@ -1,700 +0,0 @@
|
||||
//! Bootstrap orchestration.
|
||||
//!
|
||||
//! Direct port of `runBootstrap` from `apps/desktop/electron/bootstrap-runner.cjs`.
|
||||
//! Drives install.ps1 / install.sh stage-by-stage, emits progress events
|
||||
//! over the Tauri `bootstrap` channel, writes a forensic log to
|
||||
//! HERMES_HOME/logs/bootstrap-<timestamp>.log.
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//! 1. `start_bootstrap` (Tauri command) → spawns the worker task.
|
||||
//! 2. Worker resolves install script (dev/cache/download).
|
||||
//! 3. Worker calls `install.ps1 -Manifest` → emits `manifest` event.
|
||||
//! 4. Worker iterates stages, calling `install.ps1 -Stage NAME -NonInteractive -Json`.
|
||||
//! 5. On success → `complete`. On any stage failure → `failed`. On cancel → `failed`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
use crate::events::{BootstrapEvent, Manifest, StageState};
|
||||
use crate::install_script::{self, Pin, ScriptKind, ScriptSource};
|
||||
use crate::powershell::{self, StreamSink};
|
||||
use crate::AppState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Frontend → Rust: kick off the install.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct StartBootstrapArgs {
|
||||
/// Optional override for the commit pin. Defaults to the build-time
|
||||
/// pin baked in via `BUILD_PIN_COMMIT`.
|
||||
pub commit: Option<String>,
|
||||
/// Optional override for the branch pin. Defaults to `BUILD_PIN_BRANCH`.
|
||||
pub branch: Option<String>,
|
||||
/// Include Stage-Desktop (build apps/desktop) in the manifest. The
|
||||
/// signed bootstrap installer passes true; the deprecated Electron-side
|
||||
/// bootstrap-runner passes false to avoid building-while-running.
|
||||
#[serde(default = "default_true")]
|
||||
pub include_desktop: bool,
|
||||
/// Optional override for HERMES_HOME. Tests use this; production
|
||||
/// almost always falls back to the OS default.
|
||||
pub hermes_home: Option<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct BootstrapStatus {
|
||||
pub running: bool,
|
||||
pub completed: bool,
|
||||
pub install_root: Option<String>,
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
/// Handle stored in AppState while a bootstrap run is in flight. Carries
|
||||
/// the cancellation channel and the most recent terminal status so the
|
||||
/// frontend can re-query after a window refresh.
|
||||
pub struct BootstrapHandle {
|
||||
pub cancel_tx: mpsc::Sender<()>,
|
||||
pub started_at: Instant,
|
||||
pub status: BootstrapStatus,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_bootstrap(
|
||||
app: AppHandle,
|
||||
state: State<'_, Arc<AppState>>,
|
||||
args: StartBootstrapArgs,
|
||||
) -> Result<(), String> {
|
||||
let mut guard = state.bootstrap.lock().await;
|
||||
if let Some(h) = guard.as_ref() {
|
||||
if h.status.running {
|
||||
return Err("Bootstrap is already running".into());
|
||||
}
|
||||
}
|
||||
|
||||
let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1);
|
||||
let handle = BootstrapHandle {
|
||||
cancel_tx,
|
||||
started_at: Instant::now(),
|
||||
status: BootstrapStatus {
|
||||
running: true,
|
||||
completed: false,
|
||||
install_root: None,
|
||||
last_error: None,
|
||||
},
|
||||
};
|
||||
*guard = Some(handle);
|
||||
drop(guard);
|
||||
|
||||
let app_for_task = app.clone();
|
||||
let state_for_task = state.inner().clone();
|
||||
let args_for_task = args;
|
||||
let cancel_rx = Arc::new(Mutex::new(Some(cancel_rx)));
|
||||
|
||||
tokio::spawn(async move {
|
||||
let result = run_bootstrap(app_for_task.clone(), args_for_task, cancel_rx).await;
|
||||
|
||||
// Reflect terminal state into AppState so get_bootstrap_status()
|
||||
// can serve it after the task exits.
|
||||
let mut guard = state_for_task.bootstrap.lock().await;
|
||||
if let Some(h) = guard.as_mut() {
|
||||
h.status.running = false;
|
||||
match &result {
|
||||
Ok(install_root) => {
|
||||
h.status.completed = true;
|
||||
h.status.install_root = Some(install_root.clone());
|
||||
h.status.last_error = None;
|
||||
}
|
||||
Err(err) => {
|
||||
h.status.completed = false;
|
||||
h.status.last_error = Some(err.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_bootstrap(state: State<'_, Arc<AppState>>) -> Result<(), String> {
|
||||
let guard = state.bootstrap.lock().await;
|
||||
if let Some(h) = guard.as_ref() {
|
||||
let _ = h.cancel_tx.try_send(());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_bootstrap_status(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
) -> Result<BootstrapStatus, String> {
|
||||
let guard = state.bootstrap.lock().await;
|
||||
Ok(match guard.as_ref() {
|
||||
Some(h) => BootstrapStatus {
|
||||
running: h.status.running,
|
||||
completed: h.status.completed,
|
||||
install_root: h.status.install_root.clone(),
|
||||
last_error: h.status.last_error.clone(),
|
||||
},
|
||||
None => BootstrapStatus {
|
||||
running: false,
|
||||
completed: false,
|
||||
install_root: None,
|
||||
last_error: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn the locally-built Hermes desktop binary, then close the installer
|
||||
/// window. Caller resolves the binary path from `install_root`.
|
||||
///
|
||||
/// Returns Err with a human-readable message if the binary doesn't exist
|
||||
/// (e.g. when Stage-Desktop was skipped) so the frontend can present
|
||||
/// actionable failure UI rather than silently doing nothing.
|
||||
#[tauri::command]
|
||||
pub async fn launch_hermes_desktop(
|
||||
app: AppHandle,
|
||||
install_root: String,
|
||||
) -> Result<(), String> {
|
||||
let install_root = PathBuf::from(install_root);
|
||||
let exe_path = resolve_hermes_desktop_exe(&install_root).ok_or_else(|| {
|
||||
format!(
|
||||
"Couldn't find a built Hermes desktop at {}. The desktop build step \
|
||||
may have been skipped or failed. Run `hermes desktop` from a \
|
||||
terminal to build and launch it.",
|
||||
install_root.join("apps").join("desktop").join("release").display()
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(?exe_path, "launching Hermes desktop");
|
||||
|
||||
// Detach from us — the installer is about to exit.
|
||||
let mut cmd = tokio::process::Command::new(&exe_path);
|
||||
cmd.current_dir(exe_path.parent().unwrap_or(&install_root));
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// DETACHED_PROCESS = 0x00000008
|
||||
cmd.creation_flags(0x0000_0008);
|
||||
}
|
||||
|
||||
cmd.spawn().map_err(|e| {
|
||||
format!(
|
||||
"failed to launch {}: {e}",
|
||||
exe_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Give Windows ~150ms to actually start the new process before we exit.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
|
||||
// Exit the installer cleanly. Tauri's process plugin gives us the
|
||||
// right hook regardless of platform.
|
||||
app.exit(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Walks the well-known electron-builder unpacked-app paths under
|
||||
/// `install_root`. Mirrors the resolver in `cmd_gui` (apps/desktop/release/
|
||||
/// <os>-unpacked/<exe>).
|
||||
fn resolve_hermes_desktop_exe(install_root: &std::path::Path) -> Option<PathBuf> {
|
||||
let release_dir = install_root.join("apps").join("desktop").join("release");
|
||||
let candidates: &[(&str, &str)] = if cfg!(target_os = "windows") {
|
||||
&[
|
||||
("win-unpacked", "Hermes.exe"),
|
||||
("win-arm64-unpacked", "Hermes.exe"),
|
||||
]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
&[
|
||||
("mac/Hermes.app/Contents/MacOS", "Hermes"),
|
||||
("mac-arm64/Hermes.app/Contents/MacOS", "Hermes"),
|
||||
]
|
||||
} else {
|
||||
&[("linux-unpacked", "hermes")]
|
||||
};
|
||||
for (subdir, exe) in candidates {
|
||||
let p = release_dir.join(subdir).join(exe);
|
||||
if p.exists() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bootstrap implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn run_bootstrap(
|
||||
app: AppHandle,
|
||||
args: StartBootstrapArgs,
|
||||
cancel_rx_holder: Arc<Mutex<Option<mpsc::Receiver<()>>>>,
|
||||
) -> Result<String> {
|
||||
let kind = ScriptKind::for_current_os();
|
||||
|
||||
let pin = Pin {
|
||||
commit: args.commit.or_else(|| option_env_string("BUILD_PIN_COMMIT")),
|
||||
branch: args.branch.or_else(|| option_env_string("BUILD_PIN_BRANCH")),
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
?pin,
|
||||
kind = ?kind,
|
||||
include_desktop = args.include_desktop,
|
||||
"bootstrap starting"
|
||||
);
|
||||
|
||||
let app_for_log = app.clone();
|
||||
let emit_log = move |line: &str| {
|
||||
emit_event(
|
||||
&app_for_log,
|
||||
BootstrapEvent::Log {
|
||||
stage: None,
|
||||
line: line.to_string(),
|
||||
},
|
||||
);
|
||||
// Bump to info-level so the line shows in bootstrap-installer.log
|
||||
// under the default INFO filter. Previously this was debug! which
|
||||
// got dropped on the floor, leaving us blind whenever install.ps1
|
||||
// failed — the log only had the "bootstrap starting" banner.
|
||||
tracing::info!(target: "bootstrap.log", "{line}");
|
||||
};
|
||||
|
||||
// 1. Resolve install.ps1
|
||||
let script = install_script::resolve(kind, &pin, &emit_log)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
let msg = format!("resolve install script failed: {e:#}");
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: msg.clone(),
|
||||
},
|
||||
);
|
||||
anyhow!(msg)
|
||||
})?;
|
||||
|
||||
let source_note = match &script.source {
|
||||
ScriptSource::DevCheckout => "dev checkout",
|
||||
ScriptSource::Bundled => "bundled",
|
||||
ScriptSource::Cached => "cached",
|
||||
ScriptSource::Downloaded => "downloaded",
|
||||
};
|
||||
emit_log(&format!(
|
||||
"[bootstrap] script {} via {}",
|
||||
script.path.display(),
|
||||
source_note
|
||||
));
|
||||
|
||||
// 2. Fetch manifest
|
||||
//
|
||||
// -IncludeDesktop MUST be passed to the manifest call too — install.ps1
|
||||
// gates the desktop stage inclusion on this flag, so without it here
|
||||
// the manifest comes back missing the desktop stage and we never run
|
||||
// it. The per-stage call below also passes -IncludeDesktop to keep
|
||||
// the contracts identical.
|
||||
let manifest_args = build_pin_args(&script);
|
||||
let mut manifest_args_full = vec!["-Manifest".to_string()];
|
||||
manifest_args_full.extend(manifest_args.clone());
|
||||
if args.include_desktop {
|
||||
manifest_args_full.push("-IncludeDesktop".to_string());
|
||||
}
|
||||
|
||||
let manifest_result = run_install_script(
|
||||
&app,
|
||||
&script.path,
|
||||
&manifest_args_full,
|
||||
args.hermes_home.as_deref(),
|
||||
None,
|
||||
Some("__manifest__".to_string()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if manifest_result.exit_code != Some(0) {
|
||||
let err = format!(
|
||||
"install.ps1 -Manifest failed: exit {:?}\n{}",
|
||||
manifest_result.exit_code,
|
||||
manifest_result.stderr.trim()
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(err));
|
||||
}
|
||||
|
||||
let manifest: Manifest = powershell::parse_manifest(&manifest_result.stdout).ok_or_else(|| {
|
||||
let err = format!(
|
||||
"install.ps1 -Manifest produced no parseable JSON payload\n{}",
|
||||
truncate(&manifest_result.stdout, 4000)
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: None,
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
anyhow!(err)
|
||||
})?;
|
||||
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Manifest {
|
||||
stages: manifest.stages.clone(),
|
||||
protocol_version: manifest.protocol_version,
|
||||
},
|
||||
);
|
||||
|
||||
// 3. Iterate stages.
|
||||
for stage in &manifest.stages {
|
||||
// Skip Stage-Desktop unless explicitly requested. install.ps1 may
|
||||
// or may not include it in the manifest depending on the flag we
|
||||
// pass, but if it slipped in, gate client-side too.
|
||||
if !args.include_desktop && stage.name.eq_ignore_ascii_case("desktop") {
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Skipped,
|
||||
duration_ms: Some(0),
|
||||
result: None,
|
||||
error: Some("skipped by include_desktop=false".into()),
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if cancellation_signalled(&cancel_rx_holder).await {
|
||||
let err = "bootstrap cancelled by user".to_string();
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some(stage.name.clone()),
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(err));
|
||||
}
|
||||
|
||||
let started = Instant::now();
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Running,
|
||||
duration_ms: None,
|
||||
result: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
let mut stage_args = vec![
|
||||
"-Stage".to_string(),
|
||||
stage.name.clone(),
|
||||
"-NonInteractive".to_string(),
|
||||
"-Json".to_string(),
|
||||
];
|
||||
stage_args.extend(manifest_args.clone());
|
||||
if args.include_desktop {
|
||||
stage_args.push("-IncludeDesktop".to_string());
|
||||
}
|
||||
|
||||
// Each stage gets its own cancel receiver because tokio::select!
|
||||
// in run_script consumes it. Take/return through the Arc<Mutex>.
|
||||
let local_cancel_rx = cancel_rx_holder.lock().await.take();
|
||||
|
||||
let stage_result = run_install_script(
|
||||
&app,
|
||||
&script.path,
|
||||
&stage_args,
|
||||
args.hermes_home.as_deref(),
|
||||
local_cancel_rx,
|
||||
Some(stage.name.clone()),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let duration_ms = started.elapsed().as_millis() as u64;
|
||||
|
||||
if stage_result.killed {
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Failed,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: None,
|
||||
error: Some("cancelled by user".into()),
|
||||
},
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some(stage.name.clone()),
|
||||
error: "cancelled by user".into(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!("cancelled by user"));
|
||||
}
|
||||
|
||||
let result_frame = powershell::parse_stage_result(&stage_result.stdout);
|
||||
|
||||
match result_frame {
|
||||
None => {
|
||||
let err = format!(
|
||||
"install.ps1 -Stage {} produced no JSON result frame (exit={:?})",
|
||||
stage.name, stage_result.exit_code
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Failed,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: None,
|
||||
error: Some(err.clone()),
|
||||
},
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some(stage.name.clone()),
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(err));
|
||||
}
|
||||
Some(frame) if frame.ok && frame.skipped => {
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Skipped,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: Some(frame),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Some(frame) if frame.ok => {
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Succeeded,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: Some(frame),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
Some(frame) => {
|
||||
let err = frame
|
||||
.reason
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("exit code {:?}", stage_result.exit_code));
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Stage {
|
||||
name: stage.name.clone(),
|
||||
state: StageState::Failed,
|
||||
duration_ms: Some(duration_ms),
|
||||
result: Some(frame),
|
||||
error: Some(err.clone()),
|
||||
},
|
||||
);
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Failed {
|
||||
stage: Some(stage.name.clone()),
|
||||
error: err.clone(),
|
||||
},
|
||||
);
|
||||
return Err(anyhow!(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Resolve install_root. install.ps1 doesn't (yet) report this back
|
||||
// explicitly; we infer it from $HermesHome which Stage-Repository clones
|
||||
// the repo INTO at $HermesHome\hermes-agent. Mirrors hermes_constants.
|
||||
let hermes_home = args
|
||||
.hermes_home
|
||||
.clone()
|
||||
.unwrap_or_else(|| crate::paths::hermes_home().to_string_lossy().into_owned());
|
||||
let install_root = PathBuf::from(&hermes_home).join("hermes-agent");
|
||||
|
||||
emit_event(
|
||||
&app,
|
||||
BootstrapEvent::Complete {
|
||||
install_root: install_root.to_string_lossy().into_owned(),
|
||||
marker: Some(serde_json::json!({
|
||||
"pinnedCommit": pin.commit,
|
||||
"pinnedBranch": pin.branch,
|
||||
})),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(install_root.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
async fn cancellation_signalled(holder: &Arc<Mutex<Option<mpsc::Receiver<()>>>>) -> bool {
|
||||
let mut guard = holder.lock().await;
|
||||
if let Some(rx) = guard.as_mut() {
|
||||
rx.try_recv().is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_install_script(
|
||||
app: &AppHandle,
|
||||
script_path: &std::path::Path,
|
||||
args: &[String],
|
||||
hermes_home_override: Option<&str>,
|
||||
cancel_rx: Option<mpsc::Receiver<()>>,
|
||||
stage_name: Option<String>,
|
||||
) -> Result<powershell::ScriptResult> {
|
||||
let app_for_stdout = app.clone();
|
||||
let stage_for_stdout = stage_name.clone();
|
||||
let app_for_stderr = app.clone();
|
||||
let stage_for_stderr = stage_name.clone();
|
||||
let stage_for_stdout_log = stage_name.clone();
|
||||
let stage_for_stderr_log = stage_name.clone();
|
||||
|
||||
let sink = StreamSink {
|
||||
on_stdout_line: Box::new(move |line: &str| {
|
||||
emit_event(
|
||||
&app_for_stdout,
|
||||
BootstrapEvent::Log {
|
||||
stage: stage_for_stdout.clone(),
|
||||
line: line.to_string(),
|
||||
},
|
||||
);
|
||||
// Tee to the rolling installer log so we have a persistent
|
||||
// record of every install.ps1 line. Without this, the only
|
||||
// log evidence of a failure was the Tauri event stream —
|
||||
// which gets discarded the moment the failure route mounts.
|
||||
match &stage_for_stdout_log {
|
||||
Some(name) => {
|
||||
tracing::info!(target: "bootstrap.log", stage = %name, "{line}")
|
||||
}
|
||||
None => tracing::info!(target: "bootstrap.log", "{line}"),
|
||||
}
|
||||
}),
|
||||
on_stderr_line: Box::new(move |line: &str| {
|
||||
emit_event(
|
||||
&app_for_stderr,
|
||||
BootstrapEvent::Log {
|
||||
stage: stage_for_stderr.clone(),
|
||||
line: format!("stderr: {line}"),
|
||||
},
|
||||
);
|
||||
// stderr-level lines get warn! so they're visually distinct
|
||||
// when scrolling through the log later.
|
||||
match &stage_for_stderr_log {
|
||||
Some(name) => {
|
||||
tracing::warn!(target: "bootstrap.log", stage = %name, "stderr: {line}")
|
||||
}
|
||||
None => tracing::warn!(target: "bootstrap.log", "stderr: {line}"),
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
powershell::run_script(script_path, args, sink, hermes_home_override, cancel_rx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!(?e, "install script invocation failed");
|
||||
anyhow!("install script invocation failed: {e:#}")
|
||||
})
|
||||
}
|
||||
|
||||
fn build_pin_args(script: &install_script::ResolvedScript) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(c) = &script.commit {
|
||||
out.push("-Commit".to_string());
|
||||
out.push(c.clone());
|
||||
}
|
||||
if let Some(b) = &script.branch {
|
||||
out.push("-Branch".to_string());
|
||||
out.push(b.clone());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn emit_event(app: &AppHandle, event: BootstrapEvent) {
|
||||
// Tee important state transitions to the rolling installer log so
|
||||
// bootstrap-installer.log isn't just "starting" + final summary.
|
||||
// Log lines (the noisy stuff) handle their own tracing in
|
||||
// run_install_script's sink; here we cover the lifecycle frames.
|
||||
match &event {
|
||||
BootstrapEvent::Manifest { stages, .. } => {
|
||||
tracing::info!(
|
||||
stage_count = stages.len(),
|
||||
names = ?stages.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
|
||||
"manifest received"
|
||||
);
|
||||
}
|
||||
BootstrapEvent::Stage {
|
||||
name,
|
||||
state,
|
||||
duration_ms,
|
||||
error,
|
||||
..
|
||||
} => {
|
||||
tracing::info!(
|
||||
stage = %name,
|
||||
?state,
|
||||
duration_ms = ?duration_ms,
|
||||
error = ?error,
|
||||
"stage transition"
|
||||
);
|
||||
}
|
||||
BootstrapEvent::Complete { install_root, .. } => {
|
||||
tracing::info!(install_root = %install_root, "bootstrap complete");
|
||||
}
|
||||
BootstrapEvent::Failed { stage, error } => {
|
||||
tracing::error!(stage = ?stage, error = %error, "bootstrap FAILED");
|
||||
}
|
||||
BootstrapEvent::Log { .. } => {
|
||||
// Log lines are teed via the sink callbacks in
|
||||
// run_install_script — don't double-emit here.
|
||||
}
|
||||
}
|
||||
if let Err(e) = app.emit(BootstrapEvent::CHANNEL, &event) {
|
||||
tracing::warn!(?e, "failed to emit bootstrap event");
|
||||
}
|
||||
}
|
||||
|
||||
fn option_env_string(key: &str) -> Option<String> {
|
||||
// option_env! only accepts literals, so we hardcode the known keys.
|
||||
let val = match key {
|
||||
"BUILD_PIN_COMMIT" => option_env!("BUILD_PIN_COMMIT"),
|
||||
"BUILD_PIN_BRANCH" => option_env!("BUILD_PIN_BRANCH"),
|
||||
_ => None,
|
||||
};
|
||||
val.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}...", &s[..max])
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
//! Event types streamed from Rust → React.
|
||||
//!
|
||||
//! These mirror `apps/desktop/electron/bootstrap-runner.cjs`'s event shape
|
||||
//! 1:1 so the React installer code can be roughly identical to the Electron
|
||||
//! install-overlay we'll replace.
|
||||
//!
|
||||
//! The Tauri event channel name is `"bootstrap"` for all of these — the
|
||||
//! `type` discriminator on each payload is how the frontend routes.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Stage definition as reported by `install.ps1 -Manifest`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StageInfo {
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
pub category: String,
|
||||
/// `needs_user_input=true` stages run with -NonInteractive and emit
|
||||
/// skipped=true; the post-install wizard takes over for those.
|
||||
#[serde(rename = "needs_user_input", alias = "needsUserInput")]
|
||||
pub needs_user_input: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub stages: Vec<StageInfo>,
|
||||
#[serde(rename = "protocol_version", alias = "protocolVersion", default)]
|
||||
pub protocol_version: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StageResultPayload {
|
||||
pub stage: String,
|
||||
pub ok: bool,
|
||||
#[serde(default)]
|
||||
pub skipped: bool,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
/// install.ps1 may attach stage-specific structured data here.
|
||||
#[serde(default)]
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Run-state for a single stage as we transition through it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StageState {
|
||||
Running,
|
||||
Succeeded,
|
||||
Skipped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// The single event channel `bootstrap` emits these. `type` discriminates.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum BootstrapEvent {
|
||||
/// Sent once at the start with the full stage list.
|
||||
Manifest {
|
||||
stages: Vec<StageInfo>,
|
||||
#[serde(rename = "protocolVersion")]
|
||||
protocol_version: Option<u32>,
|
||||
},
|
||||
/// Stage state transition. `result` populated only on terminal states.
|
||||
Stage {
|
||||
name: String,
|
||||
state: StageState,
|
||||
#[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
|
||||
duration_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<StageResultPayload>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
/// Raw stdout/stderr line from install.ps1 (or our wrapper).
|
||||
Log {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stage: Option<String>,
|
||||
line: String,
|
||||
},
|
||||
/// Sent once when all stages complete successfully.
|
||||
Complete {
|
||||
#[serde(rename = "installRoot")]
|
||||
install_root: String,
|
||||
marker: Option<serde_json::Value>,
|
||||
},
|
||||
/// Sent once if the run aborts.
|
||||
Failed {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stage: Option<String>,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl BootstrapEvent {
|
||||
/// Tauri event name. Single channel for all bootstrap events; the
|
||||
/// `type` tag tells the renderer how to interpret the payload.
|
||||
pub const CHANNEL: &'static str = "bootstrap";
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
//! Resolves and downloads `scripts/install.ps1` (and `install.sh`).
|
||||
//!
|
||||
//! Resolution order:
|
||||
//! 1. Dev shortcut: a sibling repo checkout via $HERMES_SETUP_DEV_REPO_ROOT
|
||||
//! env var. Lets devs iterate without re-publishing the script.
|
||||
//! 2. Bundled fallback: if the installer was bundled with a script (e.g.
|
||||
//! tauri's `resource` mechanism), serve from there. Not used today.
|
||||
//! 3. Network: download from GitHub raw at a pinned commit or branch.
|
||||
//! Commit pins are immutable; branch pins are HEAD-tracking.
|
||||
//!
|
||||
//! Mirrors `apps/desktop/electron/bootstrap-runner.cjs`'s `resolveInstallScript`,
|
||||
//! but the dev-checkout resolution is driven by an env var rather than the
|
||||
//! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant
|
||||
//! to live OUTSIDE any repo checkout.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
/// Identity of the install.ps1 we'll execute. Used by both the manifest
|
||||
/// fetch and the per-stage runs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedScript {
|
||||
pub path: PathBuf,
|
||||
pub source: ScriptSource,
|
||||
/// Commit pin (40-char SHA) if known. install.ps1's `-Commit` arg is
|
||||
/// what makes the repo stage clone the exact tested SHA.
|
||||
pub commit: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ScriptSource {
|
||||
DevCheckout,
|
||||
Bundled,
|
||||
Cached,
|
||||
Downloaded,
|
||||
}
|
||||
|
||||
/// What flavor of script (Windows .ps1 vs Unix .sh).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ScriptKind {
|
||||
Ps1,
|
||||
Sh,
|
||||
}
|
||||
|
||||
impl ScriptKind {
|
||||
pub fn for_current_os() -> Self {
|
||||
if cfg!(target_os = "windows") {
|
||||
Self::Ps1
|
||||
} else {
|
||||
Self::Sh
|
||||
}
|
||||
}
|
||||
|
||||
fn filename(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ps1 => "install.ps1",
|
||||
Self::Sh => "install.sh",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a string looks like a git SHA (7+ hex chars). Mirrors
|
||||
/// `STAMP_COMMIT_RE` from bootstrap-runner.cjs.
|
||||
fn is_valid_commit(s: &str) -> bool {
|
||||
let len = s.len();
|
||||
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Resolves the install script to use for this run.
|
||||
///
|
||||
/// `pin` is the commit-or-branch from either Hermes-Setup's build-time
|
||||
/// constant (compiled into the installer) or a runtime override.
|
||||
pub async fn resolve(
|
||||
kind: ScriptKind,
|
||||
pin: &Pin,
|
||||
emit_log: &impl Fn(&str),
|
||||
) -> Result<ResolvedScript> {
|
||||
// 1. Dev shortcut.
|
||||
if let Ok(repo_root) = std::env::var("HERMES_SETUP_DEV_REPO_ROOT") {
|
||||
let candidate = PathBuf::from(repo_root).join("scripts").join(kind.filename());
|
||||
if candidate.exists() {
|
||||
emit_log(&format!(
|
||||
"[bootstrap] dev mode — using local {} at {}",
|
||||
kind.filename(),
|
||||
candidate.display()
|
||||
));
|
||||
return Ok(ResolvedScript {
|
||||
path: candidate,
|
||||
source: ScriptSource::DevCheckout,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. (Not implemented) bundled fallback.
|
||||
|
||||
// 3. Network. Pin must be a real commit or a branch ref.
|
||||
let commit_or_ref = match (&pin.commit, &pin.branch) {
|
||||
(Some(c), _) if is_valid_commit(c) => c.clone(),
|
||||
(_, Some(b)) if !b.trim().is_empty() => b.clone(),
|
||||
(Some(other), _) => {
|
||||
return Err(anyhow!(
|
||||
"install script pin commit `{other}` is not a valid git SHA"
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"no install-script pin supplied — installer cannot resolve a script source"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let cached = cached_path(kind, &commit_or_ref);
|
||||
if cached.exists() {
|
||||
emit_log(&format!(
|
||||
"[bootstrap] using cached {} for {}",
|
||||
kind.filename(),
|
||||
truncate_ref(&commit_or_ref)
|
||||
));
|
||||
return Ok(ResolvedScript {
|
||||
path: cached,
|
||||
source: ScriptSource::Cached,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
emit_log(&format!(
|
||||
"[bootstrap] downloading {} for {} from GitHub",
|
||||
kind.filename(),
|
||||
truncate_ref(&commit_or_ref)
|
||||
));
|
||||
|
||||
download(kind, &commit_or_ref, &cached).await?;
|
||||
|
||||
emit_log(&format!("[bootstrap] cached to {}", cached.display()));
|
||||
|
||||
Ok(ResolvedScript {
|
||||
path: cached,
|
||||
source: ScriptSource::Downloaded,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Pin {
|
||||
pub commit: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
fn cached_path(kind: ScriptKind, commit_or_ref: &str) -> PathBuf {
|
||||
let safe = sanitize_ref(commit_or_ref);
|
||||
let filename = match kind {
|
||||
ScriptKind::Ps1 => format!("install-{safe}.ps1"),
|
||||
ScriptKind::Sh => format!("install-{safe}.sh"),
|
||||
};
|
||||
paths::bootstrap_cache_dir().join(filename)
|
||||
}
|
||||
|
||||
/// Replace anything that's not [A-Za-z0-9._-] with `_`. Branch refs can
|
||||
/// contain `/`, dots, etc.; we want a flat filename.
|
||||
fn sanitize_ref(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn truncate_ref(s: &str) -> &str {
|
||||
if is_valid_commit(s) && s.len() >= 12 {
|
||||
&s[..12]
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads to `dest_path` via reqwest with rustls. Atomically renames
|
||||
/// `dest_path.tmp` → `dest_path` so partial writes don't poison the cache.
|
||||
async fn download(kind: ScriptKind, commit_or_ref: &str, dest_path: &Path) -> Result<()> {
|
||||
let url = format!(
|
||||
"https://raw.githubusercontent.com/NousResearch/hermes-agent/{}/scripts/{}",
|
||||
commit_or_ref,
|
||||
kind.filename()
|
||||
);
|
||||
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("creating bootstrap-cache parent dir {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
|
||||
let tmp_path = dest_path.with_extension({
|
||||
let ext = dest_path
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("tmp");
|
||||
format!("{ext}.tmp")
|
||||
});
|
||||
|
||||
let response = reqwest::Client::new()
|
||||
.get(&url)
|
||||
.header("User-Agent", "hermes-setup/0.0.1")
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("GET {url}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Failed to download {}: HTTP {} from {}",
|
||||
kind.filename(),
|
||||
response.status(),
|
||||
url
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.with_context(|| format!("reading body of {url}"))?;
|
||||
|
||||
let mut file = tokio::fs::File::create(&tmp_path)
|
||||
.await
|
||||
.with_context(|| format!("creating temp file {}", tmp_path.display()))?;
|
||||
file.write_all(&bytes)
|
||||
.await
|
||||
.with_context(|| format!("writing temp file {}", tmp_path.display()))?;
|
||||
file.flush().await.context("flushing temp file")?;
|
||||
drop(file);
|
||||
|
||||
tokio::fs::rename(&tmp_path, dest_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"renaming {} → {}",
|
||||
tmp_path.display(),
|
||||
dest_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_valid_commit_accepts_short_and_full_shas() {
|
||||
assert!(is_valid_commit("02d26981d3d4ad50e142399b8476f59ad5953ff0"));
|
||||
assert!(is_valid_commit("02d2698"));
|
||||
assert!(!is_valid_commit("02d269"));
|
||||
assert!(!is_valid_commit("not-a-sha"));
|
||||
assert!(!is_valid_commit(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ref_replaces_slashes() {
|
||||
assert_eq!(sanitize_ref("bb/gui"), "bb_gui");
|
||||
assert_eq!(sanitize_ref("main"), "main");
|
||||
assert_eq!(sanitize_ref("release/1.2.3"), "release_1.2.3");
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
//! Hermes Setup — Tauri entrypoint.
|
||||
//!
|
||||
//! Spawns a single window pointed at the React frontend (apps/bootstrap-installer/src/).
|
||||
//! All install-time work lives in `bootstrap.rs` and is invoked through the Tauri
|
||||
//! commands registered at the bottom of `run()`.
|
||||
//!
|
||||
//! The Windows-subsystem strip lives on the binary crate (src/main.rs), not
|
||||
//! here — a crate-level attribute on a lib doesn't propagate to the linker
|
||||
//! flags of the executable that consumes it.
|
||||
|
||||
mod bootstrap;
|
||||
mod events;
|
||||
mod install_script;
|
||||
mod powershell;
|
||||
mod paths;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Process-wide install state, shared across Tauri commands.
|
||||
///
|
||||
/// The bootstrap is a one-shot, single-tenant process — we only need one
|
||||
/// of these per window. `Arc<Mutex<...>>` lets command handlers grab it
|
||||
/// without lifetime gymnastics.
|
||||
pub struct AppState {
|
||||
pub bootstrap: Mutex<Option<bootstrap::BootstrapHandle>>,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bootstrap: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// Tracing → bootstrap-installer.log under HERMES_HOME/logs/ so install
|
||||
// failures leave a trail for support. Console output also goes here in
|
||||
// debug builds.
|
||||
let _guard = paths::init_logging();
|
||||
|
||||
tracing::info!("Hermes Setup starting");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(Arc::new(AppState::default()))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Bootstrap lifecycle
|
||||
bootstrap::start_bootstrap,
|
||||
bootstrap::cancel_bootstrap,
|
||||
bootstrap::get_bootstrap_status,
|
||||
// Hand-off
|
||||
bootstrap::launch_hermes_desktop,
|
||||
// Diagnostics
|
||||
paths::get_log_path,
|
||||
paths::get_hermes_home,
|
||||
paths::open_log_dir,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Hermes Setup");
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
// Hermes Setup — process entrypoint. All logic lives in lib.rs so it can
|
||||
// be unit-tested as a library; this file just calls into it.
|
||||
//
|
||||
// The windows_subsystem attribute MUST live here on the binary crate
|
||||
// (not lib.rs) — placing it on the lib was the bug that left a stray
|
||||
// cmd window behind Hermes-Setup.exe on release builds.
|
||||
//
|
||||
// `windows_subsystem = "windows"` strips the console allocation that
|
||||
// the default `windows_subsystem = "console"` would do, so double-clicking
|
||||
// the .exe gives you ONLY the Tauri window.
|
||||
//
|
||||
// debug_assertions guard: dev builds keep the console so tracing output
|
||||
// is visible during `cargo tauri dev`.
|
||||
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
hermes_bootstrap_lib::run()
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
//! Filesystem paths + logging setup.
|
||||
//!
|
||||
//! Mirrors `hermes_constants.get_hermes_home()` from the Python CLI:
|
||||
//! Windows: %LOCALAPPDATA%\hermes
|
||||
//! 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
|
||||
//! the bootstrap-complete check.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
|
||||
/// Returns the canonical Hermes home directory, respecting $HERMES_HOME if set.
|
||||
pub fn hermes_home() -> PathBuf {
|
||||
if let Ok(override_path) = std::env::var("HERMES_HOME") {
|
||||
if !override_path.trim().is_empty() {
|
||||
return PathBuf::from(override_path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// %LOCALAPPDATA%\hermes — matches scripts/install.ps1's $HermesHome.
|
||||
if let Some(local_app_data) = dirs::data_local_dir() {
|
||||
return local_app_data.join("hermes");
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
// Last resort — current dir, almost certainly wrong but at least
|
||||
// doesn't panic.
|
||||
PathBuf::from(".hermes")
|
||||
}
|
||||
|
||||
pub fn log_dir() -> PathBuf {
|
||||
hermes_home().join("logs")
|
||||
}
|
||||
|
||||
pub fn log_path() -> PathBuf {
|
||||
log_dir().join("bootstrap-installer.log")
|
||||
}
|
||||
|
||||
pub fn bootstrap_cache_dir() -> PathBuf {
|
||||
hermes_home().join("bootstrap-cache")
|
||||
}
|
||||
|
||||
/// Where install.ps1 writes the bootstrap-complete marker (existence-only file
|
||||
/// the Electron app also checks). Per main.cjs:
|
||||
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
|
||||
/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so
|
||||
/// this is a probe helper, not a definitive path.
|
||||
pub fn likely_bootstrap_marker(install_root: &Path) -> PathBuf {
|
||||
install_root.join(".hermes-bootstrap-complete")
|
||||
}
|
||||
|
||||
/// Initializes tracing to bootstrap-installer.log under HERMES_HOME/logs/.
|
||||
/// Returns a guard that flushes the appender on drop — keep it alive for
|
||||
/// the lifetime of the process.
|
||||
pub fn init_logging() -> Option<WorkerGuard> {
|
||||
let dir = log_dir();
|
||||
if let Err(err) = std::fs::create_dir_all(&dir) {
|
||||
// No log dir → log to stderr only. Don't panic; the installer
|
||||
// should still be usable on an exotic filesystem.
|
||||
eprintln!("[hermes-setup] could not create log dir {dir:?}: {err}");
|
||||
return None;
|
||||
}
|
||||
|
||||
let file_appender = tracing_appender::rolling::never(&dir, "bootstrap-installer.log");
|
||||
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
let env_filter = tracing_subscriber::EnvFilter::try_from_env("HERMES_BOOTSTRAP_LOG")
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(env_filter)
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false)
|
||||
.with_target(true)
|
||||
.init();
|
||||
|
||||
Some(guard)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_log_path() -> String {
|
||||
log_path().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_hermes_home() -> String {
|
||||
hermes_home().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_log_dir(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
let path = log_dir();
|
||||
app.opener()
|
||||
.open_path(path.to_string_lossy(), None::<&str>)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
//! Drives PowerShell (Windows) or bash (Unix) for install.ps1 / install.sh.
|
||||
//!
|
||||
//! Port of `spawnPowerShell` from bootstrap-runner.cjs, with the same
|
||||
//! line-buffered stdout/stderr streaming + cancellation semantics.
|
||||
//!
|
||||
//! On Windows we pass `-NoProfile -ExecutionPolicy Bypass -File <script>`.
|
||||
//! On Unix we shell out to `bash <script>` since install.sh expects bash.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Hooks the caller installs to receive output.
|
||||
pub struct StreamSink {
|
||||
pub on_stdout_line: Box<dyn Fn(&str) + Send + Sync>,
|
||||
pub on_stderr_line: Box<dyn Fn(&str) + Send + Sync>,
|
||||
}
|
||||
|
||||
/// Outcome of a script invocation. Mirrors bootstrap-runner.cjs's
|
||||
/// `{stdout, stderr, code, signal, killed}` shape.
|
||||
#[derive(Debug)]
|
||||
pub struct ScriptResult {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub exit_code: Option<i32>,
|
||||
pub killed: bool,
|
||||
}
|
||||
|
||||
/// Cancellation signal — `cancel_tx.send(()).await` aborts the running script.
|
||||
pub type CancelRx = mpsc::Receiver<()>;
|
||||
|
||||
/// Spawns install.ps1 / install.sh with the given args and streams output.
|
||||
///
|
||||
/// `hermes_home_override` propagates to the child as $HERMES_HOME so the
|
||||
/// install script writes to the same directory the installer is reading from.
|
||||
pub async fn run_script(
|
||||
script_path: &Path,
|
||||
args: &[String],
|
||||
sink: StreamSink,
|
||||
hermes_home_override: Option<&str>,
|
||||
mut cancel_rx: Option<CancelRx>,
|
||||
) -> Result<ScriptResult> {
|
||||
let mut cmd = build_command(script_path, args);
|
||||
|
||||
if let Some(home) = hermes_home_override {
|
||||
cmd.env("HERMES_HOME", home);
|
||||
}
|
||||
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
// On Windows, avoid spawning a flashing cmd window when we're hosted
|
||||
// inside a GUI process. Tauri's main window is already created, so
|
||||
// the side-effect console for the child is unwanted.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// CREATE_NO_WINDOW = 0x08000000
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
let mut child: Child = cmd
|
||||
.spawn()
|
||||
.with_context(|| format!("spawning {}", script_path.display()))?;
|
||||
|
||||
let stdout = child.stdout.take().expect("stdout was piped");
|
||||
let stderr = child.stderr.take().expect("stderr was piped");
|
||||
|
||||
let mut stdout_reader = BufReader::new(stdout).lines();
|
||||
let mut stderr_reader = BufReader::new(stderr).lines();
|
||||
|
||||
let mut combined_stdout = String::new();
|
||||
let mut combined_stderr = String::new();
|
||||
let mut killed = false;
|
||||
|
||||
// Loop: poll stdout, stderr, cancel, and child exit concurrently.
|
||||
loop {
|
||||
tokio::select! {
|
||||
line = stdout_reader.next_line() => {
|
||||
match line {
|
||||
Ok(Some(l)) => {
|
||||
(sink.on_stdout_line)(&l);
|
||||
combined_stdout.push_str(&l);
|
||||
combined_stdout.push('\n');
|
||||
}
|
||||
Ok(None) => {
|
||||
// EOF on stdout — wait for stderr + exit.
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("stdout read error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
line = stderr_reader.next_line() => {
|
||||
match line {
|
||||
Ok(Some(l)) => {
|
||||
(sink.on_stderr_line)(&l);
|
||||
combined_stderr.push_str(&l);
|
||||
combined_stderr.push('\n');
|
||||
}
|
||||
Ok(None) => {
|
||||
// stderr EOF — keep draining stdout.
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("stderr read error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = recv_cancel(&mut cancel_rx) => {
|
||||
tracing::warn!("cancellation received — killing child");
|
||||
killed = true;
|
||||
// best-effort kill; don't propagate errors
|
||||
let _ = child.start_kill();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drain remaining lines after the loop exited.
|
||||
while let Ok(Some(l)) = stdout_reader.next_line().await {
|
||||
(sink.on_stdout_line)(&l);
|
||||
combined_stdout.push_str(&l);
|
||||
combined_stdout.push('\n');
|
||||
}
|
||||
while let Ok(Some(l)) = stderr_reader.next_line().await {
|
||||
(sink.on_stderr_line)(&l);
|
||||
combined_stderr.push_str(&l);
|
||||
combined_stderr.push('\n');
|
||||
}
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.context("waiting for install script to exit")?;
|
||||
|
||||
Ok(ScriptResult {
|
||||
stdout: combined_stdout,
|
||||
stderr: combined_stderr,
|
||||
exit_code: status.code(),
|
||||
killed,
|
||||
})
|
||||
}
|
||||
|
||||
async fn recv_cancel(rx: &mut Option<CancelRx>) {
|
||||
match rx {
|
||||
Some(r) => {
|
||||
let _ = r.recv().await;
|
||||
}
|
||||
None => std::future::pending::<()>().await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn build_command(script_path: &Path, args: &[String]) -> Command {
|
||||
// We want PowerShell 5.1 / 7. install.ps1 uses 5.1-safe syntax everywhere.
|
||||
// Prefer `powershell.exe` (5.1 baseline, present on every Windows since 7)
|
||||
// over `pwsh.exe` (7+, may not be present).
|
||||
let mut cmd = Command::new("powershell.exe");
|
||||
cmd.arg("-NoProfile");
|
||||
cmd.arg("-ExecutionPolicy").arg("Bypass");
|
||||
cmd.arg("-File").arg(script_path);
|
||||
for a in args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn build_command(script_path: &Path, args: &[String]) -> Command {
|
||||
// install.sh expects bash. /bin/bash is fine on macOS (Apple still
|
||||
// ships an old 3.2 bash; install.sh is written to that baseline).
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg(script_path);
|
||||
for a in args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Parses the LAST line of stdout that looks like a JSON object matching
|
||||
/// the install.ps1 stage-result contract: `{ok: bool, stage: string, ...}`.
|
||||
///
|
||||
/// Mirrors `parseStageResult` from bootstrap-runner.cjs. install.ps1 may
|
||||
/// print info/banner lines before the result frame; we scan from the end.
|
||||
pub fn parse_stage_result(stdout: &str) -> Option<crate::events::StageResultPayload> {
|
||||
for line in stdout.lines().rev() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
if value.get("ok").and_then(|v| v.as_bool()).is_some()
|
||||
&& value.get("stage").and_then(|v| v.as_str()).is_some()
|
||||
{
|
||||
if let Ok(parsed) =
|
||||
serde_json::from_value::<crate::events::StageResultPayload>(value)
|
||||
{
|
||||
return Some(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Same logic but for the `-Manifest` payload (the LAST line with a `stages`
|
||||
/// array). Returns the parsed manifest.
|
||||
pub fn parse_manifest(stdout: &str) -> Option<crate::events::Manifest> {
|
||||
for line in stdout.lines().rev() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
if value.get("stages").and_then(|v| v.as_array()).is_some() {
|
||||
if let Ok(parsed) = serde_json::from_value::<crate::events::Manifest>(value) {
|
||||
return Some(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_stage_result_picks_last_json_line() {
|
||||
let stdout = r#"
|
||||
[bootstrap] some info
|
||||
{"ok": false, "stage": "venv", "reason": "bad python"}
|
||||
{"ok": true, "stage": "venv"}
|
||||
final non-json banner
|
||||
"#;
|
||||
let result = parse_stage_result(stdout).unwrap();
|
||||
assert_eq!(result.stage, "venv");
|
||||
assert!(result.ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_manifest_finds_stages_array() {
|
||||
let stdout = r#"
|
||||
info line
|
||||
{"stages": [{"name": "uv", "title": "uv", "category": "prereqs", "needs_user_input": false}], "protocol_version": 1}
|
||||
"#;
|
||||
let m = parse_manifest(stdout).unwrap();
|
||||
assert_eq!(m.stages.len(), 1);
|
||||
assert_eq!(m.stages[0].name, "uv");
|
||||
assert_eq!(m.protocol_version, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_returns_none_when_no_match() {
|
||||
assert!(parse_stage_result("just banner\n").is_none());
|
||||
assert!(parse_manifest("just banner\n").is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Hermes Setup",
|
||||
"version": "0.0.1",
|
||||
"identifier": "com.nousresearch.hermes.setup",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://127.0.0.1:5175",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Hermes Setup",
|
||||
"width": 880,
|
||||
"height": 620,
|
||||
"minWidth": 720,
|
||||
"minHeight": 520,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
|
||||
},
|
||||
"withGlobalTauri": false
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "Hermes Setup",
|
||||
"longDescription": "Installs Hermes Agent on your machine. Drives scripts/install.ps1 (Windows) and scripts/install.sh (macOS/Linux).",
|
||||
"publisher": "Nous Research",
|
||||
"copyright": "Copyright © 2026 Nous Research",
|
||||
"targets": [
|
||||
"app",
|
||||
"dmg",
|
||||
"appimage"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
"type": "embedBootstrapper"
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "11.0",
|
||||
"hardenedRuntime": true
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect } from 'react'
|
||||
import { $route, $bootstrap, initialize } from './store'
|
||||
import Welcome from './routes/welcome'
|
||||
import Progress from './routes/progress'
|
||||
import Success from './routes/success'
|
||||
import Failure from './routes/failure'
|
||||
|
||||
/*
|
||||
* App shell — Hermes Setup.
|
||||
*
|
||||
* No header chrome (the OS title bar already says "Hermes Setup"; an
|
||||
* in-window repeat of the H mark + words was redundant slop).
|
||||
*
|
||||
* Route state lives in a single $route atom — 4 screens, no react-router.
|
||||
*/
|
||||
export default function App() {
|
||||
const route = useStore($route)
|
||||
const bootstrap = useStore($bootstrap)
|
||||
|
||||
useEffect(() => {
|
||||
void initialize()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col overflow-hidden bg-background text-foreground">
|
||||
<main className="relative z-10 flex flex-1 flex-col overflow-hidden">
|
||||
{route === 'welcome' && <Welcome />}
|
||||
{route === 'progress' && <Progress bootstrap={bootstrap} />}
|
||||
{route === 'success' && <Success />}
|
||||
{route === 'failure' && <Failure bootstrap={bootstrap} />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Slot } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
/*
|
||||
* Button — copied verbatim from apps/desktop/src/components/ui/button.tsx.
|
||||
*
|
||||
* We import the desktop's local shadcn-style Button rather than
|
||||
* @nous-research/ui's <Button>, because the DS Button uses bg-midground /
|
||||
* text-background-base utilities that resolve to the DS's hardcoded
|
||||
* gold/brown brand defaults (#ffac02 / #170d02) unless overridden in
|
||||
* runtime. The desktop never sets those vars; it routes through its
|
||||
* own --dt-* token chain via shadcn classes like bg-primary. We do
|
||||
* the same so visuals match exactly.
|
||||
*/
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-xs':
|
||||
"size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ComponentProps<'button'>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const Comp = asChild ? Slot.Root : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
data-size={size}
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { buttonVariants }
|
||||
@@ -1,12 +0,0 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
/*
|
||||
* cn — Tailwind-aware class merger. Same util the desktop and dashboard
|
||||
* use. clsx handles conditional classes; twMerge resolves utility
|
||||
* conflicts so `cn('px-2', condition && 'px-4')` ends up with px-4 only,
|
||||
* not both.
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './app.tsx'
|
||||
import './styles.css'
|
||||
|
||||
// Default to LIGHT mode — matches the Hermes desktop's default. The
|
||||
// desktop's runtime theme system can switch to .dark later, but our
|
||||
// installer ships in light mode only since we don't carry the theme
|
||||
// provider machinery.
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -1,77 +0,0 @@
|
||||
import { type CSSProperties } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Button } from '../components/button'
|
||||
import {
|
||||
$logPath,
|
||||
openLogDir,
|
||||
startInstall,
|
||||
type BootstrapStateModel
|
||||
} from '../store'
|
||||
import { RefreshCw, FileText } from 'lucide-react'
|
||||
|
||||
interface FailureProps {
|
||||
bootstrap: BootstrapStateModel
|
||||
}
|
||||
|
||||
/*
|
||||
* Failure screen. Same hero treatment as Welcome/Success — the wordmark
|
||||
* carries the brand, so we keep it across every terminal state.
|
||||
*
|
||||
* The actual error message lives below in muted text. Two clear
|
||||
* affordances: Retry (primary) and Open log folder (secondary).
|
||||
*/
|
||||
export default function Failure({ bootstrap }: FailureProps) {
|
||||
const logPath = useStore($logPath)
|
||||
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-6 px-12 py-10">
|
||||
<div className="w-full max-w-2xl min-w-0 text-center">
|
||||
<p
|
||||
className="fit-text mx-auto mb-4 w-full font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-destructive mix-blend-plus-lighter dark:text-destructive/90"
|
||||
style={
|
||||
{
|
||||
'--fit-text-line-height': '0.9',
|
||||
'--fit-text-max': '5rem',
|
||||
'--fit-text-min': '2.25rem'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>Install didn’t finish</span>
|
||||
</span>
|
||||
<span aria-hidden="true">Install didn’t finish</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 mx-auto max-w-xl text-center text-sm leading-normal tracking-tight text-muted-foreground">
|
||||
{bootstrap.error ?? 'Something went wrong during installation.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => void startInstall()}
|
||||
size="lg"
|
||||
className="inline-flex items-center gap-2 px-6"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Retry install
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => void openLogDir()}
|
||||
className="inline-flex items-center gap-2"
|
||||
>
|
||||
<FileText size={16} />
|
||||
Open log folder
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{logPath && (
|
||||
<p className="max-w-lg text-center text-xs text-muted-foreground/70">
|
||||
Log: <code className="font-mono">{logPath}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { Button } from '../components/button'
|
||||
import {
|
||||
cancelInstall,
|
||||
$progress,
|
||||
type BootstrapStateModel,
|
||||
type StageState
|
||||
} from '../store'
|
||||
import { Check, X, ChevronRight, FileText, Loader2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
interface ProgressProps {
|
||||
bootstrap: BootstrapStateModel
|
||||
}
|
||||
|
||||
/*
|
||||
* Progress screen — drives a stage list + collapsible log panel. Uses
|
||||
* the DS <Progress> for the top bar so its motion + ring match the rest
|
||||
* of the product.
|
||||
*/
|
||||
export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
||||
const progress = useStore($progress)
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
const logEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (showLogs && logEndRef.current) {
|
||||
logEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [bootstrap.logs.length, showLogs])
|
||||
|
||||
const currentStage =
|
||||
bootstrap.currentStage != null
|
||||
? bootstrap.stages[bootstrap.currentStage]
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col">
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<div className="mb-3 flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
{bootstrap.status === 'running' && (
|
||||
<Loader2 size={12} className="animate-spin text-primary" />
|
||||
)}
|
||||
<span>
|
||||
{bootstrap.status === 'running'
|
||||
? currentStage
|
||||
? currentStage.info.title
|
||||
: 'Preparing\u2026'
|
||||
: bootstrap.status === 'completed'
|
||||
? 'Done'
|
||||
: 'Installing'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{progress.done} of {progress.total} steps
|
||||
</div>
|
||||
</div>
|
||||
{/* Top progress bar — plain HTML, derived from --primary so it
|
||||
tracks the theme accent. */}
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.max(2, progress.fraction * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<ol className="space-y-1">
|
||||
{bootstrap.stageOrder.map((name) => {
|
||||
const rec = bootstrap.stages[name]
|
||||
if (!rec) return null
|
||||
return (
|
||||
<li
|
||||
key={name}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
|
||||
rec.state === 'running' && 'bg-card text-foreground',
|
||||
rec.state === 'succeeded' && 'text-foreground/80',
|
||||
rec.state === 'skipped' && 'text-muted-foreground',
|
||||
rec.state === 'failed' &&
|
||||
'bg-destructive/10 text-destructive',
|
||||
!rec.state && 'text-muted-foreground/60'
|
||||
)}
|
||||
>
|
||||
<StateIcon state={rec.state ?? null} />
|
||||
<span className="flex-1 truncate">{rec.info.title}</span>
|
||||
{rec.durationMs != null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDuration(rec.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{showLogs && (
|
||||
<div className="flex w-1/2 flex-col border-l border-border bg-card/40">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-3 py-2">
|
||||
<div className="text-xs font-medium text-foreground/80">
|
||||
Live output
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{bootstrap.logs.length} lines
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[11px] leading-relaxed">
|
||||
{bootstrap.logs.map((entry, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={clsx(
|
||||
'whitespace-pre-wrap',
|
||||
entry.line.startsWith('stderr:')
|
||||
? 'text-destructive'
|
||||
: 'text-foreground/70'
|
||||
)}
|
||||
>
|
||||
{entry.line}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-border px-6 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLogs((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<FileText size={14} />
|
||||
{showLogs ? 'Hide details' : 'Show details'}
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className={clsx(
|
||||
'transition-transform',
|
||||
showLogs && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{bootstrap.status === 'running' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void cancelInstall()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StateIcon({ state }: { state: StageState | null }) {
|
||||
if (state === 'running') {
|
||||
return <Loader2 size={14} className="animate-spin text-primary" />
|
||||
}
|
||||
if (state === 'succeeded') {
|
||||
return <Check size={14} className="text-emerald-400" />
|
||||
}
|
||||
if (state === 'skipped') {
|
||||
return <ChevronRight size={14} className="text-muted-foreground/70" />
|
||||
}
|
||||
if (state === 'failed') {
|
||||
return <X size={14} className="text-destructive" />
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="h-[6px] w-[6px] rounded-full bg-muted-foreground/40"
|
||||
aria-hidden
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
|
||||
const m = Math.floor(ms / 60000)
|
||||
const s = Math.round((ms % 60000) / 1000)
|
||||
return `${m}m ${s}s`
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { type CSSProperties } from 'react'
|
||||
import { Button } from '../components/button'
|
||||
import { launchHermesDesktop } from '../store'
|
||||
import { Rocket, AlertCircle } from 'lucide-react'
|
||||
|
||||
/*
|
||||
* Success screen. HERMES AGENT wordmark stays as the visual anchor
|
||||
* (same Collapse Bold treatment as Welcome + the desktop chat intro),
|
||||
* with a status line below.
|
||||
*
|
||||
* Launching the desktop can fail (e.g. Stage-Desktop was skipped and
|
||||
* Hermes.exe doesn't exist). We catch the Tauri error and surface it
|
||||
* inline rather than silently doing nothing — the previous version
|
||||
* had `onClick={() => void launchHermesDesktop()}` which swallowed
|
||||
* the rejection and left the user staring at an unresponsive button.
|
||||
*/
|
||||
export default function Success() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [launching, setLaunching] = useState(false)
|
||||
|
||||
async function handleLaunch() {
|
||||
setError(null)
|
||||
setLaunching(true)
|
||||
try {
|
||||
await launchHermesDesktop()
|
||||
// On success the installer exits — control never returns here.
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setError(msg)
|
||||
setLaunching(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-8 px-12 py-10">
|
||||
<div className="w-full max-w-2xl min-w-0 text-center">
|
||||
<p
|
||||
className="fit-text mx-auto mb-4 w-full font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={
|
||||
{
|
||||
'--fit-text-line-height': '0.9',
|
||||
'--fit-text-max': '5rem',
|
||||
'--fit-text-min': '2.25rem'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>Hermes is ready</span>
|
||||
</span>
|
||||
<span aria-hidden="true">Hermes is ready</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 text-center text-base leading-normal tracking-tight text-muted-foreground">
|
||||
You can launch from here, or any time from your terminal with{' '}
|
||||
<code className="rounded bg-muted/60 px-1 py-0.5 font-mono text-sm">
|
||||
hermes desktop
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => void handleLaunch()}
|
||||
size="lg"
|
||||
disabled={launching}
|
||||
className="inline-flex items-center gap-2 px-6"
|
||||
>
|
||||
<Rocket size={18} />
|
||||
{launching ? 'Launching…' : 'Launch Hermes'}
|
||||
</Button>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex max-w-2xl items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
>
|
||||
<AlertCircle size={16} className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">Couldn’t launch the desktop app</div>
|
||||
<div className="mt-1 text-destructive/80">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { type CSSProperties } from 'react'
|
||||
import { Button } from '../components/button'
|
||||
import { startInstall } from '../store'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
|
||||
/*
|
||||
* Welcome screen.
|
||||
*
|
||||
* Mirrors the desktop's chat intro (apps/desktop/src/components/chat/intro.tsx):
|
||||
* - HERMES AGENT wordmark rendered in Collapse Bold, uppercase, tracked
|
||||
* - mix-blend-plus-lighter so the type "glows" on the canvas
|
||||
* - fit-text utility so the wordmark sizes itself to the column
|
||||
*
|
||||
* No install-path footer. The default install location is correct for
|
||||
* 99% of users; the rest will use the CLI installer with a -HermesHome
|
||||
* flag. Showing %LOCALAPPDATA% to grandma is developer-brain.
|
||||
*/
|
||||
export default function Welcome() {
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-10 px-12 py-10">
|
||||
{/* Hero — same recipe the desktop's chat/intro.tsx uses */}
|
||||
<div className="w-full max-w-2xl min-w-0 text-center">
|
||||
<p
|
||||
className="fit-text mx-auto mb-4 w-full font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={
|
||||
{
|
||||
'--fit-text-line-height': '0.9',
|
||||
'--fit-text-max': '6rem',
|
||||
'--fit-text-min': '2.5rem'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>HERMES AGENT</span>
|
||||
</span>
|
||||
<span aria-hidden="true">HERMES AGENT</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 text-center text-base leading-normal tracking-tight text-muted-foreground">
|
||||
The agent that grows with you. We’ll set things up in the
|
||||
background — takes a few minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => void startInstall()}
|
||||
size="lg"
|
||||
className="group inline-flex items-center gap-2 px-6"
|
||||
>
|
||||
Install Hermes
|
||||
<ArrowRight
|
||||
size={18}
|
||||
className="transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
import { atom, computed } from 'nanostores'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
/*
|
||||
* Bootstrap state store — single source of truth for installer screens.
|
||||
*
|
||||
* Lives in nanostores per the project's TypeScript guidelines (apps/desktop
|
||||
* AGENTS.md): "Prefer small nanostores over component state when state is
|
||||
* shared, reused, or read by distant UI."
|
||||
*
|
||||
* One channel from Rust ('bootstrap' event), discriminated by payload.type.
|
||||
* We translate those events into typed atom updates here so the rest of
|
||||
* the app only deals with React-friendly state.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — mirror src-tauri/src/events.rs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StageInfo {
|
||||
name: string
|
||||
title: string
|
||||
category: string
|
||||
needs_user_input: boolean
|
||||
}
|
||||
|
||||
export type StageState = 'running' | 'succeeded' | 'skipped' | 'failed'
|
||||
|
||||
export interface StageRecord {
|
||||
info: StageInfo
|
||||
state: StageState | null
|
||||
durationMs?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BootstrapStateModel {
|
||||
status: 'idle' | 'running' | 'completed' | 'failed'
|
||||
protocolVersion: number | null
|
||||
stages: Record<string, StageRecord>
|
||||
stageOrder: string[]
|
||||
currentStage: string | null
|
||||
installRoot: string | null
|
||||
error: string | null
|
||||
logs: Array<{ stage?: string; line: string }>
|
||||
}
|
||||
|
||||
const INITIAL: BootstrapStateModel = {
|
||||
status: 'idle',
|
||||
protocolVersion: null,
|
||||
stages: {},
|
||||
stageOrder: [],
|
||||
currentStage: null,
|
||||
installRoot: null,
|
||||
error: null,
|
||||
logs: []
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Route = 'welcome' | 'progress' | 'success' | 'failure'
|
||||
|
||||
export const $route = atom<Route>('welcome')
|
||||
export const $bootstrap = atom<BootstrapStateModel>(INITIAL)
|
||||
export const $logPath = atom<string | null>(null)
|
||||
export const $hermesHome = atom<string | null>(null)
|
||||
|
||||
export const $progress = computed($bootstrap, (b) => {
|
||||
const total = b.stageOrder.length
|
||||
if (total === 0) return { done: 0, total: 0, fraction: 0 }
|
||||
let done = 0
|
||||
for (const name of b.stageOrder) {
|
||||
const s = b.stages[name]?.state
|
||||
if (s === 'succeeded' || s === 'skipped' || s === 'failed') done += 1
|
||||
}
|
||||
return { done, total, fraction: done / total }
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri event subscription
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BootstrapManifestEvent {
|
||||
type: 'manifest'
|
||||
stages: StageInfo[]
|
||||
protocolVersion: number | null
|
||||
}
|
||||
|
||||
interface BootstrapStageEvent {
|
||||
type: 'stage'
|
||||
name: string
|
||||
state: StageState
|
||||
durationMs?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface BootstrapLogEvent {
|
||||
type: 'log'
|
||||
stage?: string
|
||||
line: string
|
||||
}
|
||||
|
||||
interface BootstrapCompleteEvent {
|
||||
type: 'complete'
|
||||
installRoot: string
|
||||
marker: unknown
|
||||
}
|
||||
|
||||
interface BootstrapFailedEvent {
|
||||
type: 'failed'
|
||||
stage?: string
|
||||
error: string
|
||||
}
|
||||
|
||||
type BootstrapEvent =
|
||||
| BootstrapManifestEvent
|
||||
| BootstrapStageEvent
|
||||
| BootstrapLogEvent
|
||||
| BootstrapCompleteEvent
|
||||
| BootstrapFailedEvent
|
||||
|
||||
let unlisten: UnlistenFn | null = null
|
||||
|
||||
export async function initialize(): Promise<void> {
|
||||
if (unlisten) return
|
||||
|
||||
// Pull static info on mount for the diagnostics footer.
|
||||
try {
|
||||
const [logPath, hermesHome] = await Promise.all([
|
||||
invoke<string>('get_log_path'),
|
||||
invoke<string>('get_hermes_home')
|
||||
])
|
||||
$logPath.set(logPath)
|
||||
$hermesHome.set(hermesHome)
|
||||
} catch (err) {
|
||||
console.warn('failed to fetch installer paths', err)
|
||||
}
|
||||
|
||||
unlisten = await listen<BootstrapEvent>('bootstrap', (event) => {
|
||||
const payload = event.payload
|
||||
const cur = $bootstrap.get()
|
||||
switch (payload.type) {
|
||||
case 'manifest': {
|
||||
const stages: Record<string, StageRecord> = {}
|
||||
const order: string[] = []
|
||||
for (const s of payload.stages) {
|
||||
stages[s.name] = { info: s, state: null }
|
||||
order.push(s.name)
|
||||
}
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
status: 'running',
|
||||
protocolVersion: payload.protocolVersion,
|
||||
stages,
|
||||
stageOrder: order,
|
||||
currentStage: null,
|
||||
installRoot: null,
|
||||
error: null,
|
||||
logs: []
|
||||
})
|
||||
$route.set('progress')
|
||||
break
|
||||
}
|
||||
case 'stage': {
|
||||
const existing = cur.stages[payload.name]
|
||||
if (!existing) {
|
||||
console.warn('stage event for unknown stage', payload.name)
|
||||
break
|
||||
}
|
||||
const next: StageRecord = {
|
||||
...existing,
|
||||
state: payload.state,
|
||||
durationMs: payload.durationMs,
|
||||
error: payload.error
|
||||
}
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
stages: { ...cur.stages, [payload.name]: next },
|
||||
currentStage:
|
||||
payload.state === 'running' ? payload.name : cur.currentStage
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'log': {
|
||||
const logs = [...cur.logs, { stage: payload.stage, line: payload.line }]
|
||||
// Keep the rolling buffer bounded so the UI doesn't get OOM'd
|
||||
// during a long install (playwright chromium download is ~10k lines).
|
||||
const trimmed = logs.length > 2000 ? logs.slice(-2000) : logs
|
||||
$bootstrap.set({ ...cur, logs: trimmed })
|
||||
break
|
||||
}
|
||||
case 'complete':
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
status: 'completed',
|
||||
installRoot: payload.installRoot,
|
||||
currentStage: null
|
||||
})
|
||||
$route.set('success')
|
||||
break
|
||||
case 'failed':
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
status: 'failed',
|
||||
error: payload.error,
|
||||
currentStage: null
|
||||
})
|
||||
$route.set('failure')
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function startInstall(opts?: { branch?: string }): Promise<void> {
|
||||
// Reset before kicking off so a retry from the failure screen clears
|
||||
// the previous run's state.
|
||||
$bootstrap.set(INITIAL)
|
||||
$route.set('progress')
|
||||
await invoke('start_bootstrap', {
|
||||
args: {
|
||||
commit: null,
|
||||
branch: opts?.branch ?? null,
|
||||
include_desktop: true,
|
||||
hermes_home: null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function cancelInstall(): Promise<void> {
|
||||
await invoke('cancel_bootstrap')
|
||||
}
|
||||
|
||||
export async function launchHermesDesktop(): Promise<void> {
|
||||
const installRoot = $bootstrap.get().installRoot
|
||||
if (!installRoot) throw new Error('no install root')
|
||||
await invoke('launch_hermes_desktop', { installRoot })
|
||||
}
|
||||
|
||||
export async function openLogDir(): Promise<void> {
|
||||
await invoke('open_log_dir')
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/*
|
||||
* Hermes Setup — defer entirely to the desktop's styles.css.
|
||||
*
|
||||
* Rather than re-implement the Hermes design system (and inevitably drift
|
||||
* from it), we import apps/desktop/src/styles.css wholesale. The desktop
|
||||
* is the canonical source of truth for fonts, color tokens, button chrome,
|
||||
* scrollbars, layout utilities, and animations. Any change to the
|
||||
* Hermes look propagates here automatically with no copy-paste maintenance.
|
||||
*
|
||||
* Path resolution caveats:
|
||||
* - Tailwind v4's `@import` resolves relative to this file. The desktop's
|
||||
* `@source '../../../node_modules/...'` declarations therefore re-resolve
|
||||
* against apps/bootstrap-installer/src/. Since both apps live two levels
|
||||
* deep under the same repo root, `../../../node_modules` lands in the
|
||||
* same place. (Verify if either app ever moves.)
|
||||
* - The desktop's `@font-face url('../../../node_modules/...')` references
|
||||
* are baked into the *imported* stylesheet; CSS resolves url()s relative
|
||||
* to the file that contains them, so they continue to point at the
|
||||
* correct node_modules path even from here.
|
||||
*
|
||||
* Forced light mode: the desktop ships with a runtime theme switcher
|
||||
* (ThemeProvider + applyTheme) that can flip to dark via document.documentElement.
|
||||
* The installer has no UI for theme switching, so we stay on the desktop's
|
||||
* default light surface (Nous-blue accent on near-white chrome).
|
||||
*/
|
||||
@import '../../desktop/src/styles.css';
|
||||
|
||||
/* Installer-only additions: a fade-in animation and a warm radial glow
|
||||
for the welcome screen. Everything else inherits from the desktop. */
|
||||
@keyframes hermes-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hermes-fade-in {
|
||||
animation: hermes-fade-in 0.45s ease-out both;
|
||||
}
|
||||
|
||||
.hermes-glow {
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
color-mix(in srgb, var(--ui-warm) 18%, transparent) 0%,
|
||||
transparent 60%
|
||||
);
|
||||
}
|
||||
1
apps/bootstrap-installer/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"esModuleInterop": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'node:path'
|
||||
|
||||
// Hermes Setup — Tauri-targeted Vite config.
|
||||
//
|
||||
// Port 5175 keeps us out of the way of:
|
||||
// apps/dashboard (vite default 5173)
|
||||
// apps/desktop dev (5174 per its package.json)
|
||||
//
|
||||
// `clearScreen: false` is the Tauri convention — they spawn vite as a child
|
||||
// process and want our errors to stay visible.
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 5175,
|
||||
strictPort: true,
|
||||
host: host || '127.0.0.1',
|
||||
hmr: host
|
||||
? {
|
||||
protocol: 'ws',
|
||||
host,
|
||||
port: 5176
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// Don't watch the Rust side — tauri-cli handles it.
|
||||
ignored: ['**/src-tauri/**']
|
||||
}
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true
|
||||
}
|
||||
})
|
||||
|
Before Width: | Height: | Size: 3.7 MiB |
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* AuthWidget — sidebar "Logged in as …" affordance for the dashboard
|
||||
* OAuth gate (Phase 7 of .hermes/plans/2026-05-21-dashboard-oauth-auth.md).
|
||||
*
|
||||
* Renders nothing in loopback / --insecure mode. In gated mode, fetches
|
||||
* /api/auth/me on mount and surfaces:
|
||||
*
|
||||
* - the user_id (truncated to 14 chars + ellipsis) since the Nous Portal
|
||||
* contract V1 doesn't emit email/display_name claims (Contract Anchor
|
||||
* C4 in the plan; the API responds with empty strings for those
|
||||
* fields, so we use user_id as the display value)
|
||||
* - the provider's display_name (looked up from /api/auth/providers,
|
||||
* defaults to the bare provider key)
|
||||
* - a logout button that POSTs /auth/logout and full-page-navigates to
|
||||
* /login (the dashboard becomes inaccessible again)
|
||||
*
|
||||
* Failure modes:
|
||||
* - 401 from /api/auth/me means we're not gated (or the gate is on but
|
||||
* we have no cookie — in that case the gate's middleware would have
|
||||
* redirected us before App.tsx renders, so we won't see this). The
|
||||
* widget renders nothing.
|
||||
* - Network error: shows a minimal "auth status unavailable" message
|
||||
* so the user knows the widget tried.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type AuthMeResponse } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LogOut } from "lucide-react";
|
||||
|
||||
interface AuthWidgetProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Truncate ``user_id`` to fit a small UI without revealing the full
|
||||
* opaque identifier. 14 chars is enough to disambiguate users in a
|
||||
* small org and short enough to fit a single sidebar row. */
|
||||
function truncateUserId(id: string): string {
|
||||
if (id.length <= 14) return id;
|
||||
return `${id.slice(0, 14)}…`;
|
||||
}
|
||||
|
||||
export function AuthWidget({ className }: AuthWidgetProps) {
|
||||
const [me, setMe] = useState<AuthMeResponse | null>(null);
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getAuthMe()
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setMe(data);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
// 401 from /api/auth/me means the gate isn't engaged in this
|
||||
// process (loopback mode) — render nothing. fetchJSON throws an
|
||||
// Error with the status code as a prefix; the global 401
|
||||
// handler only redirects on the structured envelope, so a plain
|
||||
// 401 from /api/auth/me with no envelope bubbles up here.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.startsWith("401:") || msg.startsWith("403:")) {
|
||||
setHidden(true);
|
||||
return;
|
||||
}
|
||||
setError("auth status unavailable");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-5 py-2 text-[0.65rem] tracking-[0.05em] text-muted-foreground/70",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!me) {
|
||||
// Loading. Reserve the row height so the sidebar doesn't flicker
|
||||
// when the data arrives.
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-9 px-5 py-2 text-[0.65rem] text-muted-foreground/40",
|
||||
className,
|
||||
)}
|
||||
aria-busy="true"
|
||||
>
|
||||
…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
void api.logout();
|
||||
};
|
||||
|
||||
// Prefer display_name → email → truncated user_id. Contract V1 only
|
||||
// populates user_id; the fallthroughs are forward-compat for a future
|
||||
// Portal that adds a userinfo endpoint (OQ-C1 in the plan).
|
||||
const label = me.display_name || me.email || truncateUserId(me.user_id);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-between gap-2",
|
||||
"px-5 py-2",
|
||||
"border-t border-current/10",
|
||||
"text-[0.65rem] tracking-[0.05em]",
|
||||
className,
|
||||
)}
|
||||
role="status"
|
||||
aria-label={`Logged in as ${label}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-mono text-foreground/90" title={me.user_id}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground/70">
|
||||
via {me.provider}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={cn(
|
||||
"shrink-0 rounded p-1.5 text-muted-foreground/70",
|
||||
"transition-colors hover:bg-current/10 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-current/40",
|
||||
)}
|
||||
aria-label="Log out"
|
||||
title="Log out"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import {
|
||||
JsonRpcGatewayClient,
|
||||
type ConnectionState,
|
||||
type GatewayEvent,
|
||||
type GatewayEventName,
|
||||
} from "@hermes/shared";
|
||||
|
||||
import { HERMES_BASE_PATH } from "@/lib/api";
|
||||
|
||||
export type { ConnectionState, GatewayEvent, GatewayEventName };
|
||||
|
||||
/**
|
||||
* Browser wrapper for the shared tui_gateway JSON-RPC client.
|
||||
*
|
||||
* Dashboard resolves its token and host from the served page. Desktop uses the
|
||||
* same shared protocol client, but supplies an absolute wsUrl from Electron.
|
||||
*/
|
||||
export class GatewayClient extends JsonRpcGatewayClient {
|
||||
async connect(token?: string): Promise<void> {
|
||||
const resolved = token ?? window.__HERMES_SESSION_TOKEN__ ?? "";
|
||||
if (!resolved) {
|
||||
throw new Error(
|
||||
"Session token not available — page must be served by the Hermes dashboard",
|
||||
);
|
||||
}
|
||||
|
||||
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
await super.connect(
|
||||
`${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws?token=${encodeURIComponent(resolved)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__HERMES_SESSION_TOKEN__?: string;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"arrowParens": "avoid",
|
||||
"bracketSpacing": true,
|
||||
"endOfLine": "auto",
|
||||
"printWidth": 120,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"useTabs": false
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
# Hermes Desktop
|
||||
|
||||
Native Electron shell for Hermes. It packages the desktop renderer, a bundled Hermes source payload, and installer targets for macOS and Windows.
|
||||
|
||||
## Setup
|
||||
|
||||
Install workspace dependencies from the repo root so `apps/desktop`, `apps/dashboard`, and `apps/shared` stay linked:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
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_ROOT=/path/to/your/clone npm run dev
|
||||
```
|
||||
|
||||
### Runtime prerequisites
|
||||
|
||||
Hermes Desktop needs:
|
||||
|
||||
- **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.
|
||||
|
||||
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:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd apps/desktop
|
||||
npm run dev
|
||||
```
|
||||
|
||||
`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/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
|
||||
```
|
||||
|
||||
`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
|
||||
hermes dashboard --tui --no-open
|
||||
```
|
||||
|
||||
For dashboard HMR, start Vite in another terminal:
|
||||
|
||||
```bash
|
||||
cd apps/dashboard
|
||||
npm run dev
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Build
|
||||
|
||||
```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
|
||||
npm run type-check
|
||||
npm run lint
|
||||
npm run test:desktop:all
|
||||
```
|
||||
|
||||
Current lint may report existing warnings, but it should exit with no errors.
|
||||
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 674 KiB |
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
/**
|
||||
* backend-probes.cjs
|
||||
*
|
||||
* Cheap "does this candidate backend actually work" checks used by
|
||||
* resolveHermesBackend (main.cjs). The resolver walks a ladder of
|
||||
* candidates -- bootstrap marker, `hermes` on PATH, system Python with
|
||||
* hermes_cli installed -- and historically returned the first candidate
|
||||
* whose binary existed on disk. That assumption breaks when a user has
|
||||
* a pre-installed Python 3.11-3.13 (so findSystemPython() returns a
|
||||
* path) but no hermes_cli in its site-packages: the resolver hands back
|
||||
* a backend the spawn step can't actually run, and the user gets a
|
||||
* dead-on-arrival "ModuleNotFoundError: No module named 'hermes_cli'"
|
||||
* instead of the first-launch installer.
|
||||
*
|
||||
* These probes give the resolver a way to verify a candidate before
|
||||
* trusting it. Failure (non-zero exit, exception, timeout) means "skip
|
||||
* this rung, try the next one"; success means "spawn this for real."
|
||||
* Falling off the bottom of the ladder lands on the bootstrap-needed
|
||||
* sentinel, which is exactly what we want when nothing pre-existing
|
||||
* actually works.
|
||||
*
|
||||
* Both probes are deliberately fast and forgiving:
|
||||
* - 5s timeout (a hung interpreter beats forever, but we still give
|
||||
* slow disks / cold caches room to breathe)
|
||||
* - stdio ignored (we only care about exit code; stdout/stderr are
|
||||
* not surfaced to the user, just to recentHermesLog for forensics
|
||||
* via the caller's catch block if it chooses)
|
||||
* - any throw -> false (never propagate -- resolver wants a boolean)
|
||||
*
|
||||
* Kept in a standalone cjs module so it can be unit-tested with
|
||||
* `node --test` without dragging in the electron runtime (same pattern
|
||||
* as bootstrap-platform.cjs and hardening.cjs).
|
||||
*/
|
||||
|
||||
const { execFileSync } = require('node:child_process')
|
||||
|
||||
const PROBE_TIMEOUT_MS = 5000
|
||||
|
||||
/**
|
||||
* Return true iff `python -c "import hermes_cli"` exits 0.
|
||||
*
|
||||
* Used to gate the "fallback to system Python with hermes_cli installed"
|
||||
* rung of resolveHermesBackend. Without this, a system Python 3.11-3.13
|
||||
* registered in PEP 514 makes findSystemPython() succeed regardless of
|
||||
* whether hermes_cli has actually been pip-installed into its
|
||||
* site-packages -- and the resolver returns a backend that immediately
|
||||
* dies on spawn.
|
||||
*
|
||||
* @param {string} pythonPath - Absolute path to a python.exe / python.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function canImportHermesCli(pythonPath) {
|
||||
if (!pythonPath) return false
|
||||
try {
|
||||
execFileSync(pythonPath, ['-c', 'import hermes_cli'], {
|
||||
stdio: 'ignore',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
windowsHide: true
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true iff `<hermesCommand> --version` exits 0.
|
||||
*
|
||||
* Used to gate the "existing `hermes` on PATH" rung. Without this, a
|
||||
* stale hermes.cmd shim left behind by an uninstalled pip install (or
|
||||
* a half-built venv whose `hermes` entry-point points at a deleted
|
||||
* Python) survives findOnPath() and gets selected as the backend.
|
||||
*
|
||||
* We intentionally avoid invoking the command with the dashboard args
|
||||
* here -- `--version` is the cheapest "is this binary alive" smoke
|
||||
* test that every hermes_cli entry-point has supported since 0.1.
|
||||
*
|
||||
* @param {string} hermesCommand - Resolved absolute path to a hermes
|
||||
* executable (or an interpreter+script wrapper).
|
||||
* @param {object} [opts]
|
||||
* @param {boolean} [opts.shell] - Whether to run through a shell. For
|
||||
* .cmd/.bat shims on Windows execFileSync needs shell:true to find
|
||||
* the cmd interpreter; mirrors the same flag isCommandScript() drives
|
||||
* in resolveHermesBackend.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function verifyHermesCli(hermesCommand, opts = {}) {
|
||||
if (!hermesCommand) return false
|
||||
try {
|
||||
execFileSync(hermesCommand, ['--version'], {
|
||||
stdio: 'ignore',
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
shell: Boolean(opts.shell),
|
||||
windowsHide: true
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
canImportHermesCli,
|
||||
verifyHermesCli,
|
||||
PROBE_TIMEOUT_MS
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Tests for electron/backend-probes.cjs.
|
||||
*
|
||||
* Run with: node --test electron/backend-probes.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
|
||||
|
||||
// Resolve the host's own Node binary -- guaranteed to be on disk and
|
||||
// runnable. We use it as both a stand-in for "a python that doesn't
|
||||
// have hermes_cli" (since `node -c "import hermes_cli"` will exit
|
||||
// non-zero) and as a way to script verifyHermesCli's success path
|
||||
// (a tiny script we write to disk that exits 0 on --version).
|
||||
const NODE_BIN = process.execPath
|
||||
|
||||
test('canImportHermesCli returns false when path is falsy', () => {
|
||||
assert.equal(canImportHermesCli(''), false)
|
||||
assert.equal(canImportHermesCli(null), false)
|
||||
assert.equal(canImportHermesCli(undefined), false)
|
||||
})
|
||||
|
||||
test('canImportHermesCli returns false when interpreter cannot run -c', () => {
|
||||
// node IS an interpreter, but `node -c "import hermes_cli"` is a
|
||||
// SyntaxError -- different exit reason from a real Python's
|
||||
// ModuleNotFoundError, but the predicate is "exit 0 or not" and
|
||||
// both land on "not", which is exactly what we want for the
|
||||
// resolver fall-through.
|
||||
assert.equal(canImportHermesCli(NODE_BIN), false)
|
||||
})
|
||||
|
||||
test('canImportHermesCli returns false when binary does not exist', () => {
|
||||
const ghost = path.join(os.tmpdir(), 'hermes-probes-ghost-' + Date.now() + '.exe')
|
||||
assert.equal(canImportHermesCli(ghost), false)
|
||||
})
|
||||
|
||||
test('verifyHermesCli returns false when command is falsy', () => {
|
||||
assert.equal(verifyHermesCli(''), false)
|
||||
assert.equal(verifyHermesCli(null), false)
|
||||
assert.equal(verifyHermesCli(undefined), false)
|
||||
})
|
||||
|
||||
test('verifyHermesCli returns false when binary does not exist', () => {
|
||||
const ghost = path.join(os.tmpdir(), 'hermes-probes-ghost-' + Date.now() + '.exe')
|
||||
assert.equal(verifyHermesCli(ghost), false)
|
||||
})
|
||||
|
||||
test('verifyHermesCli returns true when --version exits 0', () => {
|
||||
// Write a tiny script that exits 0 regardless of args, then invoke
|
||||
// it through node. This stands in for a working hermes binary --
|
||||
// verifyHermesCli only cares about the exit code.
|
||||
const scriptPath = path.join(os.tmpdir(), `hermes-probes-ok-${Date.now()}-${process.pid}.cjs`)
|
||||
fs.writeFileSync(scriptPath, 'process.exit(0)\n')
|
||||
try {
|
||||
// Use node as the launcher and our script as the "command". Pass
|
||||
// shell:false (default) -- node is a real binary, no shim.
|
||||
// execFileSync passes ['--version'] as args, which node ignores
|
||||
// gracefully (well, it prints its version and exits 0, which is
|
||||
// perfect -- exit code 0 is the only signal we read).
|
||||
assert.equal(verifyHermesCli(NODE_BIN), true)
|
||||
} finally {
|
||||
try {
|
||||
fs.unlinkSync(scriptPath)
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
|
||||
test('verifyHermesCli swallows timeouts (does not throw)', () => {
|
||||
// We can't easily provoke a real 5s hang in CI without slowing the
|
||||
// suite, but we CAN confirm that an invocation that DOES throw
|
||||
// (because the binary is missing) returns false rather than
|
||||
// propagating. Same code path the timeout case takes.
|
||||
assert.equal(verifyHermesCli('/definitely/not/a/real/binary/anywhere'), false)
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
function isWslEnvironment(env = process.env, platform = process.platform) {
|
||||
if (platform !== 'linux') return false
|
||||
return Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP)
|
||||
}
|
||||
|
||||
function isWindowsBinaryPathInWsl(filePath, options = {}) {
|
||||
const isWsl = options.isWsl ?? isWslEnvironment(options.env, options.platform)
|
||||
if (!isWsl) return false
|
||||
|
||||
const normalized = String(filePath || '')
|
||||
.replace(/\\/g, '/')
|
||||
.toLowerCase()
|
||||
|
||||
return (
|
||||
normalized.endsWith('.exe') ||
|
||||
normalized.endsWith('.cmd') ||
|
||||
normalized.endsWith('.bat') ||
|
||||
normalized.endsWith('.ps1')
|
||||
)
|
||||
}
|
||||
|
||||
function bundledRuntimeImportCheck(platform = process.platform) {
|
||||
return platform === 'win32' ? 'import fastapi, uvicorn, winpty' : 'import fastapi, uvicorn, ptyprocess'
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bundledRuntimeImportCheck,
|
||||
isWindowsBinaryPathInWsl,
|
||||
isWslEnvironment
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
|
||||
const { bundledRuntimeImportCheck, isWindowsBinaryPathInWsl, isWslEnvironment } = require('./bootstrap-platform.cjs')
|
||||
|
||||
test('isWslEnvironment detects WSL2 env vars on linux', () => {
|
||||
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
|
||||
assert.equal(isWslEnvironment({ WSL_INTEROP: '/run/WSL/123_interop' }, 'linux'), true)
|
||||
assert.equal(isWslEnvironment({}, 'linux'), false)
|
||||
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'darwin'), false)
|
||||
})
|
||||
|
||||
test('isWindowsBinaryPathInWsl blocks Windows binary types on WSL', () => {
|
||||
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.exe', { isWsl: true }), true)
|
||||
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.cmd', { isWsl: true }), true)
|
||||
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.bat', { isWsl: true }), true)
|
||||
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/install.ps1', { isWsl: true }), true)
|
||||
assert.equal(isWindowsBinaryPathInWsl('/usr/local/bin/hermes', { isWsl: true }), false)
|
||||
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.exe', { isWsl: false }), false)
|
||||
})
|
||||
|
||||
test('bundledRuntimeImportCheck selects platform-specific import checks', () => {
|
||||
assert.equal(bundledRuntimeImportCheck('win32'), 'import fastapi, uvicorn, winpty')
|
||||
assert.equal(bundledRuntimeImportCheck('darwin'), 'import fastapi, uvicorn, ptyprocess')
|
||||
assert.equal(bundledRuntimeImportCheck('linux'), 'import fastapi, uvicorn, ptyprocess')
|
||||
})
|
||||
|
||||
test('packaged electron entrypoints do not require unpackaged npm modules', () => {
|
||||
const electronDir = __dirname
|
||||
const entrypoints = ['main.cjs', 'preload.cjs', 'bootstrap-platform.cjs']
|
||||
// - electron: provided by the electron runtime, always resolvable in packaged builds.
|
||||
// - node-pty: hoisted by workspace dedup AND shipped via extraResources to
|
||||
// resources/native-deps/node-pty (see scripts/stage-native-deps.cjs). main.cjs
|
||||
// has a try/catch fallback at line ~38 that resolves the staged copy when the
|
||||
// bare require fails in the packaged asar, so the bare require itself is by
|
||||
// design rather than an oversight.
|
||||
const allowedBareRequires = new Set(['electron', 'node-pty'])
|
||||
const requirePattern = /require\(['"]([^'"]+)['"]\)/g
|
||||
|
||||
for (const entrypoint of entrypoints) {
|
||||
const source = fs.readFileSync(path.join(electronDir, entrypoint), 'utf8')
|
||||
const bareRequires = Array.from(source.matchAll(requirePattern))
|
||||
.map(match => match[1])
|
||||
.filter(specifier => !specifier.startsWith('node:'))
|
||||
.filter(specifier => !specifier.startsWith('.'))
|
||||
.filter(specifier => !allowedBareRequires.has(specifier))
|
||||
|
||||
assert.deepEqual(bareRequires, [], `${entrypoint} has unpackaged runtime requires`)
|
||||
}
|
||||
})
|
||||
@@ -1,466 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* bootstrap-runner.cjs
|
||||
*
|
||||
* Drives apps/desktop's first-launch install of Hermes Agent by spawning
|
||||
* scripts/install.ps1 stage-by-stage and streaming progress events back to
|
||||
* the renderer.
|
||||
*
|
||||
* Wired from electron/main.cjs:
|
||||
* const { runBootstrap } = require('./bootstrap-runner.cjs')
|
||||
* const result = await runBootstrap({
|
||||
* installStamp, // INSTALL_STAMP from main.cjs (may be null in dev)
|
||||
* activeRoot, // ACTIVE_HERMES_ROOT
|
||||
* sourceRepoRoot, // SOURCE_REPO_ROOT (for dev install.ps1 lookup)
|
||||
* hermesHome, // HERMES_HOME
|
||||
* logRoot, // HERMES_HOME/logs
|
||||
* emit: ev => {...} // event sink (sender.send or similar)
|
||||
* })
|
||||
*
|
||||
* Emits events with shape:
|
||||
* { type: 'manifest', stages: [{name, title, category, needs_user_input}, ...] }
|
||||
* { type: 'stage', name, state: 'running'|'succeeded'|'skipped'|'failed',
|
||||
* json?, durationMs?, error? }
|
||||
* { type: 'log', stage?, line } // raw line from install.ps1
|
||||
* { type: 'complete', marker: <written marker payload> }
|
||||
* { type: 'failed', stage?, error } // bootstrap aborted
|
||||
*
|
||||
* Resolves with the same shape as the final 'complete' or 'failed' event so
|
||||
* callers can await either way.
|
||||
*
|
||||
* 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')
|
||||
const fsp = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const https = require('node:https')
|
||||
const { spawn } = require('node:child_process')
|
||||
|
||||
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
|
||||
|
||||
// Stages flagged needs_user_input=true in the manifest are skipped by the
|
||||
// runner (passed -NonInteractive to install.ps1, which the install script
|
||||
// itself handles by emitting skipped=true frames). The renderer / 1E onboarding
|
||||
// overlay takes over for those concerns (API keys, model, persona, gateway).
|
||||
// We let install.ps1's own -NonInteractive logic drive this rather than
|
||||
// filtering client-side -- single source of truth.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// install.ps1 source resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function resolveLocalInstallScript(sourceRepoRoot) {
|
||||
if (!sourceRepoRoot) return null
|
||||
const candidate = path.join(sourceRepoRoot, 'scripts', 'install.ps1')
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.R_OK)
|
||||
return candidate
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function bootstrapCacheDir(hermesHome) {
|
||||
return path.join(hermesHome, 'bootstrap-cache')
|
||||
}
|
||||
|
||||
function cachedScriptPath(hermesHome, commit) {
|
||||
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 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'
|
||||
const out = fs.createWriteStream(tmpPath)
|
||||
https
|
||||
.get(url, res => {
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
// GitHub raw shouldn't redirect for a SHA URL, but follow once
|
||||
// defensively.
|
||||
out.close()
|
||||
fs.unlinkSync(tmpPath)
|
||||
https
|
||||
.get(res.headers.location, res2 => {
|
||||
if (res2.statusCode !== 200) {
|
||||
reject(new Error(`Failed to download install.ps1: HTTP ${res2.statusCode} from redirect ${res.headers.location}`))
|
||||
return
|
||||
}
|
||||
const out2 = fs.createWriteStream(tmpPath)
|
||||
res2.pipe(out2)
|
||||
out2.on('finish', () => {
|
||||
out2.close()
|
||||
fs.renameSync(tmpPath, destPath)
|
||||
resolve(destPath)
|
||||
})
|
||||
out2.on('error', reject)
|
||||
})
|
||||
.on('error', reject)
|
||||
return
|
||||
}
|
||||
if (res.statusCode !== 200) {
|
||||
out.close()
|
||||
try {
|
||||
fs.unlinkSync(tmpPath)
|
||||
} catch {}
|
||||
reject(new Error(`Failed to download install.ps1: HTTP ${res.statusCode} from ${url}`))
|
||||
return
|
||||
}
|
||||
res.pipe(out)
|
||||
out.on('finish', () => {
|
||||
out.close()
|
||||
fs.renameSync(tmpPath, destPath)
|
||||
resolve(destPath)
|
||||
})
|
||||
out.on('error', err => {
|
||||
try {
|
||||
fs.unlinkSync(tmpPath)
|
||||
} catch {}
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
.on('error', err => {
|
||||
try {
|
||||
fs.unlinkSync(tmpPath)
|
||||
} catch {}
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit }) {
|
||||
// 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 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 install.ps1: no SOURCE_REPO_ROOT and no install stamp. ' +
|
||||
'This packaged build was produced without a valid build-time stamp.'
|
||||
)
|
||||
}
|
||||
|
||||
const cached = cachedScriptPath(hermesHome, installStamp.commit)
|
||||
try {
|
||||
await fsp.access(cached, fs.constants.R_OK)
|
||||
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 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 }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// powershell wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ps = process.platform === 'win32' ? 'powershell.exe' : 'pwsh'
|
||||
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
|
||||
|
||||
const child = spawn(ps, fullArgs, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
// Pass HERMES_HOME through so install.ps1 respects the caller's
|
||||
// choice rather than re-computing the default.
|
||||
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')
|
||||
|
||||
// Stream stdout line-by-line so the renderer sees progress in real time.
|
||||
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)
|
||||
// Flush any trailing bytes
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Build the install.ps1 pin args (-Commit / -Branch) from the install-stamp
|
||||
// so the repository stage clones the exact SHA the .exe was tested with
|
||||
// instead of falling back to install.ps1's default ($Branch = "main").
|
||||
function buildPinArgs(installStamp) {
|
||||
const args = []
|
||||
if (installStamp && installStamp.commit) {
|
||||
args.push('-Commit', installStamp.commit)
|
||||
}
|
||||
if (installStamp && installStamp.branch) {
|
||||
args.push('-Branch', installStamp.branch)
|
||||
}
|
||||
return 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(`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).
|
||||
// Find the last line that parses as JSON with a `stages` field.
|
||||
const lines = result.stdout.split(/\r?\n/).filter(Boolean)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const parsed = JSON.parse(lines[i])
|
||||
if (parsed && Array.isArray(parsed.stages)) {
|
||||
return parsed
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
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
|
||||
// exactly one JSON line per stage in -Json or -Stage mode (post #27224 fix
|
||||
// for the double-emit bug we addressed in the install.ps1 PR).
|
||||
function parseStageResult(stdout) {
|
||||
const lines = stdout.split(/\r?\n/).filter(Boolean)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const parsed = JSON.parse(lines[i])
|
||||
if (parsed && typeof parsed.ok === 'boolean' && typeof parsed.stage === 'string') {
|
||||
return parsed
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function runStage({ scriptPath, stage, emit, hermesHome, abortSignal, installStamp }) {
|
||||
const startedAt = Date.now()
|
||||
emit({ type: 'stage', name: stage.name, state: 'running' })
|
||||
|
||||
const pinArgs = buildPinArgs(installStamp)
|
||||
const result = await spawnPowerShell(
|
||||
scriptPath,
|
||||
['-Stage', stage.name, '-NonInteractive', '-Json', ...pinArgs],
|
||||
{ emit, stageName: stage.name, abortSignal, hermesHome }
|
||||
)
|
||||
|
||||
const durationMs = Date.now() - startedAt
|
||||
|
||||
if (result.killed) {
|
||||
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, error: 'cancelled by user' }
|
||||
emit(ev)
|
||||
return ev
|
||||
}
|
||||
|
||||
const json = parseStageResult(result.stdout)
|
||||
|
||||
if (!json) {
|
||||
const ev = {
|
||||
type: 'stage',
|
||||
name: stage.name,
|
||||
state: 'failed',
|
||||
durationMs,
|
||||
error: `install.ps1 -Stage ${stage.name} produced no JSON result frame (exit=${result.code})`,
|
||||
json: null
|
||||
}
|
||||
emit(ev)
|
||||
return ev
|
||||
}
|
||||
|
||||
if (json.ok && json.skipped) {
|
||||
const ev = { type: 'stage', name: stage.name, state: 'skipped', durationMs, json }
|
||||
emit(ev)
|
||||
return ev
|
||||
}
|
||||
if (json.ok) {
|
||||
const ev = { type: 'stage', name: stage.name, state: 'succeeded', durationMs, json }
|
||||
emit(ev)
|
||||
return ev
|
||||
}
|
||||
const ev = { type: 'stage', name: stage.name, state: 'failed', durationMs, json, error: json.reason || `exit code ${result.code}` }
|
||||
emit(ev)
|
||||
return ev
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-run log file
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function openRunLog(logRoot) {
|
||||
fs.mkdirSync(logRoot, { recursive: true })
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const logPath = path.join(logRoot, `bootstrap-${ts}.log`)
|
||||
const stream = fs.createWriteStream(logPath, { flags: 'a' })
|
||||
return { path: logPath, stream }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public entrypoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runBootstrap(opts) {
|
||||
const {
|
||||
installStamp,
|
||||
activeRoot,
|
||||
sourceRepoRoot,
|
||||
hermesHome,
|
||||
logRoot,
|
||||
onEvent,
|
||||
abortSignal,
|
||||
writeMarker // callback to write the bootstrap-complete marker; main.cjs provides
|
||||
} = opts
|
||||
|
||||
const runLog = openRunLog(logRoot || path.join(hermesHome, 'logs'))
|
||||
|
||||
// Tee every event to the runLog AND the caller's onEvent. This gives us a
|
||||
// forensic trail per bootstrap run AND lets the renderer subscribe live.
|
||||
const emit = ev => {
|
||||
try {
|
||||
runLog.stream.write(JSON.stringify(ev) + '\n')
|
||||
} catch {}
|
||||
try {
|
||||
if (typeof onEvent === 'function') onEvent(ev)
|
||||
} catch (err) {
|
||||
// Don't let a subscriber bug crash the bootstrap
|
||||
runLog.stream.write(`emit error: ${err && err.message}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
emit({
|
||||
type: 'log',
|
||||
line:
|
||||
`[bootstrap] starting at ${new Date().toISOString()}; ` +
|
||||
`activeRoot=${activeRoot}; ` +
|
||||
`stamp=${installStamp ? installStamp.commit.slice(0, 12) : '<none>'}; ` +
|
||||
`runLog=${runLog.path}`
|
||||
})
|
||||
|
||||
try {
|
||||
// 1. Resolve install.ps1
|
||||
const scriptInfo = await resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit })
|
||||
|
||||
// 2. Fetch manifest
|
||||
const manifest = await fetchManifest({ scriptPath: scriptInfo.path, emit, hermesHome, installStamp })
|
||||
emit({
|
||||
type: 'manifest',
|
||||
stages: manifest.stages,
|
||||
protocolVersion: manifest.protocol_version || manifest.protocolVersion || null
|
||||
})
|
||||
|
||||
// 3. Iterate stages in order. Stages flagged needs_user_input are still
|
||||
// invoked -- install.ps1's own -NonInteractive handler in those stages
|
||||
// emits skipped=true. We trust the protocol rather than filtering
|
||||
// client-side.
|
||||
for (const stage of manifest.stages) {
|
||||
if (abortSignal && abortSignal.aborted) {
|
||||
emit({ type: 'failed', error: 'bootstrap cancelled by user' })
|
||||
return { ok: false, cancelled: true }
|
||||
}
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Write the bootstrap-complete marker.
|
||||
const markerPayload = {
|
||||
pinnedCommit: installStamp ? installStamp.commit : null,
|
||||
pinnedBranch: installStamp ? installStamp.branch : null
|
||||
}
|
||||
const marker = typeof writeMarker === 'function' ? writeMarker(markerPayload) : markerPayload
|
||||
emit({ type: 'complete', marker })
|
||||
return { ok: true, marker }
|
||||
} catch (err) {
|
||||
emit({ type: 'failed', error: err.message || String(err) })
|
||||
return { ok: false, error: err.message || String(err) }
|
||||
} finally {
|
||||
try {
|
||||
runLog.stream.end()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runBootstrap,
|
||||
// Exposed for testability
|
||||
parseStageResult,
|
||||
resolveLocalInstallScript,
|
||||
cachedScriptPath
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,14 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,184 +0,0 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { fileURLToPath } = require('node:url')
|
||||
|
||||
const DEFAULT_FETCH_TIMEOUT_MS = 15_000
|
||||
const DATA_URL_READ_MAX_BYTES = 16 * 1024 * 1024
|
||||
const TEXT_PREVIEW_SOURCE_MAX_BYTES = 64 * 1024 * 1024
|
||||
|
||||
const SAFE_ENV_SUFFIXES = new Set(['dist', 'example', 'sample', 'template'])
|
||||
const SENSITIVE_EXTENSIONS = new Set(['.kdbx', '.p12', '.pem', '.pfx'])
|
||||
|
||||
function resolveTimeoutMs(timeoutMs, fallbackMs = DEFAULT_FETCH_TIMEOUT_MS) {
|
||||
const fallback =
|
||||
Number.isFinite(fallbackMs) && Number(fallbackMs) > 0 ? Math.round(Number(fallbackMs)) : DEFAULT_FETCH_TIMEOUT_MS
|
||||
const parsed = Number(timeoutMs)
|
||||
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return Math.round(parsed)
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function encryptDesktopSecret(value, safeStorageApi) {
|
||||
const raw = String(value || '')
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
let encryptionAvailable = false
|
||||
|
||||
try {
|
||||
encryptionAvailable = Boolean(safeStorageApi?.isEncryptionAvailable?.())
|
||||
} catch {
|
||||
encryptionAvailable = false
|
||||
}
|
||||
|
||||
if (!encryptionAvailable) {
|
||||
throw new Error(
|
||||
'Secure token storage is unavailable, so Hermes Desktop cannot save remote gateway tokens. ' +
|
||||
'Set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment, or enable OS keychain access and try again.'
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
encoding: 'safeStorage',
|
||||
value: safeStorageApi.encryptString(raw).toString('base64')
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error && error.message ? ` (${error.message})` : ''
|
||||
throw new Error(
|
||||
`Failed to encrypt the remote gateway token for secure storage${detail}. ` +
|
||||
'Set HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN in your environment as a fallback.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function sensitiveFileBlockReason(filePath) {
|
||||
const normalized = String(filePath || '')
|
||||
.replace(/\\/g, '/')
|
||||
.toLowerCase()
|
||||
const basename = path.basename(normalized)
|
||||
const ext = path.extname(basename)
|
||||
|
||||
if (!basename) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (normalized.includes('/.ssh/')) {
|
||||
return 'SSH key/config files are blocked.'
|
||||
}
|
||||
|
||||
if (normalized.includes('/.gnupg/')) {
|
||||
return 'GPG key material is blocked.'
|
||||
}
|
||||
|
||||
if (normalized.endsWith('/.aws/credentials')) {
|
||||
return 'AWS credential files are blocked.'
|
||||
}
|
||||
|
||||
if (basename === '.env') {
|
||||
return '.env files are blocked because they commonly contain secrets.'
|
||||
}
|
||||
|
||||
if (basename.startsWith('.env.')) {
|
||||
const suffix = basename.slice('.env.'.length)
|
||||
if (!SAFE_ENV_SUFFIXES.has(suffix)) {
|
||||
return `${basename} is blocked because it appears to contain environment secrets.`
|
||||
}
|
||||
}
|
||||
|
||||
if (/^id_(rsa|dsa|ecdsa|ed25519)(?:\..+)?$/.test(basename) && !basename.endsWith('.pub')) {
|
||||
return 'SSH private key files are blocked.'
|
||||
}
|
||||
|
||||
if (SENSITIVE_EXTENSIONS.has(ext)) {
|
||||
return `${ext} key/certificate files are blocked.`
|
||||
}
|
||||
|
||||
if (basename === '.npmrc' || basename === '.netrc' || basename === '.pypirc') {
|
||||
return `${basename} is blocked because it may include auth credentials.`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveRequestedFilePath(filePath, baseDir = process.cwd(), purpose = 'File read') {
|
||||
const raw = String(filePath || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
throw new Error(`${purpose} failed: file path is required.`)
|
||||
}
|
||||
|
||||
if (raw.includes('\0')) {
|
||||
throw new Error(`${purpose} failed: file path is invalid.`)
|
||||
}
|
||||
|
||||
if (/^file:/i.test(raw)) {
|
||||
try {
|
||||
return fileURLToPath(raw)
|
||||
} catch {
|
||||
throw new Error(`${purpose} failed: file URL is invalid.`)
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedBase = path.resolve(String(baseDir || process.cwd()))
|
||||
return path.resolve(resolvedBase, raw)
|
||||
}
|
||||
|
||||
async function resolveReadableFileForIpc(filePath, options = {}) {
|
||||
const purpose = String(options.purpose || 'File read')
|
||||
const resolvedPath = resolveRequestedFilePath(filePath, options.baseDir, purpose)
|
||||
|
||||
if (options.blockSensitive !== false) {
|
||||
const blockReason = sensitiveFileBlockReason(resolvedPath)
|
||||
if (blockReason) {
|
||||
throw new Error(`${purpose} blocked for sensitive file: ${blockReason}`)
|
||||
}
|
||||
}
|
||||
|
||||
let stat
|
||||
try {
|
||||
stat = await fs.promises.stat(resolvedPath)
|
||||
} catch (error) {
|
||||
const code = error && typeof error === 'object' ? error.code : ''
|
||||
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
||||
throw new Error(`${purpose} failed: file does not exist.`)
|
||||
}
|
||||
throw new Error(`${purpose} failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
throw new Error(`${purpose} failed: path points to a directory.`)
|
||||
}
|
||||
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`${purpose} failed: only regular files can be read.`)
|
||||
}
|
||||
|
||||
const maxBytes = Number.isFinite(options.maxBytes) && Number(options.maxBytes) > 0 ? Number(options.maxBytes) : null
|
||||
if (maxBytes && stat.size > maxBytes) {
|
||||
throw new Error(`${purpose} failed: file is too large (${stat.size} bytes; limit ${maxBytes} bytes).`)
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.promises.access(resolvedPath, fs.constants.R_OK)
|
||||
} catch {
|
||||
throw new Error(`${purpose} failed: file is not readable.`)
|
||||
}
|
||||
|
||||
return { resolvedPath, stat }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DATA_URL_READ_MAX_BYTES,
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
TEXT_PREVIEW_SOURCE_MAX_BYTES,
|
||||
encryptDesktopSecret,
|
||||
resolveReadableFileForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const {
|
||||
DEFAULT_FETCH_TIMEOUT_MS,
|
||||
encryptDesktopSecret,
|
||||
resolveReadableFileForIpc,
|
||||
resolveTimeoutMs,
|
||||
sensitiveFileBlockReason
|
||||
} = require('./hardening.cjs')
|
||||
|
||||
test('resolveTimeoutMs falls back to defaults and accepts overrides', () => {
|
||||
assert.equal(resolveTimeoutMs(undefined), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs(0), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs(-25), DEFAULT_FETCH_TIMEOUT_MS)
|
||||
assert.equal(resolveTimeoutMs('2750'), 2750)
|
||||
})
|
||||
|
||||
test('encryptDesktopSecret requires available secure storage', () => {
|
||||
assert.equal(
|
||||
encryptDesktopSecret('', { isEncryptionAvailable: () => true, encryptString: () => Buffer.alloc(0) }),
|
||||
null
|
||||
)
|
||||
|
||||
assert.throws(
|
||||
() => encryptDesktopSecret('token', { isEncryptionAvailable: () => false, encryptString: () => Buffer.alloc(0) }),
|
||||
/Secure token storage is unavailable/
|
||||
)
|
||||
})
|
||||
|
||||
test('encryptDesktopSecret stores safeStorage base64 payload', () => {
|
||||
const secret = encryptDesktopSecret('token-123', {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: value => Buffer.from(`enc:${value}`, 'utf8')
|
||||
})
|
||||
|
||||
assert.deepEqual(secret, {
|
||||
encoding: 'safeStorage',
|
||||
value: Buffer.from('enc:token-123', 'utf8').toString('base64')
|
||||
})
|
||||
})
|
||||
|
||||
test('sensitiveFileBlockReason blocks obvious secret file patterns', () => {
|
||||
assert.match(String(sensitiveFileBlockReason('/tmp/.env')), /\.env/)
|
||||
assert.equal(sensitiveFileBlockReason('/tmp/.env.example'), null)
|
||||
assert.match(String(sensitiveFileBlockReason('/Users/me/.ssh/id_ed25519')), /SSH/)
|
||||
assert.match(String(sensitiveFileBlockReason('/tmp/server-cert.pem')), /\.pem/)
|
||||
})
|
||||
|
||||
test('resolveReadableFileForIpc validates existence type size and sensitivity', async t => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-desktop-hardening-'))
|
||||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }))
|
||||
|
||||
const textPath = path.join(tempDir, 'notes.txt')
|
||||
fs.writeFileSync(textPath, 'hello world', 'utf8')
|
||||
|
||||
const fromRelative = await resolveReadableFileForIpc('notes.txt', {
|
||||
baseDir: tempDir,
|
||||
maxBytes: 256,
|
||||
purpose: 'File preview'
|
||||
})
|
||||
assert.equal(fromRelative.resolvedPath, textPath)
|
||||
assert.equal(fromRelative.stat.size, 11)
|
||||
|
||||
const fromFileUrl = await resolveReadableFileForIpc(pathToFileURL(textPath).toString(), {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
assert.equal(fromFileUrl.resolvedPath, textPath)
|
||||
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc('missing.txt', {
|
||||
baseDir: tempDir,
|
||||
purpose: 'Text preview'
|
||||
}),
|
||||
/file does not exist/
|
||||
)
|
||||
|
||||
const nestedDir = path.join(tempDir, 'directory')
|
||||
fs.mkdirSync(nestedDir)
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(nestedDir, {
|
||||
purpose: 'Text preview'
|
||||
}),
|
||||
/path points to a directory/
|
||||
)
|
||||
|
||||
const largePath = path.join(tempDir, 'large.txt')
|
||||
fs.writeFileSync(largePath, 'x'.repeat(40), 'utf8')
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(largePath, {
|
||||
maxBytes: 8,
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
/file is too large/
|
||||
)
|
||||
|
||||
const envPath = path.join(tempDir, '.env')
|
||||
fs.writeFileSync(envPath, 'SECRET_TOKEN=123', 'utf8')
|
||||
await assert.rejects(
|
||||
resolveReadableFileForIpc(envPath, {
|
||||
purpose: 'File preview'
|
||||
}),
|
||||
/blocked for sensitive file/
|
||||
)
|
||||
|
||||
const envTemplatePath = path.join(tempDir, '.env.example')
|
||||
fs.writeFileSync(envTemplatePath, 'EXAMPLE_TOKEN=value', 'utf8')
|
||||
const envTemplate = await resolveReadableFileForIpc(envTemplatePath, {
|
||||
purpose: 'File preview'
|
||||
})
|
||||
assert.equal(envTemplate.resolvedPath, envTemplatePath)
|
||||
})
|
||||
@@ -1,108 +0,0 @@
|
||||
const { contextBridge, ipcRenderer, webUtils } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getConnection: () => ipcRenderer.invoke('hermes:connection'),
|
||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||
getConnectionConfig: () => ipcRenderer.invoke('hermes:connection-config:get'),
|
||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
|
||||
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
|
||||
api: request => ipcRenderer.invoke('hermes:api', request),
|
||||
notify: payload => ipcRenderer.invoke('hermes:notify', payload),
|
||||
requestMicrophoneAccess: () => ipcRenderer.invoke('hermes:requestMicrophoneAccess'),
|
||||
readFileDataUrl: filePath => ipcRenderer.invoke('hermes:readFileDataUrl', filePath),
|
||||
readFileText: filePath => ipcRenderer.invoke('hermes:readFileText', filePath),
|
||||
selectPaths: options => ipcRenderer.invoke('hermes:selectPaths', options),
|
||||
writeClipboard: text => ipcRenderer.invoke('hermes:writeClipboard', text),
|
||||
saveImageFromUrl: url => ipcRenderer.invoke('hermes:saveImageFromUrl', url),
|
||||
saveImageBuffer: (data, ext) => ipcRenderer.invoke('hermes:saveImageBuffer', { data, ext }),
|
||||
saveClipboardImage: () => ipcRenderer.invoke('hermes:saveClipboardImage'),
|
||||
getPathForFile: file => {
|
||||
try {
|
||||
return webUtils.getPathForFile(file) || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
normalizePreviewTarget: (target, baseDir) => ipcRenderer.invoke('hermes:normalizePreviewTarget', target, baseDir),
|
||||
watchPreviewFile: url => ipcRenderer.invoke('hermes:watchPreviewFile', url),
|
||||
stopPreviewFileWatch: id => ipcRenderer.invoke('hermes:stopPreviewFileWatch', id),
|
||||
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
fetchLinkTitle: url => ipcRenderer.invoke('hermes:fetchLinkTitle', url),
|
||||
readDir: dirPath => ipcRenderer.invoke('hermes:fs:readDir', dirPath),
|
||||
gitRoot: startPath => ipcRenderer.invoke('hermes:fs:gitRoot', startPath),
|
||||
terminal: {
|
||||
dispose: id => ipcRenderer.invoke('hermes:terminal:dispose', id),
|
||||
resize: (id, size) => ipcRenderer.invoke('hermes:terminal:resize', id, size),
|
||||
start: options => ipcRenderer.invoke('hermes:terminal:start', options),
|
||||
write: (id, data) => ipcRenderer.invoke('hermes:terminal:write', id, data),
|
||||
onData: (id, callback) => {
|
||||
const channel = `hermes:terminal:${id}:data`
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on(channel, listener)
|
||||
return () => ipcRenderer.removeListener(channel, listener)
|
||||
},
|
||||
onExit: (id, callback) => {
|
||||
const channel = `hermes:terminal:${id}:exit`
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on(channel, listener)
|
||||
return () => ipcRenderer.removeListener(channel, listener)
|
||||
}
|
||||
},
|
||||
onClosePreviewRequested: callback => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('hermes:close-preview-requested', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:close-preview-requested', listener)
|
||||
},
|
||||
onOpenUpdatesRequested: callback => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('hermes:open-updates', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:open-updates', listener)
|
||||
},
|
||||
onWindowStateChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:window-state-changed', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:window-state-changed', listener)
|
||||
},
|
||||
onPreviewFileChanged: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:preview-file-changed', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:preview-file-changed', listener)
|
||||
},
|
||||
onBackendExit: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:backend-exit', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:backend-exit', listener)
|
||||
},
|
||||
onBootProgress: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:boot-progress', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:boot-progress', listener)
|
||||
},
|
||||
// First-launch bootstrap progress -- emitted by the install.ps1 stage
|
||||
// runner in main.cjs (apps/desktop/electron/bootstrap-runner.cjs).
|
||||
// Renderer's install overlay subscribes to live events and queries the
|
||||
// current snapshot via getBootstrapState() to recover after a devtools
|
||||
// reload mid-bootstrap.
|
||||
getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'),
|
||||
resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'),
|
||||
onBootstrapEvent: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:bootstrap:event', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener)
|
||||
},
|
||||
getVersion: () => ipcRenderer.invoke('hermes:version'),
|
||||
updates: {
|
||||
check: () => ipcRenderer.invoke('hermes:updates:check'),
|
||||
apply: opts => ipcRenderer.invoke('hermes:updates:apply', opts),
|
||||
getBranch: () => ipcRenderer.invoke('hermes:updates:branch:get'),
|
||||
setBranch: name => ipcRenderer.invoke('hermes:updates:branch:set', name),
|
||||
onProgress: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:updates:progress', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:updates:progress', listener)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,122 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin'
|
||||
import typescriptParser from '@typescript-eslint/parser'
|
||||
import perfectionist from 'eslint-plugin-perfectionist'
|
||||
import reactPlugin from 'eslint-plugin-react'
|
||||
import reactCompiler from 'eslint-plugin-react-compiler'
|
||||
import hooksPlugin from 'eslint-plugin-react-hooks'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
import globals from 'globals'
|
||||
|
||||
const noopRule = {
|
||||
meta: { schema: [], type: 'problem' },
|
||||
create: () => ({})
|
||||
}
|
||||
|
||||
const customRules = {
|
||||
rules: {
|
||||
'no-process-cwd': noopRule,
|
||||
'no-process-env-top-level': noopRule,
|
||||
'no-sync-fs': noopRule,
|
||||
'no-top-level-dynamic-import': noopRule,
|
||||
'no-top-level-side-effects': noopRule
|
||||
}
|
||||
}
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['**/node_modules/**', '**/dist/**', 'src/**/*.js']
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node
|
||||
},
|
||||
parser: typescriptParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
'custom-rules': customRules,
|
||||
perfectionist,
|
||||
react: reactPlugin,
|
||||
'react-compiler': reactCompiler,
|
||||
'react-hooks': hooksPlugin,
|
||||
'unused-imports': unusedImports
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }],
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
curly: ['error', 'all'],
|
||||
'no-fallthrough': ['error', { allowEmptyCase: true }],
|
||||
'no-undef': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
'padding-line-between-statements': [
|
||||
1,
|
||||
{
|
||||
blankLine: 'always',
|
||||
next: [
|
||||
'block-like',
|
||||
'block',
|
||||
'return',
|
||||
'if',
|
||||
'class',
|
||||
'continue',
|
||||
'debugger',
|
||||
'break',
|
||||
'multiline-const',
|
||||
'multiline-let'
|
||||
],
|
||||
prev: '*'
|
||||
},
|
||||
{
|
||||
blankLine: 'always',
|
||||
next: '*',
|
||||
prev: ['case', 'default', 'multiline-const', 'multiline-let', 'multiline-block-like']
|
||||
},
|
||||
{ blankLine: 'never', next: ['block', 'block-like'], prev: ['case', 'default'] },
|
||||
{ blankLine: 'always', next: ['block', 'block-like'], prev: ['block', 'block-like'] },
|
||||
{ blankLine: 'always', next: ['empty'], prev: 'export' },
|
||||
{ blankLine: 'never', next: 'iife', prev: ['block', 'block-like', 'empty'] }
|
||||
],
|
||||
'perfectionist/sort-exports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-imports': [
|
||||
'error',
|
||||
{
|
||||
groups: ['side-effect', 'builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
|
||||
order: 'asc',
|
||||
type: 'natural'
|
||||
}
|
||||
],
|
||||
'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }],
|
||||
'react-compiler/react-compiler': 'warn',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'unused-imports/no-unused-imports': 'error'
|
||||
},
|
||||
settings: {
|
||||
react: { version: 'detect' }
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.js', '**/*.cjs'],
|
||||
ignores: ['**/node_modules/**', '**/dist/**'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
globals: { ...globals.node },
|
||||
sourceType: 'commonjs'
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['*.config.*']
|
||||
}
|
||||
]
|
||||
@@ -1,14 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/apple-touch-icon.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>Hermes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root" class="scrollbar-dt"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
18363
apps/desktop/package-lock.json
generated
@@ -1,215 +0,0 @@
|
||||
{
|
||||
"name": "hermes",
|
||||
"productName": "Hermes",
|
||||
"private": true,
|
||||
"version": "0.0.2",
|
||||
"description": "Native desktop shell for Hermes Agent.",
|
||||
"author": "Nous Research",
|
||||
"type": "module",
|
||||
"main": "electron/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
|
||||
"dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev",
|
||||
"dev:renderer": "node scripts/assert-root-install.cjs && vite --host 127.0.0.1 --port 5174",
|
||||
"dev:electron": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
|
||||
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
|
||||
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
|
||||
"start": "npm run build && electron .",
|
||||
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build",
|
||||
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
|
||||
"pack": "npm run build && npm run builder -- --dir",
|
||||
"dist": "npm run build && npm run builder",
|
||||
"dist:mac": "npm run build && npm run builder -- --mac",
|
||||
"dist:mac:dmg": "npm run build && npm run builder -- --mac dmg",
|
||||
"dist:mac:zip": "npm run build && npm run builder -- --mac zip",
|
||||
"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",
|
||||
"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",
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs",
|
||||
"type-check": "tsc -b",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' 'vite.config.ts'",
|
||||
"fix": "npm run lint:fix && npm run fmt",
|
||||
"test:ui": "vitest run --environment jsdom",
|
||||
"preview": "node scripts/assert-root-install.cjs && vite preview --host 127.0.0.1 --port 4174"
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "^0.12.28",
|
||||
"@assistant-ui/react-streamdown": "^0.1.11",
|
||||
"@audiowave/react": "^0.6.2",
|
||||
"@chenglou/pretext": "^0.0.6",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hermes/shared": "file:../shared",
|
||||
"@nanostores/react": "^1.1.0",
|
||||
"@nous-research/ui": "^0.13.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"@tabler/icons-react": "^3.41.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tanstack/react-query": "^5.100.6",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@vscode/codicons": "^0.0.45",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-unicode11": "^0.9.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.19.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"hast-util-from-html-isomorphic": "^2.0.0",
|
||||
"hast-util-to-text": "^4.0.2",
|
||||
"ignore": "^7.0.5",
|
||||
"katex": "^0.16.45",
|
||||
"leva": "^0.10.1",
|
||||
"motion": "^12.38.0",
|
||||
"nanostores": "^1.3.0",
|
||||
"node-pty": "1.1.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.5",
|
||||
"react-arborist": "^3.5.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"react-shiki": "^0.9.3",
|
||||
"remark-math": "^6.0.0",
|
||||
"shiki": "^4.0.2",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tw-shimmer": "^0.4.11",
|
||||
"unicode-animations": "^1.0.3",
|
||||
"unified": "^11.0.5",
|
||||
"unist-util-visit-parents": "^6.0.2",
|
||||
"vfile": "^6.0.3",
|
||||
"web-haptics": "^0.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
"@typescript-eslint/parser": "^8.59.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^40.9.3",
|
||||
"electron-builder": "^26.8.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-perfectionist": "^5.9.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.5",
|
||||
"wait-on": "^9.0.5"
|
||||
},
|
||||
"build": {
|
||||
"electronVersion": "40.9.3",
|
||||
"appId": "com.nousresearch.hermes",
|
||||
"productName": "Hermes",
|
||||
"executableName": "Hermes",
|
||||
"artifactName": "Hermes-${version}-${os}-${arch}.${ext}",
|
||||
"icon": "assets/icon",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**",
|
||||
"assets/**",
|
||||
"electron/**",
|
||||
"public/**",
|
||||
"package.json"
|
||||
],
|
||||
"beforeBuild": "scripts/before-build.cjs",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "build/install-stamp.json",
|
||||
"to": "install-stamp.json"
|
||||
},
|
||||
{
|
||||
"from": "build/native-deps",
|
||||
"to": "native-deps"
|
||||
}
|
||||
],
|
||||
"asar": true,
|
||||
"afterSign": "scripts/notarize.cjs",
|
||||
"asarUnpack": [
|
||||
"**/*.node",
|
||||
"**/prebuilds/**"
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"entitlements": "electron/entitlements.mac.plist",
|
||||
"entitlementsInherit": "electron/entitlements.mac.inherit.plist",
|
||||
"extendInfo": {
|
||||
"CFBundleDisplayName": "Hermes",
|
||||
"CFBundleExecutable": "Hermes",
|
||||
"CFBundleName": "Hermes",
|
||||
"NSAudioCaptureUsageDescription": "Hermes uses audio capture for voice conversations.",
|
||||
"NSMicrophoneUsageDescription": "Hermes uses the microphone for voice input and voice conversations."
|
||||
},
|
||||
"gatekeeperAssess": false,
|
||||
"hardenedRuntime": true,
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
]
|
||||
},
|
||||
"dmg": {
|
||||
"title": "Install Hermes",
|
||||
"backgroundColor": "#f5f5f7",
|
||||
"iconSize": 96,
|
||||
"window": {
|
||||
"width": 560,
|
||||
"height": 360
|
||||
},
|
||||
"contents": [
|
||||
{
|
||||
"x": 160,
|
||||
"y": 170,
|
||||
"type": "file"
|
||||
},
|
||||
{
|
||||
"x": 400,
|
||||
"y": 170,
|
||||
"type": "link",
|
||||
"path": "/Applications"
|
||||
}
|
||||
]
|
||||
},
|
||||
"win": {
|
||||
"legalTrademarks": "Hermes",
|
||||
"target": [
|
||||
"nsis",
|
||||
"msi"
|
||||
],
|
||||
"signAndEditExecutable": false
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"perMachine": false,
|
||||
"shortcutName": "Hermes",
|
||||
"uninstallDisplayName": "Hermes",
|
||||
"warningsAsErrors": false
|
||||
}
|
||||
}
|
||||
}
|
||||