Compare commits

..
Author SHA1 Message Date
Ben c83f85abe6 revert(desktop): remove the BETA gate — ship Hermes Cloud selector by default
Reverts the beta gating from 34d7d3f. The Hermes Cloud ModeCard in
Settings → Gateway now renders unconditionally alongside Local and Remote;
no BETA env var is consulted.

Not a clean `git revert`: commit 1b54d38 (the org-persist / stale-closure
fix) landed after the beta commit and edited the same useState block in
gateway-settings.tsx, so the revert conflicted there. Resolved by keeping
1b54d38's cloudOrgRef/setCloudOrg wrapper and dropping only the
cloudBetaEnabled state + its betaEnabled() useEffect. Everything else
(the sm:grid-cols-3 grid, the unconditional ModeCard, main.cjs/preload.cjs/
global.d.ts beta IPC) reverted automatically.

- main.cjs: drop betaFeaturesEnabled() + hermes:cloud:beta-enabled IPC
- preload.cjs / global.d.ts: drop the cloud.betaEnabled() bridge + type
- gateway-settings.tsx: drop the beta fetch/state; ModeCard renders
  unconditionally; grid back to sm:grid-cols-3

No test referenced the flag, so no test fallout. tsc clean; eslint clean on
all touched files.
2026-07-08 10:07:14 +10:00
Ben 1b54d38e75 fix(desktop): reliably persist cloud org, unselect cloud on mode switch, keep Change-org button after restore
Three fixes from live testing the org persist/restore flow:

1. Org not persisting (stale closure). discoverCloud() resolves the org
   asynchronously from the NAS response and setCloudOrg() is a React state
   update, but connectCloudAgent read the cloudOrg value captured in its render
   closure — often still null when the user clicked Connect in the same tick, so
   no org was saved. Mirror the org into a ref (cloudOrgRef) updated
   synchronously alongside state; connect reads cloudOrgRef.current.

2. Cloud connection lingered after switching away. coerceDesktopConnectionConfig
   inherits existingBlock.url across mode switches (correct for remote↔local),
   so switching cloud→local/remote kept the cloud instance URL in the remote
   block — re-selecting Cloud then looked 'already connected' with no way to
   re-pick. Added a leavingCloud rule: when the saved block was cloud and the new
   mode isn't cloud, start from an empty block (drop the cloud url/org/token),
   cleanly unselecting the cloud gateway. remote↔local toggles still preserve a
   real remote URL.

3. Change-org button vanished after restore-open. It was gated on
   cloudOrgs.length > 1, but the restore path discovers straight into the saved
   org and never populates cloudOrgs. Gate on cloudOrg being set instead, via a
   new changeCloudOrg() that clears the org + agent list and re-discovers with no
   org arg (multi-org → NAS 409 picker; single-org → auto-resolve back).

Depends on NAS #550 (echo resolved org), merged + live on prod (0dc86d0b).

tsc + eslint clean; 57 node --test + 16 vitest pass; all three verified live on
Ben's host (org persists + restores, cloud unselects on switch, Change-org shows
after reopen). The benign 'Session not found' 404 on backend switch is left as-is
(already handled by isSessionGoneError → fresh draft; dev-log noise only).

cloud-auto-discovery Phase 3/4 follow-up.
2026-07-01 13:28:42 +10:00
Ben 34d7d3fe2e feat(desktop): gate the Hermes Cloud gateway selector behind a BETA env flag
The Hermes Cloud ModeCard in Settings → Gateway now only appears when the BETA
env var is truthy (1/true/yes/on, case-insensitive); absent/empty/false/0 hides
it. While the feature is in beta, non-beta users see only Local + Remote.

- main.cjs: betaFeaturesEnabled() reads process.env.BETA; exposed via new IPC
  hermes:cloud:beta-enabled (the sandboxed renderer can't read process.env, and
  runtime IPC means the same build honors BETA per-launch with no rebuild).
- preload.cjs / global.d.ts: cloud.betaEnabled() bridge + type.
- gateway-settings.tsx: fetch the flag on mount (default false so it never
  flashes in for non-beta users), conditionally render the Cloud ModeCard, and
  flip the grid sm:grid-cols-3 → sm:grid-cols-2 when hidden.

Gates the SELECTOR only — an already-saved cloud connection keeps working if
BETA is later turned off; only newly selecting cloud is hidden.

tsc + eslint clean; 57 node --test + 16 vitest pass; env parsing unit-checked
across 9 cases; gate verified in the packaged bundle.

cloud-auto-discovery beta gating.
2026-07-01 13:07:46 +10:00
Ben 7e1e9d62c4 feat(desktop): persist the selected Hermes Cloud org + instance; restore on reopen
Settings → Gateway remembered 'cloud' mode but not WHICH org/instance, so
reopening dropped multi-org users back to the org picker, hiding the connected
agent (reported live).

- Persist a cloudOrg on the saved cloud connection (rides the remote block:
  coerce reads input.cloudOrg / inherits saved; buildRemoteBlock + profile
  sanitizer carry it; sanitize echoes it back as config.cloudOrg). Only for
  mode:'cloud'; plain remote is unchanged. The instance was already persisted as
  remoteUrl (the dashboardUrl).
- discoverCloudAgents now returns the org NAS echoes in the response
  (trimCloudOrg), and the renderer records cloudOrg AUTHORITATIVELY from
  result.org — so it's set even on single-membership auto-resolve where no
  picker ran (the exact case that left the org unpersisted). Requires NAS #550
  (echo resolved org in /api/agents); before that deploys, falls back to the
  requested org.
- On open, the cloud-status effect seeds cloudOrg from the persisted
  config.cloudOrg and discovers scoped to it, so Settings reopens straight into
  that org's agent list instead of the picker.
- connectCloudAgent passes cloudOrg when saving so the choice sticks.
- The connected instance is highlighted (primary tint + ring) and shows a
  'Connected' pill instead of a Connect button (compares saved remoteUrl to each
  agent's dashboardUrl, normalized).

tsc + eslint clean; 57 node --test + 16 vitest pass. Connected-pill verified live;
org-restore pending NAS #550 deploy for the authoritative echo.

cloud-auto-discovery Phase 3/4 follow-up.
2026-07-01 12:46:01 +10:00
Ben d8c00b8ce2 feat(desktop): linkify 'Nous portal' in the cloud no-agents message
When Hermes Cloud discovery returns zero agents, the empty-state message now
renders 'Nous portal' as a hyperlink to https://portal.nousresearch.com/agents
(opened via the app's ExternalLink → shell.openExternal), so the user can jump
straight to creating an agent instead of finding the portal manually.

The cloudNoAgents i18n string becomes { before, linkText, after } (en + zh) so
each locale controls link placement; ja/zh-hant fall back to en via defineLocale.
No external-link icon on this inline link to keep the sentence clean.

tsc + eslint clean; link verified present in the packaged renderer bundle.
2026-07-01 12:15:55 +10:00
Ben 883fa67b7e fix(desktop): make the per-agent cloud cascade actually silent (load protected root, not /login)
The silent per-agent sign-in (decisions.md Q5) was prompting a SECOND interactive
login after portal sign-in → org → dashboard selection (Ben's screencast). Root
cause: cloudAgentSilentSignIn → openOauthLoginWindow loaded the agent gateway's
/login, but /login is a PUBLIC route (dashboard-auth middleware allowlist), so the
gate's _auto_sso_response never runs there — it only fires on an unauthenticated
load of a PROTECTED page. The window therefore rendered the interactive
'Log in with X' chooser every time, instead of the silent 302 cascade. (Auto-SSO
is correctly configured on hosted agents: exactly one 'nous' session provider,
client_id agent:{id}, so it would have fired silently if triggered.)

Fix: openOauthLoginWindow(baseUrl, { silent }). The cascade passes silent:true,
which loads the PROTECTED root '/' instead of '/login'. The gate then runs
auto-SSO — single provider + a live partition portal session → 302 through
/auth/login → portal /oauth/authorize (auto-approves org members) → /auth/callback
sets the gateway session cookie with NO prompt. In silent mode the window also
starts HIDDEN and only reveals after 2.5s if the cascade hasn't completed
(graceful fallback to interactive, e.g. the portal session lapsed). The
interactive remote-gateway login (settings UI) keeps silent:false → /login
chooser, behavior unchanged.

Verified live end-to-end on Ben's host: portal sign-in → org picker → select agent
→ Connect now completes with no second login prompt.

cloud-auto-discovery Phase 3 follow-up (decisions.md Q9).
2026-07-01 12:08:20 +10:00
Ben 7e06e61fc8 fix(desktop): Hermes Cloud sign-in uses Privy session + multi-org org picker
Two fixes surfaced by the first live end-to-end test of cloud sign-in (both
would have shipped broken — green units + code review did not catch them).

1. Portal session is PRIVY, not Hermes-gateway cookies (Q7). Phase 3 polled for
   hermes_session_at/rt on the portal host, but the Nous portal (NAS) is a
   Privy-authed Next.js app — it sets privy-token (which NAS auth() and the
   /api/agents cookie path both read). The sign-in window therefore never
   detected success and hung. Fix: cookiesHavePrivySession (privy-token + __Host/
   __Secure/legacy privy-session variants) in connection-config.cjs, and
   hasLivePortalSession now checks the Privy cookie on the portal host. The
   per-agent silent cascade still uses the gateway-cookie check (each agent IS a
   Hermes gateway).

2. Multi-org discovery needs an org picker (Q8). A portal session carries no org
   pin, so a user in >1 org got a dead-end 403. Paired with NAS #545 (merged):
   /api/agents now returns 409 org_selection_required + the user's org list, and
   accepts a membership-validated ?org=. discoverCloudAgents(org) appends ?org=,
   and on 409 returns { needsOrgSelection, orgs } instead of throwing; the cloud
   panel shows a 'Choose an organization' picker, then re-runs discovery scoped
   to the chosen org (with a 'Change org' affordance for multi-org users).

Also reverts the ERR_NETWORK_CHANGED retry helper from the prior commit: the
IPv6-churn aborts on Ben's Arch host are a host/network-layer issue, and a
client reload can't safely drive Privy's single-use-code redirect chain
(disable IPv6 for the session is the workaround). Kept out of this feature PR.

Tests: connection-config.test.cjs (57, +5 Privy-cookie cases, proven to fail
without the helper); boot-failure-reauth (16). tsc + eslint clean. Verified live
end-to-end against prod portal: sign-in → org picker → scoped agent list →
silent per-agent connect.

cloud-auto-discovery Phases 3+4 follow-up (decisions.md Q7, Q8).
2026-07-01 09:44:23 +10:00
Ben 318910ce80 feat(desktop): Hermes Cloud mode card + agent picker in Gateway settings
Phase 4 of cloud-auto-discovery — the UI on top of the Phase 3 cloud plumbing.

Adds a third 'Hermes Cloud' ModeCard alongside Local/Remote in gateway-settings.
Selecting it reveals the cloud panel instead of the URL/token form:
- signed-out → 'Sign in to Hermes Cloud' (one portal login in the OAuth partition)
- signed-in  → a discovered-agent picker (loading / empty / list states) with a
  Refresh control. Selecting an agent drives the silent per-agent cascade
  (cloud.agentSignIn) then applies a mode:'cloud' connection pointed at its
  dashboardUrl — no second sign-in prompt.
Cloud auto-discovers on entering the mode when a portal session already exists.
Test/Save bottom-row actions are hidden in cloud mode (selection applies the
connection); the remote URL/token form is now gated to remote mode only.

Wires the renderer to the Phase 3 IPC (window.hermesDesktop.cloud.*). i18n
strings added to en + zh (full) and the Translations type; ja/zh-hant inherit via
defineLocale fallback. New 'Cloud' icon (IconCloud) exported from lib/icons.

Validated: tsc clean, eslint clean, vite renderer build succeeds, 52 electron +
16 vitest tests pass.

cloud-auto-discovery Phase 4.
2026-06-30 13:55:34 +10:00
Ben 2704e6e39c feat(desktop): cloud connection mode plumbing — widen mode, portal login, discovery, silent cascade
Phase 3 (non-UI) of cloud-auto-discovery. Adds the 'cloud' connection mode and
the IPC plumbing for a single portal login that powers both agent discovery and
silent per-agent sign-in. The Phase 4 UI (cloud ModeCard + instance picker)
sits on top of these IPC methods.

Mode widening (Model A, decisions.md Q6): DesktopConnectionConfig.mode and
DesktopConnectionConfigInput.mode widen to 'local'|'remote'|'cloud'. A cloud
entry is a remote-shaped block (remoteUrl = the selected agent's dashboardUrl,
remoteAuthMode 'oauth') tagged mode 'cloud' so settings reopens into the cloud
picker. Every RESOLUTION site treats cloud as remote via the new
modeIsRemoteLike() helper (centralized in connection-config.cjs): readDesktop-
ConnectionConfig, sanitizeConnectionProfiles, sanitizeDesktopConnectionConfig,
coerceDesktopConnectionConfig, profileRemoteOverride, resolveRemoteBackend,
globalRemoteActive, testDesktopConnectionConfig, and isRemoteReauthFailure. The
live resolved HermesConnection.mode stays 'local'|'remote' — cloud never reaches
the boot path or the renderer remote-gating sites.

Cloud mechanics (main.cjs): one portal session in the persist:hermes-remote-oauth
partition does double duty — discoverCloudAgents() GETs {portal}/api/agents over
the partition-bound net (cookie-authed; NAS #542 accepts the cookie), and
cloudAgentSilentSignIn() opens a selected agent's /login in the same partition so
the portal's silent auto-approve 302s back with that agent's session cookie, no
second prompt. Portal base URL resolves via DEFAULT_NOUS_PORTAL_URL +
HERMES_PORTAL_BASE_URL/NOUS_PORTAL_BASE_URL overrides, mirroring the CLI.

IPC: hermes☁️{status,login,logout,discover,agent-sign-in} in main.cjs +
preload.cjs, typed in global.d.ts (DesktopCloudStatus/Agent/DiscoverResult/
AgentSignInResult).

Tests: modeIsRemoteLike + cloud profileRemoteOverride (node --test, 52 pass);
cloud reauth-failure cases (vitest, 16 pass). tsc clean; eslint clean. New tests
verified to fail without the source changes.

cloud-auto-discovery Phase 3 (non-discovery half + discovery/cascade plumbing).
2026-06-30 13:45:33 +10:00
103 changed files with 2102 additions and 5696 deletions
-64
View File
@@ -165,67 +165,3 @@ jobs:
sys.exit(1)
print('All checks passed (or were skipped)')
"
# ─────────────────────────────────────────────────────────────────────
# CI timing report: collect per-job/step durations from the GitHub API,
# cache them on main (as a baseline), and on PRs generate an HTML diff
# report with a gantt chart + per-step breakdown. The report is uploaded
# as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY.
# ─────────────────────────────────────────────────────────────────────
ci-timings:
name: CI timing report
needs: [all-checks-pass, docker]
if: always()
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore baseline cache (PR only)
if: github.event_name == 'pull_request'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ci-timings-baseline.json
# Prefix-match: exact key will never hit (run_id differs), so
# restore-keys finds the most recent baseline from main.
key: ci-timings-baseline-never-exact
restore-keys: |
ci-timings-baseline-
- name: Collect timings and generate report
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python3 scripts/ci/timings_report.py \
--baseline ci-timings-baseline.json \
--output ci-timings-report.html \
--json-out ci-timings.json \
--summary-out ci-timings-summary.md
- name: Upload HTML report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
id: ci-timings-artifact
with:
name: ci-timings-report
path: ci-timings-report.html
retention-days: 14
archive: false
- name: Output summary
env:
REPORT_URL: ${{ steps.ci-timings-artifact.outputs.artifact-url}}
run: |
echo "# CI Timing report" >> "$GITHUB_STEP_SUMMARY"
echo "[View the full interactive report]($REPORT_URL)" >> "$GITHUB_STEP_SUMMARY"
cat ci-timings-summary.md >> "$GITHUB_STEP_SUMMARY"
- name: Save baseline cache (main only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: cp ci-timings.json ci-timings-baseline.json
- name: Upload baseline to cache (main only)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ci-timings-baseline.json
key: ci-timings-baseline-${{ github.run_id }}
+176 -70
View File
@@ -7,11 +7,15 @@ on:
permissions:
contents: read
# Needed so the arm64 job can push/pull its registry-backed build cache
# to ghcr.io (cache-to/cache-from type=registry). See the build-arm64
# job for why registry cache replaced the gha cache on that arch.
packages: write
# Concurrency: push/release runs are NEVER cancelled so every merge gets
# its own image. PR runs reuse a PR-scoped group with
# cancel-in-progress: true so rapid pushes to the same PR collapse to
# the latest commit.
# cancel-in-progress: true so rapid pushes to the same PR collapse to the
# latest commit.
concurrency:
group: docker-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
@@ -20,91 +24,39 @@ env:
IMAGE_NAME: nousresearch/hermes-agent
jobs:
# Build, test, and optionally push the image for each architecture.
build:
# Build, test, and optionally push the amd64 image.
build-amd64:
# Only run on the upstream repository, not on forks
if: github.repository == 'NousResearch/hermes-agent'
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
platform: linux/amd64
cache-from: type=gha,scope=docker-amd64
cache-to: type=gha,mode=max,scope=docker-amd64
- arch: arm64
runner: ubuntu-24.04-arm
platform: linux/arm64
cache-from: type=gha,scope=docker-arm64
cache-to: type=gha,mode=max,scope=docker-arm64
runs-on: ${{ matrix.runner }}
runs-on: ubuntu-latest
timeout-minutes: 45
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# The image build + integration tests run on every event
# (PRs, push-to-main, release). Publish steps below are gated to
# push-to-main / release only.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
# Build once, load into the local daemon for testing. Cached
# per-arch; the push step below reuses every layer from this build.
- name: Build image (${{ matrix.arch }})
# to gha with a per-arch scope; the push step below reuses every
# layer from this build.
- name: Build image (amd64)
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
load: true
platforms: ${{ matrix.platform }}
platforms: linux/amd64
tags: ${{ env.IMAGE_NAME }}:test
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
cache-from: ${{ matrix.cache-from }}
cache-to: ${{ (github.event_name != 'pull_request') && matrix.cache-to || '' }}
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Push 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.
- name: Push ${{ matrix.arch }} by digest
id: push
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: ${{ matrix.cache-from }}
cache-to: ${{ matrix.cache-to }}
# Write the digest to a file and upload it as an artifact so the
# merge job can stitch both per-arch digests into a manifest list.
- name: Export digest
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
run: |
mkdir -p /tmp/digests
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digest-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
cache-from: type=gha,scope=docker-amd64
cache-to: type=gha,mode=max,scope=docker-amd64
# Run the docker-integration test suite against the freshly-built
# image already loaded into the local daemon (`:test`).
@@ -146,6 +98,160 @@ jobs:
run: |
scripts/run_tests.sh tests/docker/ --file-timeout 600
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# 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.
- name: Push amd64 by digest
id: push
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
platforms: linux/amd64
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=docker-amd64
cache-to: type=gha,mode=max,scope=docker-amd64
# Write the digest to a file and upload it as an artifact so the
# merge job can stitch both per-arch digests into a manifest list.
- name: Export digest
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
run: |
mkdir -p /tmp/digests
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digest-amd64
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# ---------------------------------------------------------------------------
# Build, test, and optionally push the arm64 image.
# ---------------------------------------------------------------------------
build-arm64:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-24.04-arm
timeout-minutes: 45
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
# Log in to ghcr.io so the registry-backed build cache below can be
# read (cache-from) on every event and written (cache-to) on
# push/release. Uses the workflow's GITHUB_TOKEN, which is valid for
# the whole job — unlike the gha cache backend's short-lived Azure SAS
# token, which expired mid-build on slow cold-cache arm64 runs and
# crashed the build before the tests ran (the reason the gha cache
# was removed from arm64 PRs in the first place).
- name: Log in to ghcr.io (build cache)
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Build once, load into the local daemon for testing, then push
# by digest below. Reads AND writes the registry-backed cache so the
# push reuses layers from this build and the next build starts warm.
#
# Registry cache (type=registry on ghcr.io) is used instead of the gha
# cache that previously broke here: its credential is the job-lifetime
# GITHUB_TOKEN, not a short-lived SAS token, so the cold-build-outlives-
# token failure mode cannot recur.
- name: Build image (arm64, cached publish)
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
load: true
platforms: linux/arm64
tags: ${{ env.IMAGE_NAME }}:test
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
- name: Install uv for docker tests
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
- name: Set up Python 3.11 for docker tests
run: uv python install 3.11
- name: Install Python dependencies for docker tests
run: |
uv sync --locked --python 3.11 --extra dev
- name: Run docker tests
env:
# Skip rebuild; use the image already loaded by the build step.
HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
OPENROUTER_API_KEY: ""
OPENAI_API_KEY: ""
NOUS_API_KEY: ""
run: |
scripts/run_tests.sh tests/docker/ --file-timeout 600
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Push arm64 by digest
id: push
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
platforms: linux/arm64
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
- name: Export digest
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
run: |
mkdir -p /tmp/digests
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digest-arm64
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# ---------------------------------------------------------------------------
# Stitch both per-arch digests into a single tagged multi-arch manifest.
# This is a registry-side operation — no building, no layer re-push —
@@ -157,7 +263,7 @@ jobs:
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]
needs: [build-amd64, build-arm64]
timeout-minutes: 10
steps:
- name: Download digests
+40
View File
@@ -109,6 +109,46 @@ jobs:
--output .lint-reports/summary.md
cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY"
- name: Upload reports as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: lint-reports
path: .lint-reports/
retention-days: 14
- name: Post / update PR comment
if: inputs.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
continue-on-error: true
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
with:
script: |
const fs = require('fs');
const body = fs.readFileSync('.lint-reports/summary.md', 'utf8');
const marker = '<!-- lint-diff-summary -->';
const fullBody = marker + '\n' + body;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: fullBody,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: fullBody,
});
}
ruff-blocking:
# Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently
# PLW1514 (unspecified-encoding) — catches bare ``open()`` /
+53 -10
View File
@@ -1289,22 +1289,65 @@ scripts/run_tests.sh # full suite, CI-parity
scripts/run_tests.sh tests/gateway/ # one directory
scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test
scripts/run_tests.sh -v --tb=long # pass-through pytest flags
scripts/run_tests.sh --no-isolate tests/foo/ # disable subprocess isolation (faster, for debugging)
```
### Subprocess-per-test-file isolation
### Subprocess-per-test isolation
Every test file runs in a freshly-spawned Python subprocess via `run_tests_parallel.py`. This means module-level dicts/sets and
ContextVars from one test file cannot leak into the next.
Every test runs in a freshly-spawned Python subprocess via the in-tree plugin
at `tests/_isolate_plugin.py`. This means module-level dicts/sets and
ContextVars from one test cannot leak into the next — the historic
`_reset_module_state` autouse fixture is gone.
### Why the wrapper
Implementation notes:
| | Without wrapper | With wrapper |
| ------------------- | ------------------------------------------- | ----------------------------------------- |
| Provider API keys | Whatever is in your env (auto-detects pool) | All env vars except a specific few unset. |
| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
| Timezone | Local TZ (PDT etc.) | UTC |
| Locale | Whatever is set | C.UTF-8 |
- The plugin uses `multiprocessing.get_context("spawn")`, which works on
Linux, macOS, and Windows alike (POSIX `fork` is not used).
- Per-test overhead is ~0.5–1.0s (Python startup + pytest collection). xdist
parallelism amortizes this across cores; on a 20-core box the full suite
finishes in roughly the same wall time as before, but flake-free.
- `isolate_timeout` (configured in `pyproject.toml`) caps each test at 30s.
Hangs are killed and surfaced as a failure report.
- Pass `--no-isolate` to disable isolation — useful when debugging a single
test interactively, or when you specifically want to verify state leakage.
- The plugin disables itself in child processes (sentinel envvar
`HERMES_ISOLATE_CHILD=1`), so there's no fork-bomb risk.
### Why the wrapper (and why the old "just call pytest" doesn't work)
Five real sources of local-vs-CI drift the script closes:
| | Without wrapper | With wrapper |
|---|---|---|
| Provider API keys | Whatever is in your env (auto-detects pool) | All `*_API_KEY`/`*_TOKEN`/etc. unset |
| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
| Timezone | Local TZ (PDT etc.) | UTC |
| Locale | Whatever is set | C.UTF-8 |
| xdist workers | `-n auto` = all cores | `-n auto` (safe — subprocess isolation prevents cross-worker flakes) |
`tests/conftest.py` also enforces points 1-4 as an autouse fixture so ANY pytest
invocation (including IDE integrations) gets hermetic behavior — but the wrapper
is belt-and-suspenders.
### Running without the wrapper (only if you must)
If you can't use the wrapper (e.g. inside an IDE that shells pytest directly),
at minimum activate the venv. The isolation plugin loads automatically from
`addopts` in `pyproject.toml`, so you get the same per-test process isolation
either way.
```bash
source .venv/bin/activate # or: source venv/bin/activate
python -m pytest tests/ -q
```
If you need to bypass isolation for fast feedback while debugging:
```bash
python -m pytest tests/agent/test_foo.py -q --no-isolate
```
Always run the full suite before pushing changes.
### Don't write change-detector tests
-31
View File
@@ -353,29 +353,6 @@ def _coding_mode(config: Optional[dict[str, Any]]) -> str:
return "auto"
def _coding_instructions(config: Optional[dict[str, Any]]) -> str:
"""Standing operator instructions for the coding posture (config).
``agent.coding_instructions`` — a string or list of strings appended to the
coding brief as an extra stable system block, so a user can pin project-wide
coding-workflow rules (e.g. "for UI work don't run tsc/lint until I approve;
clean the diff before committing") without editing the shipped brief.
Cache-safe: resolved once per session into the stable system-prompt tier,
like the rest of the posture.
"""
if config is None:
try:
from hermes_cli.config import load_config
config = load_config()
except Exception:
config = {}
raw = ((config or {}).get("agent", {}) or {}).get("coding_instructions", "")
if isinstance(raw, (list, tuple)):
return "\n".join(str(item).strip() for item in raw if str(item).strip())
return str(raw or "").strip()
def _resolve_cwd(cwd: Optional[str | Path]) -> Path:
if cwd:
return Path(cwd).expanduser()
@@ -482,9 +459,6 @@ class RuntimeMode:
# only to steer edit-format guidance toward the model's family — see
# ``_edit_format_line``. Fixed for the session, so cache-safe.
model: Optional[str] = None
# Standing operator instructions (``agent.coding_instructions``), appended
# as an extra stable system block. Empty unless the user configures it.
instructions: str = ""
@property
def kind(self) -> str:
@@ -531,10 +505,6 @@ class RuntimeMode:
workspace = build_coding_workspace_block(self.cwd)
if workspace:
blocks.append(workspace)
# Operator instructions ride their own block so the brief (block 0) stays
# byte-stable and cache-keyed independently of user config.
if self.instructions:
blocks.append(f"Operator instructions (from config):\n{self.instructions}")
return blocks
def compact_skill_categories(self) -> frozenset[str]:
@@ -587,7 +557,6 @@ def resolve_runtime_mode(
cwd=resolved_cwd,
config_mode=mode,
model=model,
instructions=_coding_instructions(config),
)
-49
View File
@@ -4810,55 +4810,6 @@ def run_conversation(
agent._verification_stop_nudges)
continue
# User verification-loop gate: when the agent edited code this
# turn, let a registered `pre_verify` hook (plugin/shell) keep it
# going one more turn. The shipped guidance is folded into the
# evidence-based verify-on-stop nudge above, so this path has no
# default continuation cost.
_verify_nudge2 = None
_edited = sorted(getattr(agent, "_turn_file_mutation_paths", set()) or [])
_attempt = getattr(agent, "_pre_verify_nudges", 0)
try:
from agent.verify_hooks import max_verify_nudges
from hermes_cli.plugins import get_pre_verify_continue_message, has_hook
if _edited and has_hook("pre_verify") and _attempt < max_verify_nudges():
# Posture is fixed for the session — resolve once + cache.
coding = getattr(agent, "_resolved_is_coding", None)
if coding is None:
from agent.coding_context import is_coding_context
coding = bool(is_coding_context(platform=getattr(agent, "platform", "") or ""))
agent._resolved_is_coding = coding
_verify_nudge2 = get_pre_verify_continue_message(
session_id=getattr(agent, "session_id", None) or "",
platform=getattr(agent, "platform", "") or "",
model=getattr(agent, "model", "") or "",
coding=coding,
attempt=_attempt,
final_response=final_response,
changed_paths=_edited,
)
except Exception:
logger.debug("pre_verify hook check failed", exc_info=True)
_verify_nudge2 = None
if _verify_nudge2:
agent._pre_verify_nudges = _attempt + 1
final_msg["finish_reason"] = "verify_hook_continue"
# Same alternation contract as verify-on-stop: keep the
# attempted answer in history, follow it with a synthetic
# user nudge, and don't surface the premature answer.
messages.append(final_msg)
messages.append({
"role": "user",
"content": _verify_nudge2,
"_pre_verify_synthetic": True,
})
agent._session_messages = messages
logger.debug("pre_verify nudge issued (attempt %d)",
agent._pre_verify_nudges)
continue
messages.append(final_msg)
_turn_exit_reason = f"text_response(finish_reason={finish_reason})"
-116
View File
@@ -537,122 +537,6 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
return preview
# =========================================================================
# Friendly tool labels (human-phrased verbs for built-in tools)
#
# Turns "web_search <query>" into "Searching the web for <query>" — the
# ChatGPT-style "Searching…/Reading…" surface. Curated and built-in only:
# we know each core tool's semantics, so the verb is fixed, not computed.
# Custom/plugin/MCP tools have no entry and fall back to the raw preview.
# =========================================================================
# Each entry maps a built-in tool name to its present-participle verb phrase.
# A trailing space-then-preview is appended by build_tool_label() when the
# tool's argument preview is available (e.g. "Reading docs/api.md").
_TOOL_VERBS: dict[str, str] = {
"web_search": "Searching the web",
"web_extract": "Reading",
"browser_navigate": "Browsing",
"browser_click": "Clicking",
"browser_type": "Typing",
"read_file": "Reading",
"write_file": "Writing",
"patch": "Editing",
"search_files": "Searching files",
"terminal": "Running",
"execute_code": "Running code",
"image_generate": "Generating image",
"video_generate": "Generating video",
"text_to_speech": "Generating speech",
"vision_analyze": "Looking at the image",
"session_search": "Searching past sessions",
"skill_view": "Reading skill",
"skills_list": "Listing skills",
"skill_manage": "Updating skill",
"delegate_task": "Delegating",
"cronjob": "Scheduling",
"clarify": "Asking",
"memory": "Updating memory",
"todo": "Updating tasks",
}
# Verbs that read better without the raw argument preview appended.
_TOOL_VERBS_NO_PREVIEW: frozenset[str] = frozenset({
"skills_list",
"session_search",
})
# Verbs that take a "for" connector before the preview (search-style phrasing):
# "Searching the web for <query>" reads better than "Searching the web <query>".
_TOOL_VERBS_FOR_CONNECTOR: frozenset[str] = frozenset({
"web_search",
"search_files",
})
_friendly_tool_labels: bool = True
def set_friendly_tool_labels(enabled: bool) -> None:
"""Toggle friendly human-phrased tool labels (display.friendly_tool_labels)."""
global _friendly_tool_labels
_friendly_tool_labels = bool(enabled)
def get_friendly_tool_labels() -> bool:
"""Return whether friendly tool labels are enabled."""
return _friendly_tool_labels
def get_tool_verb(tool_name: str) -> str | None:
"""Return the friendly verb for a built-in tool, or None.
Returns None when friendly labels are disabled or the tool has no curated
verb (custom/plugin/MCP tools). Callers that already hold a computed
argument preview can compose ``f"{verb} {preview}"`` themselves; use
:func:`tool_verb_connector` to pick the right joiner.
"""
if not _friendly_tool_labels:
return None
return _TOOL_VERBS.get(tool_name)
def tool_verb_connector(tool_name: str) -> str:
"""Return the connector between a verb and its preview (" for " or " ")."""
return " for " if tool_name in _TOOL_VERBS_FOR_CONNECTOR else " "
def verb_drops_preview(tool_name: str) -> bool:
"""Whether the verb should render alone, without the argument preview."""
return tool_name in _TOOL_VERBS_NO_PREVIEW
def build_tool_label(tool_name: str, args: dict, max_len: int | None = None) -> str | None:
"""Build a human-phrased status label for a tool call.
For built-in tools with a known verb (``web_search`` -> "Searching the
web for ..."), returns the verb optionally followed by the argument
preview. For everything else (custom/plugin/MCP tools, or when friendly
labels are disabled) returns the raw preview, so callers can use this as a
drop-in replacement for :func:`build_tool_preview`.
"""
if not _friendly_tool_labels:
return build_tool_preview(tool_name, args, max_len=max_len)
verb = _TOOL_VERBS.get(tool_name)
if not verb:
return build_tool_preview(tool_name, args, max_len=max_len)
if tool_name in _TOOL_VERBS_NO_PREVIEW:
return verb
preview = build_tool_preview(tool_name, args, max_len=max_len)
if not preview:
return verb
if tool_name in _TOOL_VERBS_FOR_CONNECTOR:
return f"{verb} for {preview}"
return f"{verb} {preview}"
# =========================================================================
# Inline diff previews for write actions
# =========================================================================
-11
View File
@@ -588,17 +588,6 @@ def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]:
return {"action": "block", "message": _block_message(data.get("reason"), data.get("message"))}
return None
if event == "pre_verify":
# "continue" (Hermes) / "block" (Claude-Code Stop: block the stop) both
# mean keep going; the message/reason is the follow-up for the model. A
# continue with no message is a no-op — let the turn finish.
action = str(data.get("action") or data.get("decision") or "").strip().lower()
if action in {"continue", "block"}:
message = data.get("message") or data.get("reason")
if isinstance(message, str) and message.strip():
return {"action": "continue", "message": message.strip()}
return None
context = data.get("context")
if isinstance(context, str) and context.strip():
return {"context": context}
+3 -4
View File
@@ -24,7 +24,6 @@ from typing import Any, Optional
from agent.display import (
KawaiiSpinner,
build_tool_preview as _build_tool_preview,
build_tool_label as _build_tool_label,
get_cute_tool_message as _get_cute_tool_message_impl,
get_tool_emoji as _get_tool_emoji,
redact_tool_args_for_display as _redact_tool_args_for_display,
@@ -1225,7 +1224,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
face = random.choice(KawaiiSpinner.get_waiting_faces())
emoji = _get_tool_emoji(function_name)
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
preview = _build_tool_label(function_name, display_args) or function_name
preview = _build_tool_preview(function_name, display_args) or function_name
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
spinner.start()
_ce_result = None
@@ -1259,7 +1258,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
face = random.choice(KawaiiSpinner.get_waiting_faces())
emoji = _get_tool_emoji(function_name)
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
preview = _build_tool_label(function_name, display_args) or function_name
preview = _build_tool_preview(function_name, display_args) or function_name
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
spinner.start()
_mem_result = None
@@ -1291,7 +1290,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
face = random.choice(KawaiiSpinner.get_waiting_faces())
emoji = _get_tool_emoji(function_name)
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
preview = _build_tool_label(function_name, display_args) or function_name
preview = _build_tool_preview(function_name, display_args) or function_name
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
spinner.start()
_spinner_result = None
-1
View File
@@ -443,7 +443,6 @@ def build_turn_context(
agent._turn_failed_file_mutations = {}
agent._turn_file_mutation_paths = set()
agent._verification_stop_nudges = 0
agent._pre_verify_nudges = 0
# Record the execution thread so interrupt()/clear_interrupt() can scope
# the tool-level interrupt signal to THIS agent's thread only.
+1 -11
View File
@@ -273,15 +273,6 @@ def build_verify_on_stop_nudge(
if state == "passed":
return None
# Optional shipped coding guidance, only paid when this evidence gate fires.
try:
from agent.verify_hooks import coding_verify_guidance
guidance = coding_verify_guidance()
except Exception:
guidance = None
addendum = f"\n\n{guidance}" if guidance else ""
if verify_commands:
command_instruction = (
"Run the relevant verification command now ("
@@ -306,8 +297,7 @@ def build_verify_on_stop_nudge(
f"Verification status: {_status_detail(status)}\n\n"
f"Changed paths:\n{_format_changed_paths(paths)}\n\n"
f"{command_instruction} If verification is not possible, explain the "
"concrete blocker instead of claiming the work is fully verified."
f"{addendum}]"
"concrete blocker instead of claiming the work is fully verified.]"
)
-69
View File
@@ -1,69 +0,0 @@
"""Verification-loop helpers for the ``pre_verify`` round-end gate.
When the agent has edited code and is about to verify/finish, the loop fires the
``pre_verify`` hook (user directives resolved by
:func:`hermes_cli.plugins.get_pre_verify_continue_message`). A directive keeps
the agent going one more turn — run a check, defer it, tidy the diff — instead of
stopping immediately.
The shipped coding guidance lives on the evidence-based verification-stop nudge
(``agent/verification_stop.py``), not as a second default stop gate. That keeps
the default token cost tied to the existing "missing verification evidence"
decision while preserving ``pre_verify`` for user/plugin policy.
"""
from __future__ import annotations
from typing import Any, Optional
from utils import is_truthy_value
DEFAULT_MAX_VERIFY_NUDGES = 3
# Shipped guidance appended to the verification-stop nudge when code lacks fresh
# verification evidence. Wording mirrors the user-facing "clean your work"
# workflow, but does not create its own extra model turn.
CODING_VERIFY_GUIDANCE = (
"[Coding] Before you run tests/linters or call this done: if this is "
"creative UI/visual work, hold off on tests and linters until the user says "
"they like the result or you're about to commit. And before every commit, "
"clean your work: keep it KISS/DRY, match the surrounding code style, and be "
"elitist, shorthand, clever, concise, efficient, and elegant."
)
def max_verify_nudges(config: Optional[dict[str, Any]] = None) -> int:
"""Bound on consecutive ``pre_verify`` continue directives per turn (>= 0)."""
agent_cfg = _agent_cfg(config)
raw = agent_cfg.get("max_verify_nudges")
try:
return max(0, int(raw))
except (TypeError, ValueError):
return DEFAULT_MAX_VERIFY_NUDGES
def coding_verify_guidance(config: Optional[dict[str, Any]] = None) -> Optional[str]:
"""Return the optional guidance appended to verification-stop nudges."""
if not is_truthy_value(_agent_cfg(config).get("verify_guidance", True), default=True):
return None
return CODING_VERIFY_GUIDANCE
def _agent_cfg(config: Optional[dict[str, Any]]) -> dict[str, Any]:
if config is None:
try:
from hermes_cli.config import load_config
config = load_config()
except Exception:
config = {}
agent_cfg = (config or {}).get("agent") if isinstance(config, dict) else None
return agent_cfg if isinstance(agent_cfg, dict) else {}
__all__ = [
"CODING_VERIFY_GUIDANCE",
"DEFAULT_MAX_VERIFY_NUDGES",
"coding_verify_guidance",
"max_verify_nudges",
]
+44 -3
View File
@@ -37,6 +37,20 @@
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt']
// The Nous portal (NAS) does NOT use Hermes gateway session cookies — it is a
// Privy-authed Next.js app. NAS `auth()` (src/server/auth/session.ts) reads the
// `privy-token` access-token cookie (with `privy-id-token` alongside), which is
// also exactly what the `/api/agents` cookie-auth path validates. So portal
// sign-in / discovery liveness must look for the Privy cookie, NOT the gateway
// cookies above. `privy-token` is the access token (the required signal);
// variants cover the secured-prefix forms and the older `privy-session` name.
const PRIVY_SESSION_COOKIE_VARIANTS = [
'__Host-privy-token',
'__Secure-privy-token',
'privy-token',
'privy-session'
]
function normalizeRemoteBaseUrl(rawUrl) {
const value = String(rawUrl || '').trim()
@@ -142,19 +156,30 @@ function normAuthMode(mode) {
return mode === 'oauth' ? 'oauth' : 'token'
}
// True for connection modes that resolve to a REMOTE backend. 'cloud' is a
// Hermes Cloud connection (cloud-auto-discovery Q3/Q6): it carries a
// remote-shaped block and reuses the entire remote connect/probe/reconnect
// path, so every resolution site treats it exactly like 'remote'. The only
// places that distinguish cloud from remote are the settings UI (which card to
// show) and config persistence (remembering the provenance). Centralized here
// so no resolution site forgets the third arm.
function modeIsRemoteLike(mode) {
return mode === 'remote' || mode === 'cloud'
}
/**
* Select a profile's explicit remote override from a connection config, or null
* when it has none (so the caller falls back to env → global remote → local).
*
* The config may carry a `profiles` map keyed by name; an entry counts as an
* override only with `mode === 'remote'` and a non-empty `url`. Pure: `token`
* is the raw stored secret; main.cjs decrypts it. Returns
* override only with a remote-like `mode` (remote or cloud) and a non-empty
* `url`. Pure: `token` is the raw stored secret; main.cjs decrypts it. Returns
* `{ url, authMode, token } | null`.
*/
function profileRemoteOverride(config, profile) {
const key = connectionScopeKey(profile)
const entry = key ? config?.profiles?.[key] : null
if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') {
if (!entry || typeof entry !== 'object' || !modeIsRemoteLike(entry.mode)) {
return null
}
@@ -264,15 +289,31 @@ function cookiesHaveLiveSession(cookies) {
return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name)))
}
/**
* True if the cookie jar holds a live Nous PORTAL (Privy) session — a non-empty
* `privy-token` (access-token) cookie, or a variant. This is the portal
* analogue of `cookiesHaveLiveSession`: the portal authenticates via Privy, not
* the Hermes gateway session cookies, so cloud sign-in / discovery liveness
* must check THIS, not the gateway helpers. (NAS `auth()` and the `/api/agents`
* cookie path both key off `privy-token`.)
*/
function cookiesHavePrivySession(cookies) {
if (!Array.isArray(cookies)) return false
return cookies.some(c => c && c.value && PRIVY_SESSION_COOKIE_VARIANTS.includes(c.name))
}
module.exports = {
AT_COOKIE_VARIANTS,
RT_COOKIE_VARIANTS,
PRIVY_SESSION_COOKIE_VARIANTS,
authModeFromStatus,
buildGatewayWsUrl,
buildGatewayWsUrlWithTicket,
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
cookiesHavePrivySession,
modeIsRemoteLike,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
@@ -22,6 +22,8 @@ const {
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
cookiesHavePrivySession,
modeIsRemoteLike,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
@@ -47,6 +49,19 @@ test('normAuthMode coerces to token unless explicitly oauth', () => {
assert.equal(normAuthMode('weird'), 'token')
})
// --- modeIsRemoteLike ---
test('modeIsRemoteLike is true for remote and cloud, false otherwise', () => {
// cloud resolves to a remote backend under the hood (Q6), so every resolution
// site treats it like remote.
assert.equal(modeIsRemoteLike('remote'), true)
assert.equal(modeIsRemoteLike('cloud'), true)
assert.equal(modeIsRemoteLike('local'), false)
assert.equal(modeIsRemoteLike(undefined), false)
assert.equal(modeIsRemoteLike(null), false)
assert.equal(modeIsRemoteLike('weird'), false)
})
// --- profileRemoteOverride ---
test('profileRemoteOverride returns null when no profile is given', () => {
@@ -85,6 +100,21 @@ test('profileRemoteOverride preserves an explicit oauth auth mode', () => {
assert.equal(profileRemoteOverride(config, 'coder').authMode, 'oauth')
})
test('profileRemoteOverride treats a cloud entry as a remote override', () => {
// A 'cloud' per-profile entry resolves to the same remote backend a 'remote'
// entry would (Q6) — the override must be returned, not dropped.
const config = {
profiles: {
coder: { mode: 'cloud', url: 'https://agent-1.agents.nousresearch.com', authMode: 'oauth' }
}
}
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
url: 'https://agent-1.agents.nousresearch.com',
authMode: 'oauth',
token: undefined
})
})
test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
assert.equal(profileRemoteOverride({}, 'coder'), null)
assert.equal(profileRemoteOverride({ profiles: null }, 'coder'), null)
@@ -331,6 +361,35 @@ test('cookiesHaveLiveSession is false for unrelated cookies and non-arrays', ()
assert.equal(cookiesHaveLiveSession([]), false)
})
// --- cookiesHavePrivySession (Nous portal / Privy auth, NOT gateway cookies) ---
test('cookiesHavePrivySession detects the privy-token access cookie', () => {
assert.equal(cookiesHavePrivySession([{ name: 'privy-token', value: 'jwt' }]), true)
})
test('cookiesHavePrivySession detects __Host-/__Secure- prefixes and the legacy privy-session name', () => {
assert.equal(cookiesHavePrivySession([{ name: '__Host-privy-token', value: 'x' }]), true)
assert.equal(cookiesHavePrivySession([{ name: '__Secure-privy-token', value: 'x' }]), true)
assert.equal(cookiesHavePrivySession([{ name: 'privy-session', value: 'x' }]), true)
})
test('cookiesHavePrivySession is false for an empty value', () => {
assert.equal(cookiesHavePrivySession([{ name: 'privy-token', value: '' }]), false)
})
test('cookiesHavePrivySession does NOT treat hermes gateway cookies as a portal session', () => {
// The whole point of Q7: a gateway session cookie is NOT a portal sign-in.
assert.equal(cookiesHavePrivySession([{ name: 'hermes_session_at', value: 'x' }]), false)
assert.equal(cookiesHavePrivySession([{ name: '__Host-hermes_session_rt', value: 'x' }]), false)
})
test('cookiesHavePrivySession is false for unrelated cookies and non-arrays', () => {
assert.equal(cookiesHavePrivySession([{ name: 'other', value: 'x' }]), false)
assert.equal(cookiesHavePrivySession(null), false)
assert.equal(cookiesHavePrivySession(undefined), false)
assert.equal(cookiesHavePrivySession([]), false)
})
// --- tokenPreview ---
test('tokenPreview returns null for empty', () => {
-96
View File
@@ -1,96 +0,0 @@
'use strict'
const { scanGitRepos } = require('./git-repo-scan.cjs')
const {
fileDiffVsHead,
repoStatus,
reviewCommit,
reviewCommitContext,
reviewCreatePr,
reviewDiff,
reviewList,
reviewPush,
reviewRevParse,
reviewRevert,
reviewShipInfo,
reviewStage,
reviewUnstage
} = require('./git-review-ops.cjs')
const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs')
// Register the git/worktree/review IPC handlers. Thin delegators to the
// git-*-ops sibling modules; the git/gh binary resolution lives in the main
// process (Windows PATH discovery) and is injected so this module stays pure.
function registerGitIpc({ ipcMain, resolveGitBinary, resolveGhBinary }) {
// Git-driven worktree management ("Start work" flow). Errors surface to the
// renderer as rejected promises so it can toast a friendly message.
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
addWorktree(repoPath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
switchBranch(repoPath, branch, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
// Compact repo status (branch, ahead/behind, change counts + files) for the
// composer coding rail. Returns null on a non-repo / remote backend so the rail
// hides cleanly rather than erroring.
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
// Codex-style review pane: list changed files for a scope, fetch one file's
// unified diff, and stage / unstage / revert. Reads return empty on failure;
// mutations reject so the renderer can toast.
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
reviewList(repoPath, scope, baseRef, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
)
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
reviewRevParse(repoPath, ref, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
reviewCommitContext(repoPath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
)
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
// no native addon). Never throws to the renderer — failures yield an empty list.
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
try {
return await scanGitRepos(roots || [], options || {})
} catch {
return []
}
})
}
module.exports = { registerGitIpc }
-61
View File
@@ -1,61 +0,0 @@
'use strict'
const assert = require('node:assert/strict')
const test = require('node:test')
const { registerGitIpc } = require('./git-ipc.cjs')
function fakeIpcMain() {
const handlers = new Map()
return {
handlers,
handle(channel, handler) {
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
handlers.set(channel, handler)
}
}
}
test('registerGitIpc wires only hermes:git:* channels, each to a handler fn', () => {
const ipcMain = fakeIpcMain()
registerGitIpc({ ipcMain, resolveGitBinary: () => 'git', resolveGhBinary: () => 'gh' })
assert.ok(ipcMain.handlers.size >= 19, `expected the full git surface, got ${ipcMain.handlers.size}`)
for (const [channel, handler] of ipcMain.handlers) {
assert.match(channel, /^hermes:git:/, `${channel} is not a git channel`)
assert.equal(typeof handler, 'function', `${channel} should register a handler`)
}
// Spot-check the load-bearing channels across the worktree / review / scan groups.
for (const channel of ['hermes:git:worktreeList', 'hermes:git:review:commit', 'hermes:git:scanRepos']) {
assert.ok(ipcMain.handlers.has(channel), `missing ${channel}`)
}
})
test('handlers thread the injected resolver into the ops layer', async () => {
const ipcMain = fakeIpcMain()
const calls = []
registerGitIpc({
ipcMain,
resolveGitBinary: () => {
calls.push('git')
return 'git'
},
resolveGhBinary: () => 'gh'
})
// The resolver is consulted synchronously to build the ops call; whatever the
// ops layer does with a non-repo path is irrelevant to the wiring.
try {
await ipcMain.handlers.get('hermes:git:worktreeList')({}, '/definitely/not/a/repo')
} catch {
// ops layer may reject on a bad path — not what this test asserts.
}
assert.deepEqual(calls, ['git'])
})
+492 -31
View File
@@ -58,7 +58,23 @@ const {
buildRelaunchScript
} = require('./update-relaunch.cjs')
const { gitRootForIpc } = require('./git-root.cjs')
const { registerGitIpc } = require('./git-ipc.cjs')
const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs')
const {
fileDiffVsHead,
repoStatus,
reviewCommit,
reviewCommitContext,
reviewCreatePr,
reviewDiff,
reviewList,
reviewPush,
reviewRevParse,
reviewRevert,
reviewShipInfo,
reviewStage,
reviewUnstage
} = require('./git-review-ops.cjs')
const { scanGitRepos } = require('./git-repo-scan.cjs')
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
@@ -86,6 +102,8 @@ const {
connectionScopeKey,
cookiesHaveSession,
cookiesHaveLiveSession,
cookiesHavePrivySession,
modeIsRemoteLike,
normAuthMode,
normalizeRemoteBaseUrl,
pathWithGlobalRemoteProfile,
@@ -1345,7 +1363,10 @@ function backendSupportsServe(backend) {
let supported = null
if (backend.root) {
try {
const src = fs.readFileSync(path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), 'utf8')
const src = fs.readFileSync(
path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'),
'utf8'
)
supported = sourceDeclaresServe(src)
} catch {
supported = null // source unreadable — fall through to the probe
@@ -2273,7 +2294,9 @@ async function handOffWindowsBootstrapRecovery(reason) {
// --repair (full venv recreate) and drove reinstall loops. The venv interpreter
// and the bootstrap-complete marker are present earlier and are better signals.
const haveRealInstall =
fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
fileExists(venvPython) ||
fileExists(venvHermes) ||
fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
await releaseBackendLockForUpdate(updateRoot)
@@ -4377,7 +4400,23 @@ async function clearOauthSession(baseUrl) {
// reject if the user closes the window first. The window navigates through the
// IDP and back to /auth/callback, which sets the session cookies on the
// partition; we poll the cookie jar rather than try to read the HttpOnly value.
function openOauthLoginWindow(baseUrl) {
// Open a gateway login window in the OAuth session partition, resolving once
// the access-token cookie appears (login done) or rejecting if the user closes
// the window first.
//
// `silent` selects the URL the window loads, which decides interactive-vs-silent:
// - silent=false (default): load ``/login`` — the public interstitial that
// renders the "Log in with X" provider chooser. This is the interactive
// remote-gateway login the settings UI drives.
// - silent=true: load the PROTECTED root ``/`` instead. ``/login`` is a public
// route, so loading it NEVER triggers the gate's auto-SSO and always shows
// the chooser. Loading a protected page with no session cookie makes the
// gate run ``_auto_sso_response``: single registered provider + a live
// portal session in this partition → a silent 302 through
// ``/auth/login`` → portal ``/oauth/authorize`` (auto-approves org members)
// → ``/auth/callback``, which sets the gateway cookie with NO interactive
// prompt. This is the per-agent cloud cascade (decisions.md Q5).
function openOauthLoginWindow(baseUrl, { silent = false } = {}) {
return new Promise((resolve, reject) => {
if (!app.isReady()) {
reject(new Error('Desktop is not ready to start an OAuth login.'))
@@ -4392,11 +4431,13 @@ function openOauthLoginWindow(baseUrl) {
let settled = false
let win = null
let pollTimer = null
let revealTimer = null
const finish = err => {
if (settled) return
settled = true
if (pollTimer) clearInterval(pollTimer)
if (revealTimer) clearTimeout(revealTimer)
try {
if (win && !win.isDestroyed()) win.destroy()
} catch {
@@ -4415,8 +4456,14 @@ function openOauthLoginWindow(baseUrl) {
win = new BrowserWindow({
width: 520,
height: 720,
title: 'Sign in to Hermes gateway',
title: silent ? 'Connecting to Hermes Cloud agent…' : 'Sign in to Hermes gateway',
autoHideMenuBar: true,
// Silent cascade: start HIDDEN. The auto-SSO 302 chain completes in
// well under a second, so the window normally never needs to show. We
// only reveal it as a fallback if the cascade DOESN'T complete quickly
// (e.g. the portal session lapsed and the gate fell through to the
// interactive chooser) — see the reveal timer below.
show: !silent,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
@@ -4438,6 +4485,23 @@ function openOauthLoginWindow(baseUrl) {
win.webContents.on('did-frame-navigate', () => void checkCookie())
pollTimer = setInterval(() => void checkCookie(), 750)
// Silent-mode reveal fallback: if the cascade hasn't settled shortly, the
// auto-SSO didn't go through silently (no portal session, multi-provider,
// loop-guard tripped, etc.) and the window is now showing an interactive
// page. Reveal it so the user can complete sign-in manually rather than
// staring at nothing. Cleared on finish().
if (silent && win) {
revealTimer = setTimeout(() => {
try {
if (!settled && win && !win.isDestroyed() && !win.isVisible()) {
win.show()
}
} catch {
// window torn down
}
}, 2500)
}
win.on('closed', () => {
if (!settled) finish(new Error('Login window closed before authentication completed.'))
})
@@ -4445,7 +4509,11 @@ function openOauthLoginWindow(baseUrl) {
// ``next`` is intentionally omitted: the gateway lands on ``/`` after
// login, which is a valid authenticated page that sets the cookies. We
// only care that the cookie jar is populated.
const loginUrl = `${normalizeRemoteBaseUrl(baseUrl)}/login`
//
// silent=true loads the protected root so the gate auto-SSOs (no chooser);
// silent=false loads the public ``/login`` chooser for interactive sign-in.
const normalizedBase = normalizeRemoteBaseUrl(baseUrl)
const loginUrl = silent ? `${normalizedBase}/` : `${normalizedBase}/login`
win.loadURL(loginUrl).catch(error => {
finish(error instanceof Error ? error : new Error(String(error)))
})
@@ -4574,6 +4642,258 @@ async function freshGatewayWsUrl(profile) {
return connection.wsUrl
}
// --- Hermes Cloud discovery + silent per-agent sign-in (cloud-auto-discovery
// Phase 3) ---------------------------------------------------------------
//
// The "cloud" connection mode lets a user sign in to the Nous portal ONCE in
// the OAuth session partition, then (a) discover their hosted agents and (b)
// connect to any of them with no second interactive sign-in. Both ride the one
// portal session cookie living in `persist:hermes-remote-oauth`:
// - discovery → GET {portal}/api/agents over the partition-bound net; the
// portal session cookie authenticates it (NAS Phase 2.5 accepts the cookie).
// - cascade → opening an agent's own /login in the same partition hits the
// portal's silent auto-approve (org member, existing session) and 302s back
// with that agent's session cookie — no prompt. Each agent still completes
// its own PKCE exchange; SSO removes the human click, not a security check.
// Canonical Nous portal base URL, overridable for staging/dev. Mirrors the CLI
// convention (hermes_cli/auth.py DEFAULT_NOUS_PORTAL_URL + the same env names)
// so a single override flips every Hermes surface to the same portal.
const DEFAULT_NOUS_PORTAL_URL = 'https://portal.nousresearch.com'
function resolvePortalBaseUrl() {
const raw =
process.env.HERMES_PORTAL_BASE_URL || process.env.NOUS_PORTAL_BASE_URL || DEFAULT_NOUS_PORTAL_URL
return String(raw).trim().replace(/\/+$/, '')
}
// Whether the OAuth partition currently holds a live Nous portal session — the
// credential that powers both discovery and the silent cascade. The portal
// authenticates via PRIVY, not the Hermes gateway session cookies, so this
// checks for the `privy-token` cookie on the portal host (NOT
// hasLiveOauthSession, which looks for hermes_session_at/rt that the portal
// never sets). See connection-config.cjs cookiesHavePrivySession.
async function hasLivePortalSession() {
const sess = getOauthSession()
if (!sess) return false
const portalBaseUrl = resolvePortalBaseUrl()
const parsed = new URL(portalBaseUrl)
try {
const cookies = await sess.cookies.get({ url: portalBaseUrl })
return cookiesHavePrivySession(cookies)
} catch {
try {
const cookies = await sess.cookies.get({ domain: parsed.hostname })
return cookiesHavePrivySession(cookies)
} catch {
return false
}
}
}
// Drive a one-time interactive portal sign-in in the OAuth partition. Unlike
// openOauthLoginWindow (which targets a gateway's /login), this lands on the
// portal itself so the resulting session cookie is portal-scoped — the cookie
// that authenticates discovery AND is reused for every silent per-agent
// cascade. Resolves once the portal session cookie appears.
function openPortalLoginWindow() {
const portalBaseUrl = resolvePortalBaseUrl()
return new Promise((resolve, reject) => {
if (!app.isReady()) {
reject(new Error('Desktop is not ready to start a Hermes Cloud sign-in.'))
return
}
const sess = getOauthSession()
if (!sess) {
reject(new Error('OAuth session partition is unavailable.'))
return
}
let settled = false
let win = null
let pollTimer = null
const finish = err => {
if (settled) return
settled = true
if (pollTimer) clearInterval(pollTimer)
try {
if (win && !win.isDestroyed()) win.destroy()
} catch {
// window already torn down
}
if (err) reject(err)
else resolve({ portalBaseUrl, ok: true })
}
const checkCookie = async () => {
if (settled) return
// A live portal (Privy) session cookie means sign-in completed.
if (await hasLivePortalSession()) finish(null)
}
try {
win = new BrowserWindow({
width: 520,
height: 720,
title: 'Sign in to Hermes Cloud',
autoHideMenuBar: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
session: sess,
webSecurity: true
}
})
} catch (error) {
finish(error instanceof Error ? error : new Error(String(error)))
return
}
win.webContents.on('did-navigate', () => void checkCookie())
win.webContents.on('did-redirect-navigation', () => void checkCookie())
win.webContents.on('did-frame-navigate', () => void checkCookie())
pollTimer = setInterval(() => void checkCookie(), 750)
win.on('closed', () => {
if (!settled) finish(new Error('Sign-in window closed before authentication completed.'))
})
// Land on the portal root; any authenticated portal page sets the session
// cookie. We only care that the partition cookie jar is populated.
win.loadURL(portalBaseUrl).catch(error => {
finish(error instanceof Error ? error : new Error(String(error)))
})
})
}
// Discover the hosted (Hermes Cloud) agents the signed-in user can see. Calls
// the NAS trimmed-summary endpoint over the partition-bound net, so the portal
// session cookie is attached automatically (no bearer needed — NAS accepts the
// cookie). Returns { agents } on success, or { needsOrgSelection: true, orgs }
// when the user belongs to multiple orgs and hasn't picked one yet (NAS 409
// org_selection_required). Pass `org` (a slug/id from a prior org list) to
// scope discovery to that org. Throws a needsCloudLogin-tagged error when no
// portal session is present.
async function discoverCloudAgents(org) {
const portalBaseUrl = resolvePortalBaseUrl()
if (!(await hasLivePortalSession())) {
const err = new Error(
'You are not signed in to Hermes Cloud. Open Settings → Gateway, choose Hermes Cloud, and sign in.'
)
err.needsCloudLogin = true
throw err
}
const orgQuery = org ? `?org=${encodeURIComponent(org)}` : ''
let body
try {
body = await fetchJsonViaOauthSession(`${portalBaseUrl}/api/agents${orgQuery}`, {
method: 'GET',
timeoutMs: 15_000
})
} catch (error) {
// A 401 means the portal session lapsed between the liveness check and the
// call — surface it as a re-login, not a generic failure.
if (error && error.statusCode === 401) {
const err = new Error('Your Hermes Cloud session has expired. Open Settings → Gateway and sign in again.')
err.needsCloudLogin = true
err.cause = error
throw err
}
// A 409 means we're a multi-org user who hasn't picked an org. The body
// carries the user's org list; surface it so the renderer shows a picker
// and re-calls discovery with the chosen org. (fetchJsonViaOauthSession
// throws on >=400 with err.statusCode + err.message "409: <json body>".)
if (error && error.statusCode === 409) {
const orgs = parseOrgSelectionError(error)
if (orgs) {
return { needsOrgSelection: true, orgs }
}
}
throw error
}
return { agents: trimCloudAgents(body), org: trimCloudOrg(body?.org) }
}
// Project a NAS response org ({ id, slug, name, isPersonal }) to the trimmed
// shape the renderer persists, or null when absent/malformed.
function trimCloudOrg(org) {
if (!org || typeof org !== 'object' || typeof org.id !== 'string') return null
return {
id: org.id,
slug: typeof org.slug === 'string' ? org.slug : null,
name: typeof org.name === 'string' ? org.name : org.id,
isPersonal: Boolean(org.isPersonal),
role: typeof org.role === 'string' ? org.role : 'MEMBER'
}
}
// Extract the org list from a 409 org_selection_required error body. The error
// message is "409: <raw json>" (see fetchJsonViaOauthSession); parse defensively
// and return null if it isn't the shape we expect (caller then rethrows).
function parseOrgSelectionError(error) {
const msg = String(error?.message || '')
const jsonStart = msg.indexOf('{')
if (jsonStart < 0) return null
let parsed
try {
parsed = JSON.parse(msg.slice(jsonStart))
} catch {
return null
}
if (parsed?.error !== 'org_selection_required' || !Array.isArray(parsed.orgs)) return null
return parsed.orgs
.filter(o => o && typeof o === 'object' && typeof o.id === 'string')
.map(o => ({
id: o.id,
slug: typeof o.slug === 'string' ? o.slug : null,
name: typeof o.name === 'string' ? o.name : o.id,
isPersonal: Boolean(o.isPersonal),
role: typeof o.role === 'string' ? o.role : 'MEMBER'
}))
}
// Project NAS's agent rows to the trimmed DTO the renderer consumes.
function trimCloudAgents(body) {
const agents = Array.isArray(body?.agents) ? body.agents : []
return agents
.filter(a => a && typeof a === 'object' && typeof a.id === 'string')
.map(a => ({
id: a.id,
name: typeof a.name === 'string' ? a.name : a.id,
status: typeof a.status === 'string' ? a.status : 'unknown',
dashboardUrl: typeof a.dashboardUrl === 'string' ? a.dashboardUrl : null,
dashboardGatewayState:
typeof a.dashboardGatewayState === 'string' ? a.dashboardGatewayState : 'unknown'
}))
}
// Silent per-agent sign-in: open the selected agent dashboard's /login in the
// SAME OAuth partition. Because the user already holds a live portal session
// there, the agent's /oauth/authorize auto-approves (org member) and 302s back,
// setting that agent's gateway session cookie WITHOUT a second interactive
// prompt. Reuses openOauthLoginWindow — the window self-closes the instant the
// agent's session cookie lands (a silent flow finishes in well under a second;
// if the portal session were absent it would fall through to an interactive
// login, which the discovery gate already prevents). Returns once the agent's
// gateway session cookie is present.
async function cloudAgentSilentSignIn(dashboardUrl) {
const baseUrl = normalizeRemoteBaseUrl(dashboardUrl)
// Pre-req: a live portal session must exist, or this would surface an
// interactive prompt rather than a silent cascade. Discovery already gates on
// this, but a selection can arrive after the session lapsed.
if (!(await hasLivePortalSession())) {
const err = new Error('Your Hermes Cloud session has expired. Sign in to Hermes Cloud again.')
err.needsCloudLogin = true
throw err
}
await openOauthLoginWindow(baseUrl, { silent: true })
return { baseUrl, connected: await hasOauthSessionCookie(baseUrl) }
}
function encryptDesktopSecret(value) {
return encryptDesktopSecretStrict(value, safeStorage)
}
@@ -4617,7 +4937,7 @@ function sanitizeConnectionProfiles(raw) {
continue
}
const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' }
const cleaned = { mode: modeIsRemoteLike(entry.mode) ? entry.mode : 'local' }
const url = String(entry.url || '').trim()
if (url) {
cleaned.url = url
@@ -4626,6 +4946,14 @@ function sanitizeConnectionProfiles(raw) {
if (entry.token && typeof entry.token === 'object') {
cleaned.token = entry.token
}
// Preserve the Hermes Cloud org tag on cloud-mode entries so Settings can
// reopen into the same org for a per-profile cloud connection.
if (cleaned.mode === 'cloud') {
const org = String(entry.org || '').trim()
if (org) {
cleaned.org = org
}
}
out[name] = cleaned
}
@@ -4660,7 +4988,7 @@ function readDesktopConnectionConfig() {
// backward compatibility with configs written before OAuth support.
remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
config = {
mode: parsed.mode === 'remote' ? 'remote' : 'local',
mode: modeIsRemoteLike(parsed.mode) ? parsed.mode : 'local',
remote,
// Per-profile remote overrides: each profile may point at its own
// backend (local spawn or its own remote URL). Preserved verbatim so
@@ -4731,7 +5059,11 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
const remoteToken = decryptDesktopSecret(block.token)
const authMode = normAuthMode(block.authMode)
const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '')
const mode = envOverride || (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
// The env override forces a plain remote connection. Otherwise reflect the
// saved mode, preserving 'cloud' (a Hermes Cloud connection — Q6) so the UI
// reopens into the cloud picker; any non-remote-like value collapses to local.
const savedMode = key ? scoped?.mode : config.mode
const mode = envOverride ? 'remote' : modeIsRemoteLike(savedMode) ? savedMode : 'local'
let remoteOauthConnected = false
if (authMode === 'oauth' && remoteUrl) {
@@ -4753,6 +5085,9 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
remoteAuthMode: authMode,
remoteOauthConnected,
remoteUrl,
// The persisted Hermes Cloud org (slug/id) for a cloud connection, or '' for
// remote/local. Lets Settings → Gateway reopen into the same org.
cloudOrg: mode === 'cloud' ? String(block.org || '') : '',
remoteTokenPreview: tokenPreview(remoteToken),
remoteTokenSet: Boolean(remoteToken),
// The env override only forces the global/primary connection; a per-profile
@@ -4764,23 +5099,49 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
// Build + validate a `{ url, authMode, token }` remote block. OAuth gateways
// authenticate via the login-window session cookie (verified at connect time in
// resolveRemoteBackend), so only token-auth remotes require a saved token.
function buildRemoteBlock(remoteUrl, authMode, token) {
// `org` (optional) is the Hermes Cloud org slug/id the instance was discovered
// under — persisted so Settings can reopen into the same org; omitted from the
// block when empty so plain remote connections stay unchanged.
function buildRemoteBlock(remoteUrl, authMode, token, org) {
if (authMode !== 'oauth' && !decryptDesktopSecret(token)) {
throw new Error('Remote gateway session token is required.')
}
return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token }
const block = { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token }
const orgValue = typeof org === 'string' ? org.trim() : ''
if (orgValue) {
block.org = orgValue
}
return block
}
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
const persistToken = options.persistToken !== false
const key = connectionScopeKey(input.profile)
const mode = input.mode === 'remote' ? 'remote' : 'local'
// 'cloud' and 'remote' both persist a remote-shaped block; 'cloud' is
// remembered as its own provenance (Q6) and resolves to remote downstream.
// Anything else collapses to local.
const mode = modeIsRemoteLike(input.mode) ? input.mode : 'local'
const remoteLike = modeIsRemoteLike(mode)
// The block being edited: a per-profile entry or the global remote block.
const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {}
const rawExistingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {}
// Leaving a CLOUD connection unselects it: a cloud block's url/org/token
// describe a discovered Hermes Cloud instance, NOT a user-owned remote gateway,
// so switching to local or remote must NOT inherit them (otherwise the stale
// cloud URL lingers and re-selecting Cloud looks "already connected"). When the
// saved block was cloud and the new mode is not cloud, start from an empty
// block. (remote↔local toggles still preserve a real remote URL as before.)
const existingMode = key ? existing.profiles?.[key]?.mode : existing.mode
const leavingCloud = existingMode === 'cloud' && mode !== 'cloud'
const existingBlock = leavingCloud ? {} : rawExistingBlock
const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim()
// authMode: explicit input wins; otherwise inherit the saved value, default 'token'.
const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode)
// Cloud org: only meaningful for 'cloud' mode. Explicit input wins; otherwise
// inherit the saved org. A plain 'remote' connection never carries an org
// (switching cloud→remote drops it), so it stays unset unless mode is cloud.
const cloudOrg =
mode === 'cloud' ? String(input.cloudOrg ?? existingBlock.org ?? '').trim() : ''
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
const nextToken = incomingToken
? persistToken
@@ -4789,21 +5150,25 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
: existingBlock.token
if (key) {
// Per-profile scope: a remote entry pins this profile to its own backend; a
// local entry clears the override so the profile inherits the default.
// Per-profile scope: a remote/cloud entry pins this profile to its own
// backend; a local entry clears the override so the profile inherits the
// default. The mode tag (remote vs cloud) is preserved on the entry.
const profiles = { ...(existing.profiles || {}) }
if (mode === 'remote') {
profiles[key] = { mode: 'remote', ...buildRemoteBlock(remoteUrl, authMode, nextToken) }
if (remoteLike) {
profiles[key] = { mode, ...buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) }
} else {
delete profiles[key]
}
return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles }
return {
mode: modeIsRemoteLike(existing.mode) ? existing.mode : 'local',
remote: existing.remote || {},
profiles
}
}
const nextRemote =
mode === 'remote'
? buildRemoteBlock(remoteUrl, authMode, nextToken)
: { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }
const nextRemote = remoteLike
? buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg)
: { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }
// Preserve per-profile overrides when saving the global connection.
return { mode, remote: nextRemote, profiles: existing.profiles || {} }
@@ -4907,8 +5272,8 @@ async function resolveRemoteBackend(profile) {
return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env')
}
// 3. Global remote.
if (config.mode !== 'remote') {
// 3. Global remote (or cloud — cloud resolves to a remote backend, Q6).
if (!modeIsRemoteLike(config.mode)) {
return null
}
const authMode = normAuthMode(config.remote?.authMode)
@@ -4930,13 +5295,14 @@ function configuredRemoteProfileNames() {
}
// True when the app is in app-global remote mode (Settings → "All profiles" →
// Remote, or the env override): a SINGLE remote backend serves every profile via
// ?profile=. Distinct from per-profile overrides — here there's one host for all.
// Remote/Cloud, or the env override): a SINGLE remote backend serves every
// profile via ?profile=. Cloud counts — it resolves to a remote backend (Q6).
// Distinct from per-profile overrides — here there's one host for all.
function globalRemoteActive() {
if (process.env.HERMES_DESKTOP_REMOTE_URL) {
return true
}
return readDesktopConnectionConfig().mode === 'remote'
return modeIsRemoteLike(readDesktopConnectionConfig().mode)
}
// GET a profile's resolved backend (remote pool or local primary), parsed JSON.
@@ -5024,7 +5390,9 @@ async function testDesktopConnectionConfig(input = {}) {
// already normalized the URL and resolved token inheritance for the scope.
const block = key ? config.profiles?.[key] || null : config.remote
const wantRemote =
block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block)
modeIsRemoteLike(block?.mode) ||
(!key && modeIsRemoteLike(config.mode)) ||
(modeIsRemoteLike(input.mode) && block)
// ``/api/status`` is public on every gateway (no creds needed), so a
// reachability test works for local, token, and oauth modes alike — we only
// need a base URL. For a remote config we normalize the URL from the input;
@@ -6252,6 +6620,33 @@ ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) =
// as still-connected rather than silently signed-out.
return { ok: true, connected: baseUrl ? await hasLiveOauthSession(baseUrl) : false }
})
// --- Hermes Cloud (cloud-auto-discovery Phase 3) ---
// One portal login in the OAuth partition powers both discovery and the silent
// per-agent cascade. See the discovery/cascade helpers above.
ipcMain.handle('hermes:cloud:status', async () => ({
portalBaseUrl: resolvePortalBaseUrl(),
signedIn: await hasLivePortalSession()
}))
ipcMain.handle('hermes:cloud:login', async () => {
await openPortalLoginWindow()
return { ok: true, signedIn: await hasLivePortalSession() }
})
ipcMain.handle('hermes:cloud:logout', async () => {
await clearOauthSession(resolvePortalBaseUrl())
return { ok: true, signedIn: await hasLivePortalSession() }
})
ipcMain.handle('hermes:cloud:discover', async (_event, org) => {
// Returns { agents } or { needsOrgSelection: true, orgs }. `org` (optional)
// scopes discovery to a chosen org for multi-org users.
return discoverCloudAgents(typeof org === 'string' && org ? org : undefined)
})
ipcMain.handle('hermes:cloud:agent-sign-in', async (_event, dashboardUrl) => {
// Silent per-agent sign-in via the shared portal session. Returns the agent's
// gateway baseUrl + whether its session cookie landed; the renderer then
// saves a cloud-mode connection pointed at this dashboardUrl.
return cloudAgentSilentSignIn(dashboardUrl)
})
ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {
const config = coerceDesktopConnectionConfig(payload)
writeDesktopConnectionConfig(config)
@@ -6987,9 +7382,75 @@ ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
return true
})
// Git/worktree/review IPC lives in git-ipc.cjs; the git + gh binary resolvers
// stay here (Windows PATH discovery) and are injected into the registrar.
registerGitIpc({ ipcMain, resolveGitBinary, resolveGhBinary })
// Git-driven worktree management ("Start work" flow). Errors surface to the
// renderer as rejected promises so it can toast a friendly message.
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
addWorktree(repoPath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
switchBranch(repoPath, branch, resolveGitBinary())
)
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
// Compact repo status (branch, ahead/behind, change counts + files) for the
// composer coding rail. Returns null on a non-repo / remote backend so the rail
// hides cleanly rather than erroring.
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
// Codex-style review pane: list changed files for a scope, fetch one file's
// unified diff, and stage / unstage / revert. Reads return empty on failure;
// mutations reject so the renderer can toast.
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
reviewList(repoPath, scope, baseRef, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
)
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
reviewRevParse(repoPath, ref, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
)
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
reviewCommitContext(repoPath, resolveGitBinary())
)
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
)
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
// no native addon). Never throws to the renderer — failures yield an empty list.
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
try {
return await scanGitRepos(roots || [], options || {})
} catch {
return []
}
})
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
if (!nodePty) {
+9
View File
@@ -41,6 +41,15 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
// Hermes Cloud: one portal login powers discovery + silent per-agent sign-in
// (cloud-auto-discovery Phase 3).
cloud: {
status: () => ipcRenderer.invoke('hermes:cloud:status'),
login: () => ipcRenderer.invoke('hermes:cloud:login'),
logout: () => ipcRenderer.invoke('hermes:cloud:logout'),
discover: org => ipcRenderer.invoke('hermes:cloud:discover', org),
agentSignIn: dashboardUrl => ipcRenderer.invoke('hermes:cloud:agent-sign-in', dashboardUrl)
},
profile: {
get: () => ipcRenderer.invoke('hermes:profile:get'),
set: name => ipcRenderer.invoke('hermes:profile:set', name)
+1 -1
View File
@@ -37,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-ipc.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/windows-hermes-resolution.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/windows-hermes-resolution.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -8,7 +8,6 @@
* user can grab several.
*/
import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
@@ -19,7 +18,6 @@ import { triggerHaptic } from '@/lib/haptics'
import { Check, Download, Loader2, Palette } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { installVscodeThemeFromMarketplace } from '@/themes/install'
import { $marketplaceInstalls } from '@/themes/user-themes'
const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })
@@ -45,8 +43,8 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
const { t } = useI18n()
const copy = t.commandCenter.installTheme
const debouncedSearch = useDebounced(search.trim(), 300)
const installs = useStore($marketplaceInstalls)
const [installingId, setInstallingId] = useState<string | null>(null)
const [installed, setInstalled] = useState<Record<string, true>>({})
const [installError, setInstallError] = useState<string | null>(null)
const query = useQuery({
@@ -55,20 +53,6 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
staleTime: 5 * 60 * 1000
})
// Already installed → just re-activate it; never re-download what we have.
const select = (item: DesktopMarketplaceSearchItem) => {
const owned = installs.get(item.extensionId)
if (owned) {
triggerHaptic('crisp')
onPickTheme(owned.name)
return
}
void install(item)
}
const install = async (item: DesktopMarketplaceSearchItem) => {
if (installingId) {
return
@@ -81,6 +65,7 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
triggerHaptic('crisp')
setInstalled(prev => ({ ...prev, [item.extensionId]: true }))
onPickTheme(theme.name)
} catch (error) {
setInstallError(error instanceof Error ? error.message : copy.error)
@@ -108,7 +93,7 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
{installError && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{installError}</p>}
{results.map(item => {
const busy = installingId === item.extensionId
const done = installs.has(item.extensionId)
const done = installed[item.extensionId]
return (
<button
@@ -119,7 +104,7 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
)}
disabled={Boolean(installingId) && !busy}
key={item.extensionId}
onClick={() => select(item)}
onClick={() => void install(item)}
onMouseDown={event => event.preventDefault()}
role="option"
type="button"
@@ -17,8 +17,7 @@ import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { $translucency, setTranslucency } from '@/store/translucency'
import { getBaseColors, useTheme } from '@/themes/context'
import { installVscodeThemeFromMarketplace } from '@/themes/install'
import type { DesktopTheme } from '@/themes/types'
import { $marketplaceInstalls, isUserTheme, removeUserTheme } from '@/themes/user-themes'
import { isUserTheme, removeUserTheme } from '@/themes/user-themes'
import { MODE_OPTIONS } from './constants'
import { PetSettings } from './pet-settings'
@@ -83,17 +82,18 @@ const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', ma
*/
function MarketplaceThemeResults({
query,
installs,
installedExtIds,
onInstalled
}: {
query: string
installs: ReadonlyMap<string, DesktopTheme>
installedExtIds: Set<string>
onInstalled: (name: string) => void
}) {
const { t } = useI18n()
const copy = t.commandCenter.installTheme
const debounced = useDebounced(query.trim(), 300)
const [installingId, setInstallingId] = useState<string | null>(null)
const [installedHere, setInstalledHere] = useState<Record<string, true>>({})
const [error, setError] = useState<string | null>(null)
const search = useQuery({
@@ -103,20 +103,6 @@ function MarketplaceThemeResults({
staleTime: 5 * 60 * 1000
})
// Already installed → just re-activate it; never re-download what we have.
const select = (item: DesktopMarketplaceSearchItem) => {
const owned = installs.get(item.extensionId)
if (owned) {
triggerHaptic('crisp')
onInstalled(owned.name)
return
}
void install(item)
}
const install = async (item: DesktopMarketplaceSearchItem) => {
if (installingId) {
return
@@ -129,6 +115,7 @@ function MarketplaceThemeResults({
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
triggerHaptic('crisp')
setInstalledHere(prev => ({ ...prev, [item.extensionId]: true }))
onInstalled(theme.name)
} catch (e) {
setError(e instanceof Error ? e.message : copy.error)
@@ -186,7 +173,7 @@ function MarketplaceThemeResults({
<div className="grid gap-2 sm:grid-cols-2">
{results.map(item => {
const busy = installingId === item.extensionId
const done = installs.has(item.extensionId)
const done = installedHere[item.extensionId] || installedExtIds.has(item.extensionId)
return (
<button
@@ -196,7 +183,7 @@ function MarketplaceThemeResults({
)}
disabled={Boolean(installingId) && !busy}
key={item.extensionId}
onClick={() => select(item)}
onClick={() => void install(item)}
type="button"
>
<Palette className="size-4 shrink-0 text-(--ui-text-tertiary)" />
@@ -233,7 +220,6 @@ export function AppearanceSettings() {
const embedMode = useStore($embedMode)
const embedAllowed = useStore($embedAllowed)
const translucency = useStore($translucency)
const installs = useStore($marketplaceInstalls)
const profiles = useStore($profiles)
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
const a = t.settings.appearance
@@ -256,6 +242,20 @@ export function AppearanceSettings() {
// Active theme first; stable sort keeps the rest in their original order.
.sort((a, b) => Number(b.name === themeName) - Number(a.name === themeName))
// Marketplace imports describe themselves as "VS Code · <publisher.extension>";
// pull those ids back out so search results already imported show as installed.
const MARKETPLACE_DESC_PREFIX = 'VS Code · '
const installedExtIds = new Set(
availableThemes
.map(theme =>
theme.description.startsWith(MARKETPLACE_DESC_PREFIX)
? theme.description.slice(MARKETPLACE_DESC_PREFIX.length)
: ''
)
.filter(Boolean)
)
// Themes save per profile. Surface that only when the user actually has more
// than one profile (single-profile installs never see the distinction).
const showProfileNote = profiles.length > 1
@@ -365,7 +365,11 @@ export function AppearanceSettings() {
})}
</div>
)}
<MarketplaceThemeResults installs={installs} onInstalled={name => setTheme(name)} query={query} />
<MarketplaceThemeResults
installedExtIds={installedExtIds}
onInstalled={name => setTheme(name)}
query={query}
/>
</div>
{showProfileNote && (
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
@@ -3,9 +3,15 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
import type {
DesktopAuthProvider,
DesktopCloudAgent,
DesktopCloudOrg,
DesktopConnectionProbeResult
} from '@/global'
import { useI18n } from '@/i18n'
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
import { ExternalLink } from '@/lib/external-link'
import { AlertCircle, Check, Cloud, FileText, Globe, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { $profiles, refreshActiveProfile } from '@/store/profile'
@@ -13,9 +19,11 @@ import { $profiles, refreshActiveProfile } from '@/store/profile'
import { CONTROL_TEXT } from './constants'
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
type Mode = 'local' | 'remote'
type Mode = 'local' | 'remote' | 'cloud'
type AuthMode = 'oauth' | 'token'
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
// Hermes Cloud discovery lifecycle for the cloud-mode panel.
type CloudDiscoverStatus = 'idle' | 'loading' | 'done' | 'error'
interface GatewaySettingsState {
envOverride: boolean
@@ -25,6 +33,7 @@ interface GatewaySettingsState {
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
cloudOrg: string
}
const EMPTY_STATE: GatewaySettingsState = {
@@ -34,7 +43,8 @@ const EMPTY_STATE: GatewaySettingsState = {
remoteOauthConnected: false,
remoteTokenPreview: null,
remoteTokenSet: false,
remoteUrl: ''
remoteUrl: '',
cloudOrg: ''
}
function ModeCard({
@@ -105,6 +115,32 @@ export function GatewaySettings() {
const [remoteToken, setRemoteToken] = useState('')
const [lastTest, setLastTest] = useState<null | string>(null)
// --- Hermes Cloud (cloud mode) state ---
// One portal session powers discovery + the silent per-agent cascade. These
// track the cloud panel: whether we're signed in, the discovered agent list,
// and which agent is mid-connect.
const [cloudSignedIn, setCloudSignedIn] = useState(false)
const [cloudSigningIn, setCloudSigningIn] = useState(false)
const [cloudAgents, setCloudAgents] = useState<DesktopCloudAgent[]>([])
const [cloudDiscover, setCloudDiscover] = useState<CloudDiscoverStatus>('idle')
const [cloudConnectingId, setCloudConnectingId] = useState<null | string>(null)
// Multi-org users: when discovery returns needsOrgSelection, we hold the org
// list here and show a picker. `cloudOrg` is the chosen org slug/id (null =
// not yet chosen / single-org user).
const [cloudOrgs, setCloudOrgs] = useState<DesktopCloudOrg[]>([])
const [cloudOrg, setCloudOrgState] = useState<null | string>(null)
// Mirror the selected org into a ref so connect reads the CURRENT value, not a
// value captured in a stale render closure. discoverCloud() resolves the org
// asynchronously (from the NAS response) and a user can click Connect in the
// same render tick; without the ref, connectCloudAgent could persist a null
// org even though discovery just resolved one. Always set both together.
const cloudOrgRef = useRef<null | string>(null)
const setCloudOrg = (value: null | string) => {
cloudOrgRef.current = value
setCloudOrgState(value)
}
// Connection scope: null = the global/default connection (the original
// behavior); a profile name = that profile's per-profile remote override, so
// each profile can point at its own backend.
@@ -163,6 +199,21 @@ export function GatewaySettings() {
// OAuth login button or the session-token entry box. The effective auth mode
// prefers a fresh probe result over the saved value.
const trimmedUrl = state.remoteUrl.trim()
// The dashboardUrl of the currently-connected cloud instance (the saved
// cloud connection's remoteUrl), normalized for comparison against each
// discovered agent's dashboardUrl so we can highlight the active one and hide
// its Connect button. Empty unless the saved connection is a cloud one.
const connectedCloudUrl =
state.mode === 'cloud' ? state.remoteUrl.trim().replace(/\/+$/, '') : ''
const isConnectedAgent = (agent: DesktopCloudAgent) =>
Boolean(
connectedCloudUrl &&
agent.dashboardUrl &&
agent.dashboardUrl.trim().replace(/\/+$/, '') === connectedCloudUrl
)
useEffect(() => {
if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
setProbeStatus('idle')
@@ -379,6 +430,235 @@ export function GatewaySettings() {
}
}
// --- Hermes Cloud handlers ---
// Pull the discovered agent list over the shared portal session. Tolerant of
// a lapsed session: a needsCloudLogin error flips us back to signed-out.
// `org` scopes discovery for multi-org users; when discovery comes back with
// needsOrgSelection we surface the org list and show a picker instead.
const discoverCloud = async (org?: string) => {
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
setCloudDiscover('loading')
try {
const result = await desktop.cloud.discover(org)
if ('needsOrgSelection' in result && result.needsOrgSelection) {
// Multi-org user with no org chosen yet: show the picker. Don't clear a
// previously-chosen org list on a refresh.
setCloudOrgs(result.orgs)
setCloudAgents([])
setCloudDiscover('done')
return
}
// Single org (or org now chosen): we have agents.
setCloudAgents('agents' in result ? result.agents : [])
// Record the org AUTHORITATIVELY from the response (NAS echoes the org the
// list was scoped to), falling back to the org we requested. This is what
// gets persisted on connect, so it must be set even on single-membership
// auto-resolve where no picker ran and no `org` arg was passed.
const resolvedOrgRef =
'org' in result && result.org ? (result.org.slug ?? result.org.id) : null
if (resolvedOrgRef) {
setCloudOrg(resolvedOrgRef)
} else if (org) {
setCloudOrg(org)
}
setCloudDiscover('done')
} catch (err) {
setCloudAgents([])
setCloudDiscover('error')
// A lapsed/absent portal session means we're effectively signed out.
if (err && typeof err === 'object' && 'needsCloudLogin' in err) {
setCloudSignedIn(false)
}
notifyError(err, g.cloudDiscoverFailed)
}
}
// User picked an org from the multi-org picker: remember it and re-run
// discovery scoped to it.
const selectCloudOrg = (org: DesktopCloudOrg) => {
const ref = org.slug ?? org.id
setCloudOrg(ref)
void discoverCloud(ref)
}
// "Change org": clear the selected org and re-discover with no org arg. A
// multi-org user gets NAS's 409 → the picker; a single-org user auto-resolves
// back to their one org. Also clear the agent list so the current org's
// agents don't linger under the picker while discovery re-runs.
const changeCloudOrg = () => {
setCloudOrg(null)
setCloudAgents([])
void discoverCloud()
}
// On entering cloud mode (or scope change), read the portal session status and
// auto-discover when already signed in, so the picker is populated on open.
useEffect(() => {
if (state.mode !== 'cloud') {
return
}
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
let cancelled = false
desktop.cloud
.status()
.then(status => {
if (cancelled) {
return
}
setCloudSignedIn(status.signedIn)
if (status.signedIn) {
// Restore the persisted org (if any) so we reopen straight into that
// org's agent list instead of the picker; discoverCloud(org) also
// records it as the selected org. Empty → normal discovery (single-org
// resolves automatically; multi-org shows the picker).
const savedOrg = state.cloudOrg || ''
if (savedOrg) {
setCloudOrg(savedOrg)
}
void discoverCloud(savedOrg || undefined)
} else {
setCloudAgents([])
setCloudOrgs([])
setCloudOrg(null)
setCloudDiscover('idle')
}
})
.catch(() => {
if (!cancelled) {
setCloudSignedIn(false)
}
})
return () => void (cancelled = true)
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload on mode/scope change only
}, [state.mode, scope])
const cloudSignIn = async () => {
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
setCloudSigningIn(true)
try {
const result = await desktop.cloud.login()
setCloudSignedIn(result.signedIn)
if (result.signedIn) {
await discoverCloud()
}
} catch (err) {
notifyError(err, g.cloudSignInFailed)
} finally {
setCloudSigningIn(false)
}
}
const cloudSignOut = async () => {
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
setCloudSigningIn(true)
try {
await desktop.cloud.logout()
setCloudSignedIn(false)
setCloudAgents([])
setCloudOrgs([])
setCloudOrg(null)
setCloudDiscover('idle')
notify({ kind: 'success', title: g.cloudSignedOutTitle, message: g.cloudSignedOutMessage })
} catch (err) {
notifyError(err, g.signOutFailed)
} finally {
setCloudSigningIn(false)
}
}
// Select a discovered agent: drive the silent per-agent cascade (no second
// prompt — the shared portal session auto-approves), then persist a cloud-mode
// connection pointed at its dashboardUrl and apply it (reconnects the window).
const connectCloudAgent = async (agent: DesktopCloudAgent) => {
if (!agent.dashboardUrl) {
return
}
const desktop = window.hermesDesktop
if (!desktop?.cloud) {
return
}
setCloudConnectingId(agent.id)
try {
const result = await desktop.cloud.agentSignIn(agent.dashboardUrl)
if (!result.connected) {
notify({
kind: 'warning',
title: t.boot.failure.signInIncompleteTitle,
message: t.boot.failure.signInIncompleteMessage
})
return
}
// Persist a cloud-mode connection (remote-shaped, oauth) and reconnect.
// Include the selected org so Settings reopens into the same org + instance.
// Read the REF (not the cloudOrg state) so a just-resolved org from
// discovery in this same render tick is captured, not a stale null.
const next = await desktop.applyConnectionConfig({
mode: 'cloud',
profile: scope ?? undefined,
remoteAuthMode: 'oauth',
remoteUrl: agent.dashboardUrl,
cloudOrg: cloudOrgRef.current ?? undefined
})
setState(next)
notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) })
} catch (err) {
if (err && typeof err === 'object' && 'needsCloudLogin' in err) {
setCloudSignedIn(false)
}
notifyError(err, g.cloudConnectFailed)
} finally {
setCloudConnectingId(null)
}
}
const testRemote = async () => {
if (!canUseRemote) {
notify({
@@ -465,7 +745,7 @@ export function GatewaySettings() {
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-3">
<ModeCard
active={state.mode === 'local'}
description={g.localDesc}
@@ -474,6 +754,14 @@ export function GatewaySettings() {
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
title={g.localTitle}
/>
<ModeCard
active={state.mode === 'cloud'}
description={g.cloudDesc}
disabled={state.envOverride}
icon={Cloud}
onSelect={() => setState(current => ({ ...current, mode: 'cloud' }))}
title={g.cloudTitle}
/>
<ModeCard
active={state.mode === 'remote'}
description={g.remoteDesc}
@@ -484,6 +772,155 @@ export function GatewaySettings() {
/>
</div>
{/* Hermes Cloud panel: one portal sign-in, then a discovered-agent picker
whose selection drives the silent per-agent cascade + a cloud
connection. Replaces the URL/token form while in cloud mode. */}
{state.mode === 'cloud' && !state.envOverride ? (
<div className="mt-5 grid gap-1">
<ListRow
action={
cloudSignedIn ? (
<div className="flex items-center gap-2">
<Pill tone="primary">
<Check className="size-3" /> {g.cloudSignedIn}
</Pill>
<Button disabled={cloudSigningIn} onClick={() => void cloudSignOut()} variant="outline">
{cloudSigningIn ? <Loader2 className="animate-spin" /> : null}
{g.signOut}
</Button>
</div>
) : (
<Button disabled={cloudSigningIn} onClick={() => void cloudSignIn()}>
{cloudSigningIn ? <Loader2 className="animate-spin" /> : <LogIn />}
{g.cloudSignIn}
</Button>
)
}
description={cloudSignedIn ? g.cloudSignedInDesc : g.cloudNeedsSignIn}
title={g.cloudSignInTitle}
/>
{cloudSignedIn ? (
cloudOrgs.length > 0 && !cloudOrg ? (
// Multi-org user who hasn't picked an org yet: show the org picker
// instead of the agent list. Selecting one re-runs discovery
// scoped to it.
<div className="mt-3">
<div className="mb-2 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
{g.cloudOrgPickerTitle}
</div>
<div className="grid gap-1">
{cloudOrgs.map(orgEntry => (
<ListRow
action={
<Button onClick={() => selectCloudOrg(orgEntry)} size="sm">
{g.cloudOrgSelect}
</Button>
}
description={g.cloudOrgRole(orgEntry.role)}
key={orgEntry.id}
title={orgEntry.name}
/>
))}
</div>
</div>
) : (
<div className="mt-3">
<div className="mb-2 flex items-center justify-between">
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
{g.cloudAgentsTitle}
</div>
<div className="flex items-center gap-2">
{cloudOrg ? (
// Let the user switch orgs. Gating on cloudOrgs.length would
// hide this after a restore-open (which discovers straight
// into the saved org and never populates the org list). So
// show it whenever an org is selected: clicking clears the
// org and re-runs discovery with no org arg — a multi-org
// user gets the picker (NAS 409), a single-org user simply
// auto-resolves back to their one org (harmless).
<Button onClick={() => changeCloudOrg()} size="sm" variant="text">
{g.cloudOrgChange}
</Button>
) : null}
<Button
disabled={cloudDiscover === 'loading'}
onClick={() => void discoverCloud(cloudOrg ?? undefined)}
size="sm"
variant="text"
>
{cloudDiscover === 'loading' ? <Loader2 className="animate-spin" /> : <RefreshCw />}
{g.cloudRefresh}
</Button>
</div>
</div>
{cloudDiscover === 'loading' ? (
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<Loader2 className="size-4 animate-spin" />
{g.cloudLoadingAgents}
</div>
) : cloudAgents.length === 0 ? (
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>
{g.cloudNoAgents.before}
<ExternalLink href="https://portal.nousresearch.com/agents" showExternalIcon={false}>
{g.cloudNoAgents.linkText}
</ExternalLink>
{g.cloudNoAgents.after}
</span>
</div>
) : (
<div className="grid gap-1">
{cloudAgents.map(agent => {
const connected = isConnectedAgent(agent)
return (
<div
className={cn(
'rounded-md px-2',
connected && 'bg-primary/5 ring-1 ring-primary/25'
)}
key={agent.id}
>
<ListRow
action={
connected ? (
<Pill tone="primary">
<Check className="mr-1 inline size-3" />
{g.cloudConnectedPill}
</Pill>
) : (
<Button
disabled={!agent.dashboardUrl || cloudConnectingId !== null}
onClick={() => void connectCloudAgent(agent)}
size="sm"
>
{cloudConnectingId === agent.id ? <Loader2 className="animate-spin" /> : null}
{agent.dashboardUrl
? cloudConnectingId === agent.id
? g.cloudConnecting
: g.cloudConnect
: g.cloudAgentProvisioning}
</Button>
)
}
description={g.cloudStatusLabel(agent.dashboardGatewayState)}
title={agent.name}
/>
</div>
)
})}
</div>
)}
</div>
)
) : null}
</div>
) : null}
{state.mode === 'remote' && !state.envOverride ? (
<div className="mt-5 grid gap-1">
<ListRow
action={
@@ -568,28 +1005,36 @@ export function GatewaySettings() {
/>
) : null}
</div>
) : null}
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
<Button
className="mr-auto"
disabled={state.envOverride || testing || !canUseRemote}
onClick={() => void testRemote()}
size="sm"
variant="text"
>
{testing ? <Loader2 className="animate-spin" /> : null}
{g.testRemote}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
{g.saveForRestart}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
{saving ? <Loader2 className="animate-spin" /> : null}
{g.saveAndReconnect}
</Button>
</div>
{/* Test/Save apply to local + remote. Cloud connects via the agent picker
above (which applies a cloud connection on select), so its only
bottom-row action would be redundant — hidden in cloud mode. */}
{state.mode !== 'cloud' ? (
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
{state.mode === 'remote' ? (
<Button
className="mr-auto"
disabled={state.envOverride || testing || !canUseRemote}
onClick={() => void testRemote()}
size="sm"
variant="text"
>
{testing ? <Loader2 className="animate-spin" /> : null}
{g.testRemote}
</Button>
) : null}
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
{g.saveForRestart}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
{saving ? <Loader2 className="animate-spin" /> : null}
{g.saveAndReconnect}
</Button>
</div>
) : null}
<div className="mt-6 grid gap-1">
<ListRow
@@ -14,6 +14,7 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
remoteTokenPreview: null,
remoteTokenSet: false,
remoteUrl: 'https://box:9119',
cloudOrg: '',
...overrides
}
}
@@ -31,6 +32,16 @@ describe('isRemoteReauthFailure', () => {
expect(isRemoteReauthFailure(config({ mode: 'local' }))).toBe(false)
})
it('true for a cloud connection with a lapsed session (cloud resolves to remote oauth)', () => {
// A 'cloud' connection is a remote oauth backend under the hood (Q6), so a
// lapsed cloud session is the same reauth failure as a lapsed remote one.
expect(isRemoteReauthFailure(config({ mode: 'cloud' }))).toBe(true)
})
it('false for a connected cloud session', () => {
expect(isRemoteReauthFailure(config({ mode: 'cloud', remoteOauthConnected: true }))).toBe(false)
})
it('false for a token (non-gated) remote gateway', () => {
expect(isRemoteReauthFailure(config({ remoteAuthMode: 'token' }))).toBe(false)
})
@@ -31,14 +31,16 @@ const DEFAULT_SIGN_IN_COPY: SignInCopy = {
// dashboard restarted) and the local-recovery buttons (Retry/Repair) can't
// fix it — only re-establishing the remote session can. A connected oauth
// session, or a token/local gateway, boots for some other reason the
// local-recovery buttons address, so those return false here.
// local-recovery buttons address, so those return false here. 'cloud' counts
// as remote here — it resolves to a remote oauth backend (cloud-auto-discovery
// Q6), so a lapsed cloud session is the same reauth failure.
export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined): boolean {
if (!config) {
return false
}
return (
config.mode === 'remote' &&
(config.mode === 'remote' || config.mode === 'cloud') &&
config.remoteAuthMode === 'oauth' &&
!config.remoteOauthConnected &&
Boolean(config.remoteUrl)
@@ -1,97 +0,0 @@
import { describe, expect, it } from 'vitest'
import { chooseMove, dwellMs, type DwellRange, HOP_CHANCE, pickStrollTarget, REST_CHANCE, type Rng } from './roam-behavior'
import type { Ledge } from './roam-geometry'
// Deterministic rng that replays a fixed sequence (last value sticks).
const seq =
(...vals: number[]): Rng =>
() =>
vals.shift() ?? vals[vals.length - 1] ?? 0
const RANGE: DwellRange = { maxMs: 13000, meanMs: 4000, minMs: 1500 }
const ledge = (left: number, right: number, y = 0): Ledge => ({ left, right, y })
describe('dwellMs', () => {
it('clamps the degenerate draws to the floor and ceiling', () => {
// rng→0 ⇒ u=1 ⇒ -ln(1)·mean = 0, raised to the floor.
expect(dwellMs(RANGE, () => 0)).toBe(RANGE.minMs)
// rng→~1 ⇒ u→0 ⇒ -ln(u) blows up, capped at the ceiling.
expect(dwellMs(RANGE, () => 1 - 1e-9)).toBe(RANGE.maxMs)
})
it('returns the mean at the exponential median point', () => {
// rng = 1 - 1/e ⇒ u = 1/e ⇒ -ln(u) = 1 ⇒ exactly the mean.
expect(dwellMs(RANGE, () => 1 - 1 / Math.E)).toBeCloseTo(RANGE.meanMs, 6)
})
it('stays within [min, max] across the whole rng domain', () => {
let state = 0.123456789
for (let i = 0; i < 5000; i++) {
state = (state * 9301 + 0.49297) % 1 // cheap deterministic walk
const ms = dwellMs(RANGE, () => state)
expect(ms).toBeGreaterThanOrEqual(RANGE.minMs)
expect(ms).toBeLessThanOrEqual(RANGE.maxMs)
}
})
})
describe('chooseMove', () => {
it('rests whenever the first draw lands under restChance — even where it could hop', () => {
expect(chooseMove(true, seq(0))).toBe('rest')
expect(chooseMove(false, seq(REST_CHANCE - 1e-9))).toBe('rest')
})
it('strolls when moving with nowhere to hop', () => {
expect(chooseMove(false, seq(0.99))).toBe('stroll')
})
it('hops only when moving, a ledge is reachable, and the second draw says so', () => {
expect(chooseMove(true, seq(0.99, HOP_CHANCE - 1e-9))).toBe('hop')
expect(chooseMove(true, seq(0.99, HOP_CHANCE))).toBe('stroll')
})
it('treats restChance as a strict lower bound (boundary stays a move)', () => {
expect(chooseMove(false, seq(REST_CHANCE))).toBe('stroll')
})
it('loafs far more than it roams over a long run (the whole point)', () => {
let state = 0.314159
const rng: Rng = () => (state = (state * 16807 + 0.5) % 1)
let rests = 0
const N = 20000
for (let i = 0; i < N; i++) {
if (chooseMove(true, rng) === 'rest') {
rests++
}
}
// ~62% rests; assert the contract (majority loafing), not the exact rate.
expect(rests / N).toBeGreaterThan(0.5)
})
})
describe('pickStrollTarget', () => {
it('collapses to the left edge on a ledge too narrow to walk', () => {
expect(pickStrollTarget(ledge(100, 102), 100, seq(0))).toBe(100)
})
it('lands inside the ledge and clears the minimum travel distance', () => {
const wide = ledge(0, 1000)
const from = 500
const x = pickStrollTarget(wide, from, seq(0.5, 0))
expect(x).toBeGreaterThanOrEqual(wide.left)
expect(x).toBeLessThanOrEqual(wide.right)
expect(Math.abs(x - from)).toBeGreaterThanOrEqual(110) // STROLL_MIN_PX
})
it('heads toward the side with more room', () => {
// Pinned near the right wall, the roomier side is left. First draw clears the
// rare double-back coin ⇒ it commits to the roomy (left) side ⇒ target < x.
const x = pickStrollTarget(ledge(0, 1000), 950, seq(0.5, 0))
expect(x).toBeLessThan(950)
})
})
@@ -1,97 +0,0 @@
/**
* Pure decision helpers for the floating pet's wander — the "what to do & when"
* layer, split out from the geometry (`roam-geometry.ts`) and the RAF/DOM loop
* (`use-pet-roam.ts`) so the *rhythm* of the roam is tunable in one place and
* unit testable (every function takes an injectable `rng`).
*
* The goal is a calm, believable critter rather than a fidgeting one. Two ideas
* from ambient game-AI carry the weight (see GameAIPro ch.36 "Breathing Life
* into Your Background Characters" + standard idle/wander state machines):
*
* 1. **Loaf, don't pace.** A background character that picks a new walk on
* every beat reads as nervous. Most decision beats just keep resting;
* movement is the exception, not the default (`REST_CHANCE`).
* 2. **Memoryless dwell times.** Uniform pauses feel metronomic. An
* exponential dwell — the classic model for idle durations — gives mostly
* short rests with the occasional long loaf, so the cadence never reads as a
* fixed pattern (`dwellMs` / `PAUSE_DWELL`).
*/
import type { Ledge } from './roam-geometry'
export type Rng = () => number
/** What the pet does when a rest beat ends. */
export type RoamMove = 'rest' | 'stroll' | 'hop'
export interface DwellRange {
/** Mean of the exponential draw — the "typical" rest length. */
meanMs: number
/** Floor, so a near-zero draw never produces a jittery micro-pause. */
minMs: number
/** Ceiling, so a fat-tail draw (or a throttled tab) can't freeze the pet. */
maxMs: number
}
// Rest length between beats: mostly short loafs, the occasional long one.
export const PAUSE_DWELL: DwellRange = { maxMs: 13000, meanMs: 4200, minMs: 1500 }
// Most beats the pet just keeps loafing — a critter that re-walks every beat
// reads as nervous, not alive.
export const REST_CHANCE = 0.62
// When it *does* move, chance it hops to another ledge vs. strolling this one.
export const HOP_CHANCE = 0.2
// Strolls should cover ground, not shuffle: travel at least this fraction of the
// ledge (or this many px, whichever is larger), up to the room available.
const STROLL_MIN_FRACTION = 0.45
const STROLL_MIN_PX = 110
// Bias toward the roomier side so the pet crosses the app instead of pacing one
// spot; the long tail of the coin still lets it double back now and then.
const STROLL_TOWARD_ROOM = 0.85
/**
* Exponential (memoryless) dwell time, clamped to `[minMs, maxMs]`. With rng→0
* this returns `minMs`; with rng→1 it saturates at `maxMs`; in between it's
* `-ln(u)·meanMs`, so short rests dominate and long loafs are rare but possible.
*/
export function dwellMs({ meanMs, minMs, maxMs }: DwellRange, rng: Rng = Math.random): number {
const u = 1 - rng() // map [0,1) → (0,1] so the log stays finite
return Math.min(maxMs, Math.max(minMs, -Math.log(u) * meanMs))
}
/**
* Decide a beat: rest (the common case), or — when the pet is actually going to
* move — hop to a reachable ledge if one exists and the dice say so, else stroll
* the current ledge. `canHop` is false when no neighbouring surface overlaps, so
* the pet never "hops" in place.
*/
export function chooseMove(canHop: boolean, rng: Rng = Math.random): RoamMove {
if (rng() < REST_CHANCE) {
return 'rest'
}
return canHop && rng() < HOP_CHANCE ? 'hop' : 'stroll'
}
/**
* A stroll destination (absolute x) on `ledge` that actually goes somewhere:
* lean toward the side with more room and guarantee a decent minimum travel, so
* the pet crosses the app rather than shuffling in place.
*/
export function pickStrollTarget(ledge: Ledge, fromX: number, rng: Rng = Math.random): number {
const span = ledge.right - ledge.left
if (span <= 4) {
return ledge.left
}
const roomLeft = fromX - ledge.left
const roomRight = ledge.right - fromX
// Usually head to the roomier side; the long tail of the coin doubles back.
const goRight = (rng() < STROLL_TOWARD_ROOM) === (roomRight >= roomLeft)
const room = Math.max(0, goRight ? roomRight : roomLeft)
const minDist = Math.min(room, Math.max(span * STROLL_MIN_FRACTION, STROLL_MIN_PX))
const dist = minDist + rng() * Math.max(0, room - minDist)
return goRight ? fromX + dist : fromX - dist
}
@@ -1,51 +0,0 @@
import { describe, expect, it } from 'vitest'
import { GROUND_EPS, groundTop, type Ledge, overlapsX, resolveLedge } from './roam-geometry'
const ledge = (y: number, left = 0, right = 1000): Ledge => ({ left, right, y })
describe('groundTop', () => {
it('sinks the feet by the padding offset so they meet the surface', () => {
// y - petH + FEET_DROP_PX(4)
expect(groundTop(ledge(500), 100)).toBe(404)
})
})
describe('overlapsX', () => {
it('is true only when the walkable ranges share real width', () => {
expect(overlapsX(ledge(0, 0, 100), ledge(0, 50, 200))).toBe(true)
expect(overlapsX(ledge(0, 0, 100), ledge(0, 100, 200))).toBe(false) // touching, not overlapping
expect(overlapsX(ledge(0, 0, 100), ledge(0, 300, 400))).toBe(false)
})
})
describe('resolveLedge', () => {
const floor = ledge(600)
const shelf = ledge(300, 100, 400)
it('returns the highest surface at or below the feet under the current x', () => {
// Standing on the shelf line, under the shelf's x-span ⇒ the shelf.
const petH = 100
const onShelf = resolveLedge([floor, shelf], 200, shelf.y - petH, petH)
expect(onShelf).toBe(shelf)
})
it('ignores surfaces the pet is not horizontally over', () => {
const petH = 100
// x=800 is past the shelf ⇒ only the floor qualifies.
const onFloor = resolveLedge([floor, shelf], 800, floor.y - petH, petH)
expect(onFloor).toBe(floor)
})
it('falls back to the floor (ledges[0]) when below everything', () => {
const petH = 100
const below = resolveLedge([floor, shelf], 200, 5000, petH)
expect(below).toBe(floor)
})
it('counts a surface within GROUND_EPS of the feet as standing on it', () => {
const petH = 100
const justAbove = resolveLedge([floor], 10, floor.y - petH - GROUND_EPS + 0.5, petH)
expect(justAbove).toBe(floor)
})
})
@@ -1,130 +0,0 @@
/**
* The "where can it stand" layer of the floating pet's wander: it measures the
* live DOM for walkable surfaces and answers pure questions about them. Split
* from the decision logic (`roam-behavior.ts`) and the RAF/DOM loop
* (`use-pet-roam.ts`) so the loop reads as physics, not geometry, and the pure
* helpers (`overlapsX`, `resolveLedge`, `groundTop`) stay unit testable.
*/
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
/**
* A horizontal surface the pet can stand and walk on. `y` is the surface line
* (where the pet's feet rest); `left`/`right` bound the pet's top-left x so the
* whole sprite stays on the ledge.
*/
export interface Ledge {
y: number
left: number
right: number
}
// Elements the pet can perch on top of, measured fresh each beat. The bottom
// floor is always a ledge; these add app furniture the pet can climb onto (the
// composer, the profile rail). Add a `data-slot` here to grow the playground.
const PERCH_SELECTORS = ['[data-slot="composer-surface"]', '[data-slot="profile-rail"]']
// A full-width bar pinned to the window bottom (the status bar). When present,
// the pet walks along its TOP edge instead of the window edge, so it stands on
// the bar rather than covering it.
const FLOOR_BAR_SELECTOR = '[data-slot="statusbar"]'
// Sprites carry a few px of transparent padding below the feet; sink the pet by
// this much so the visible feet meet the surface instead of hovering above it.
const FEET_DROP_PX = 4
// Snap distance: how close the feet must be to count as "on this ledge".
export const GROUND_EPS = 2
const vw = (): number => window.innerWidth || 800
const vh = (): number => window.innerHeight || 600
/** The y a pet of height `petH` rests at when standing on `ledge`. */
export const groundTop = (ledge: Ledge, petH: number): number => ledge.y - petH + FEET_DROP_PX
/**
* Do the pet's walkable x-ranges on two ledges overlap enough to step across?
* (Pure — the wander uses it to find hop-reachable neighbours.)
*/
export const overlapsX = (from: Ledge, to: Ledge): boolean =>
Math.min(from.right, to.right) > Math.max(from.left, to.left) + 2
/**
* The highest surface at or below the pet's feet under its current x — i.e. what
* it's standing on, or what it would fall onto. Pure; falls back to the floor
* (always `ledges[0]`) if the pet is somehow below everything.
*/
export function resolveLedge(ledges: Ledge[], x: number, y: number, petH: number): Ledge {
const bottom = y + petH
let best: Ledge | null = null
for (const ledge of ledges) {
if (x < ledge.left - 2 || x > ledge.right + 2) {
continue
}
if (ledge.y >= bottom - GROUND_EPS && (!best || ledge.y < best.y)) {
best = ledge
}
}
return best ?? ledges[0]!
}
/** The bottom ground line: the top of the status bar if it's pinned full-width
* across the window bottom, otherwise the window edge. */
function floorY(width: number, height: number, petH: number): number {
const bar = document.querySelector(FLOOR_BAR_SELECTOR)
if (bar) {
const rect = bar.getBoundingClientRect()
if (rect.width >= width * 0.5 && height - rect.bottom < 4 && rect.top - petH >= 0) {
return rect.top
}
}
return height
}
/** Snapshot the walkable surfaces right now: the bottom floor plus any on-screen
* perch element with room above it for the pet to stand. */
export function snapshotLedges(petW: number, petH: number): Ledge[] {
const width = vw()
const height = vh()
const ledges: Ledge[] = [{ left: 0, right: Math.max(0, width - petW), y: floorY(width, height, petH) }]
for (const selector of PERCH_SELECTORS) {
const el = document.querySelector(selector)
if (!el) {
continue
}
const rect = el.getBoundingClientRect()
const left = Math.max(0, rect.left)
const right = Math.min(width - petW, rect.right - petW)
// Skip surfaces that are too narrow for the pet, have no headroom above, or
// sit off-screen / flush with the floor (no daylight between them).
if (right <= left + 2 || rect.top - petH < 0 || rect.top > height - 8 || height - rect.top < 12) {
continue
}
ledges.push({ left, right, y: rect.top })
}
return ledges
}
/**
* While a full-screen route overlay is up it's the only walkable surface: a
* single ledge at the overlay card's bottom inner edge. The card uses
* `OverlayView`'s equal inset on every side — `titlebar-height + padding` — so
* we derive it from that rather than measuring.
*/
export function overlayLedge(petW: number): Ledge {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
const inset = TITLEBAR_HEIGHT + (vw() >= 640 ? 0.875 : 0.625) * rem
return { left: inset, right: Math.max(0, vw() - inset - petW), y: vh() - inset }
}
+167 -32
View File
@@ -1,15 +1,34 @@
import { type RefObject, useEffect } from 'react'
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
import { $petMotion, $petRoamDir, type PetState } from '@/store/pet'
import { chooseMove, dwellMs, PAUSE_DWELL, pickStrollTarget } from './roam-behavior'
import { GROUND_EPS, groundTop, type Ledge, overlapsX, overlayLedge, resolveLedge, snapshotLedges } from './roam-geometry'
interface Point {
x: number
y: number
}
/**
* A horizontal surface the pet can stand and walk on. `y` is the surface line
* (where the pet's feet rest); `left`/`right` bound the pet's top-left x so the
* whole sprite stays on the ledge.
*/
interface Ledge {
y: number
left: number
right: number
}
// Elements the pet can perch on top of, measured fresh each beat. The bottom
// floor is always a ledge; these add app furniture the pet can climb onto (the
// composer, the profile rail). Add a `data-slot` here to grow the playground.
const PERCH_SELECTORS = ['[data-slot="composer-surface"]', '[data-slot="profile-rail"]']
// A full-width bar pinned to the window bottom (the status bar). When present,
// the pet walks along its TOP edge instead of the window edge, so it stands on
// the bar rather than covering it.
const FLOOR_BAR_SELECTOR = '[data-slot="statusbar"]'
// Foot-sync: advance this many body-widths per animation loop so the walk reads
// as steps, not a glide. Actual px/s is derived from the sprite's loop duration
// and on-screen size (see `walkSpeedPxS`).
@@ -18,10 +37,22 @@ const STRIDE_PER_LOOP = 0.8
const GRAVITY_PX_S2 = 5200
// Time to spring up onto a higher ledge.
const JUMP_DUR_MS = 460
const PAUSE_MIN_MS = 1800
const PAUSE_MAX_MS = 5200
// Tiny settle after a drag release before the pet re-plans (and usually falls),
// so dropping it in mid-air snaps down promptly instead of hanging for a beat.
const DROP_SETTLE_MS = 90
// Arrived at a walk target.
// Chance a beat hops to another ledge instead of strolling the current one.
const HOP_CHANCE = 0.45
// Strolls should cover ground, not shuffle: travel at least this fraction of the
// ledge (or this many px, whichever is larger), up to the room available.
const STROLL_MIN_FRACTION = 0.45
const STROLL_MIN_PX = 110
// Sprites carry a few px of transparent padding below the feet; sink the pet by
// this much so the visible feet meet the surface instead of hovering above it.
const FEET_DROP_PX = 4
// Snap distances: "on this ledge" / arrived at a walk target.
const GROUND_EPS = 2
const ARRIVE_EPS = 1.5
// Cap dt so a backgrounded/throttled tab can't teleport the pet on resume.
const MAX_DT_S = 0.05
@@ -32,6 +63,60 @@ const rand = (min: number, max: number): number => min + Math.random() * (max -
const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3
const signDir = (n: number): -1 | 0 | 1 => (n > 0 ? 1 : n < 0 ? -1 : 0)
function vw(): number {
return window.innerWidth || 800
}
function vh(): number {
return window.innerHeight || 600
}
/** The bottom ground line: the top of the status bar if it's pinned full-width
* across the window bottom, otherwise the window edge. */
function floorY(width: number, height: number, petH: number): number {
const bar = document.querySelector(FLOOR_BAR_SELECTOR)
if (bar) {
const rect = bar.getBoundingClientRect()
if (rect.width >= width * 0.5 && height - rect.bottom < 4 && rect.top - petH >= 0) {
return rect.top
}
}
return height
}
/** Snapshot the walkable surfaces right now: the bottom floor plus any on-screen
* perch element with room above it for the pet to stand. */
function snapshotLedges(petW: number, petH: number): Ledge[] {
const width = vw()
const height = vh()
const ledges: Ledge[] = [{ left: 0, right: Math.max(0, width - petW), y: floorY(width, height, petH) }]
for (const selector of PERCH_SELECTORS) {
const el = document.querySelector(selector)
if (!el) {
continue
}
const rect = el.getBoundingClientRect()
const left = Math.max(0, rect.left)
const right = Math.min(width - petW, rect.right - petW)
// Skip surfaces that are too narrow for the pet, have no headroom above, or
// sit off-screen / flush with the floor (no daylight between them).
if (right <= left + 2 || rect.top - petH < 0 || rect.top > height - 8 || height - rect.top < 12) {
continue
}
ledges.push({ left, right, y: rect.top })
}
return ledges
}
interface PetRoamOptions {
/** Run the wander loop (roam opt-in + pet active + in-window + agent at rest). */
enabled: boolean
@@ -49,13 +134,13 @@ interface PetRoamOptions {
}
/**
* Drive the floating pet's wander as a platformer state machine: it loafs, then
* walks along surfaces (the window floor, the top of the composer, …), hops up
* onto higher ledges, and drops off them — instead of drifting through empty
* space. The walkable surfaces come from `roam-geometry` (re-measured from the
* live DOM every beat, so the pet tracks the composer growing, the sidebar
* opening, the window resizing) and the beat-to-beat choices from
* `roam-behavior`; this hook owns only the physics and the DOM writes.
* Make the floating pet wander the app like a platformer character: it walks
* along surfaces (the window floor, the top of the composer, …), hops up onto
* higher ledges, and drops off them — instead of drifting diagonally through
* empty space. Surfaces are re-measured from the live DOM at the start of every
* beat (`snapshotLedges`), so the pet tracks the composer growing, the sidebar
* opening, the window resizing, and even falls back to the floor when its perch
* disappears.
*
* Movement mutates `el.style.left/top` directly each frame — like the drag
* handler — so a steady wander triggers no React re-renders, and because it
@@ -95,7 +180,28 @@ export function usePetRoam({
// Pace the stride to the sprite: one body-width per animation loop.
const walkSpeedPxS = (petW * STRIDE_PER_LOOP) / (loopMs / 1000)
const restY = (ledge: Ledge): number => groundTop(ledge, petH)
const groundTop = (ledge: Ledge): number => ledge.y - petH + FEET_DROP_PX
// A stroll destination on `ledge` that actually goes somewhere: lean toward
// the side with more room (so the pet crosses the app rather than shuffling
// in place) and guarantee a decent minimum travel distance.
const pickStrollTarget = (ledge: Ledge): number => {
const span = ledge.right - ledge.left
if (span <= 4) {
return ledge.left
}
const roomLeft = cur.x - ledge.left
const roomRight = ledge.right - cur.x
const goRight = roomRight >= roomLeft ? Math.random() < 0.85 : Math.random() < 0.15
const room = Math.max(0, goRight ? roomRight : roomLeft)
const minDist = Math.min(room, Math.max(span * STROLL_MIN_FRACTION, STROLL_MIN_PX))
const dist = minDist + Math.random() * Math.max(0, room - minDist)
return goRight ? cur.x + dist : cur.x - dist
}
// Seed from the live DOM rect so we resume from wherever the pet actually is
// (after a drag, reclamp, or activity pause) rather than a stale closure.
@@ -131,14 +237,14 @@ export function usePetRoam({
const beginPause = (now: number) => {
phase = 'pause'
pauseUntil = now + dwellMs(PAUSE_DWELL)
pauseUntil = now + rand(PAUSE_MIN_MS, PAUSE_MAX_MS)
signal(null, 0)
commit({ ...cur })
}
// Land flush on a ledge, then settle into the next idle beat.
const settleOn = (ledge: Ledge, now: number) => {
cur.y = restY(ledge)
cur.y = groundTop(ledge)
curLedge = ledge
applyDom()
beginPause(now)
@@ -147,7 +253,7 @@ export function usePetRoam({
const beginVertical = (ledge: Ledge) => {
targetLedge = ledge
if (restY(ledge) < cur.y - 1) {
if (groundTop(ledge) < cur.y - 1) {
// Up onto a higher ledge: a quick spring.
phase = 'jump'
jumpFromY = cur.y
@@ -161,14 +267,51 @@ export function usePetRoam({
signal('jump', 0)
}
const planNext = (now: number) => {
// Does the pet, standing at cur.x, have a stretch of `to` it can step across
// to from `from` (their walkable x-ranges overlap)?
const overlapsX = (from: Ledge, to: Ledge): boolean =>
Math.min(from.right, to.right) > Math.max(from.left, to.left) + 2
// Find the highest surface at or below the pet's feet under its current x —
// i.e. what it's standing on, or what it would fall onto.
const resolveLedge = (ledges: Ledge[]): Ledge => {
const bottom = cur.y + petH
let best: Ledge | null = null
for (const ledge of ledges) {
if (cur.x < ledge.left - 2 || cur.x > ledge.right + 2) {
continue
}
if (ledge.y >= bottom - GROUND_EPS && (!best || ledge.y < best.y)) {
best = ledge
}
}
// Floor always spans the clamped x-range, so this only falls back if the
// pet is somehow below everything — drop it to the floor.
return best ?? ledges[0]!
}
// While an overlay is up, it's the only walkable surface: a single ledge at
// the overlay card's bottom inner edge. The card uses `OverlayView`'s equal
// inset on every side — `titlebar-height + padding` — so derive it from that
// (never measured).
const overlayLedge = (): Ledge => {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
const inset = TITLEBAR_HEIGHT + (vw() >= 640 ? 0.875 : 0.625) * rem
return { left: inset, right: Math.max(0, vw() - inset - petW), y: vh() - inset }
}
const planNext = () => {
// An open overlay swaps the surface set to just its bottom edge, so the pet
// patrols along it; closing it restores the normal surfaces (and the pet
// drops to whatever's below).
const ledges = overlayOpen ? [overlayLedge(petW)] : snapshotLedges(petW, petH)
curLedge = resolveLedge(ledges, cur.x, cur.y, petH)
const ledges = overlayOpen ? [overlayLedge()] : snapshotLedges(petW, petH)
curLedge = resolveLedge(ledges)
if (Math.abs(cur.y - restY(curLedge)) > GROUND_EPS) {
if (Math.abs(cur.y - groundTop(curLedge)) > GROUND_EPS) {
// Dragged into the air, or the surface moved out from under it: fall.
beginVertical(curLedge)
@@ -176,16 +319,8 @@ export function usePetRoam({
}
const reachable = ledges.filter(ledge => ledge !== curLedge && overlapsX(curLedge!, ledge))
const move = chooseMove(reachable.length > 0)
if (move === 'rest') {
// Stay put and loaf another beat — movement is the exception.
beginPause(now)
return
}
if (move === 'hop') {
if (reachable.length > 0 && Math.random() < HOP_CHANCE) {
const next = reachable[Math.floor(Math.random() * reachable.length)]!
const lo = Math.max(curLedge.left, next.left)
const hi = Math.min(curLedge.right, next.right)
@@ -193,7 +328,7 @@ export function usePetRoam({
walkTargetX = lo + Math.random() * (hi - lo)
} else {
pendingHop = null
walkTargetX = pickStrollTarget(curLedge, cur.x)
walkTargetX = pickStrollTarget(curLedge)
}
phase = 'walk'
@@ -223,7 +358,7 @@ export function usePetRoam({
switch (phase) {
case 'pause': {
if (now >= pauseUntil) {
planNext(now)
planNext()
}
break
@@ -262,7 +397,7 @@ export function usePetRoam({
fallVel += GRAVITY_PX_S2 * dt
cur.y += fallVel * dt
if (cur.y >= restY(targetLedge)) {
if (cur.y >= groundTop(targetLedge)) {
settleOn(targetLedge, now)
} else {
applyDom()
@@ -280,7 +415,7 @@ export function usePetRoam({
jumpElapsed += dt * 1000
const t = Math.min(1, jumpElapsed / JUMP_DUR_MS)
cur.y = jumpFromY + (restY(targetLedge) - jumpFromY) * easeOutCubic(t)
cur.y = jumpFromY + (groundTop(targetLedge) - jumpFromY) * easeOutCubic(t)
if (t >= 1) {
settleOn(targetLedge, now)
+73 -2
View File
@@ -55,6 +55,15 @@ declare global {
probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult>
oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult>
oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult>
// Hermes Cloud: one portal login powers discovery + silent per-agent
// sign-in (cloud-auto-discovery Phase 3).
cloud: {
status: () => Promise<DesktopCloudStatus>
login: () => Promise<DesktopCloudStatus & { ok: boolean }>
logout: () => Promise<DesktopCloudStatus & { ok: boolean }>
discover: (org?: string) => Promise<DesktopCloudDiscoverResult>
agentSignIn: (dashboardUrl: string) => Promise<DesktopCloudAgentSignInResult>
}
profile: {
get: () => Promise<DesktopActiveProfile>
// Persists the desktop's profile choice and relaunches the local
@@ -354,6 +363,9 @@ export interface DesktopUpdateProgress {
export interface HermesConnection {
baseUrl: string
isFullscreen: boolean
// The live, RESOLVED connection mode. Only ever 'local' or 'remote' — a
// 'cloud' saved-config entry resolves to a 'remote' connection under the hood
// (cloud-auto-discovery Q3/Q6), so this never carries 'cloud'.
mode?: 'local' | 'remote'
authMode?: 'oauth' | 'token'
nativeOverlayWidth: number
@@ -386,7 +398,12 @@ export interface DesktopActiveProfile {
export interface DesktopConnectionConfig {
envOverride: boolean
mode: 'local' | 'remote'
// The saved connection mode. 'cloud' is a Hermes Cloud connection: it carries
// a remote-shaped block (remoteUrl = the selected agent's dashboardUrl,
// remoteAuthMode 'oauth') but is remembered as cloud so settings reopens into
// the cloud picker. Resolution treats cloud exactly as remote
// (cloud-auto-discovery Q3/Q6).
mode: 'local' | 'remote' | 'cloud'
// The profile this config describes, or null for the global/default
// connection. Per-profile entries let a profile point at its own backend.
profile: null | string
@@ -395,16 +412,23 @@ export interface DesktopConnectionConfig {
remoteTokenPreview: string | null
remoteTokenSet: boolean
remoteUrl: string
// For a 'cloud' connection: the persisted Hermes Cloud org (slug or id) the
// connected instance was discovered under, so Settings → Gateway can reopen
// into that org. Empty string for remote/local.
cloudOrg: string
}
export interface DesktopConnectionConfigInput {
mode: 'local' | 'remote'
mode: 'local' | 'remote' | 'cloud'
// When set, the save/apply/test targets this profile's per-profile remote
// override instead of the global connection.
profile?: null | string
remoteAuthMode?: 'oauth' | 'token'
remoteToken?: string
remoteUrl?: string
// For a 'cloud' connection: the selected Hermes Cloud org (slug or id) to
// persist so Settings can reopen into it. Ignored for remote/local modes.
cloudOrg?: string
}
export interface DesktopConnectionTestResult {
@@ -443,6 +467,53 @@ export interface DesktopOauthLogoutResult {
connected: boolean
}
// --- Hermes Cloud (cloud-auto-discovery Phase 3) ---
export interface DesktopCloudStatus {
// The portal base URL the desktop talks to (default or env-overridden).
portalBaseUrl: string
// Whether the OAuth partition holds a live portal session (AT-or-RT).
signedIn: boolean
}
// A discovered Hermes Cloud agent — the trimmed DTO from NAS GET /api/agents.
export interface DesktopCloudAgent {
id: string
name: string
status: string
// null until the agent has a provisioned dashboard (show "provisioning…").
dashboardUrl: string | null
// "active" | "degraded" | "down" | "unknown".
dashboardGatewayState: string
}
// An org the signed-in user belongs to — for the org picker shown when a
// multi-org user's discovery call needs disambiguation (NAS 409).
export interface DesktopCloudOrg {
id: string
slug: string | null
name: string
isPersonal: boolean
// "OWNER" | "MEMBER".
role: string
}
// Discovery result: either the agent list, OR a request to pick an org first
// (multi-org user, no org chosen yet). The renderer shows a picker on the
// latter and re-calls discover(org). On the agents branch, `org` echoes the
// authoritatively-resolved org the list was scoped to (from NAS), so the
// desktop persists it without relying on transient picker state.
export type DesktopCloudDiscoverResult =
| { agents: DesktopCloudAgent[]; org?: DesktopCloudOrg | null; needsOrgSelection?: false }
| { needsOrgSelection: true; orgs: DesktopCloudOrg[] }
export interface DesktopCloudAgentSignInResult {
// The agent gateway base URL the silent sign-in targeted.
baseUrl: string
// Whether the agent's gateway session cookie landed (silent cascade done).
connected: boolean
}
export interface DesktopBootProgress {
error: string | null
fakeMode: boolean
+32
View File
@@ -530,6 +530,38 @@ export const en: Translations = {
remoteTitle: 'Remote gateway',
remoteDesc:
'Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.',
cloudTitle: 'Hermes Cloud',
cloudDesc:
'Sign in once to Hermes Cloud and pick from the agents on your account — no URL to paste. Connects to the one you choose.',
cloudSignInTitle: 'Hermes Cloud',
cloudSignIn: 'Sign in to Hermes Cloud',
cloudSignedIn: 'Signed in to Hermes Cloud',
cloudNeedsSignIn: 'Sign in to Hermes Cloud to discover the agents on your account.',
cloudSignedInDesc: 'You are signed in. Pick an agent below; the session refreshes automatically.',
cloudAgentsTitle: 'Your agents',
cloudOrgPickerTitle: 'Choose an organization',
cloudOrgSelect: 'Select',
cloudOrgChange: 'Change org',
cloudOrgRole: role => `Role: ${role}`,
cloudLoadingAgents: 'Loading your agents…',
cloudNoAgents: {
before: 'No agents found on this account. Create one in the ',
linkText: 'Nous portal',
after: ', then refresh.'
},
cloudRefresh: 'Refresh',
cloudConnect: 'Connect',
cloudConnecting: 'Connecting…',
cloudDiscoverFailed: 'Could not load your Hermes Cloud agents',
cloudConnectFailed: 'Could not connect to that agent',
cloudSignInFailed: 'Hermes Cloud sign-in failed',
cloudSignedOutTitle: 'Signed out of Hermes Cloud',
cloudSignedOutMessage: 'Cleared the Hermes Cloud session.',
cloudConnectedTitle: 'Connected',
cloudConnectedPill: 'Connected',
cloudConnectedTo: name => `Connected to ${name}.`,
cloudAgentProvisioning: 'Provisioning…',
cloudStatusLabel: status => `Status: ${status}`,
remoteUrlTitle: 'Remote URL',
remoteUrlDesc: 'Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes.',
probing: 'Checking how this gateway authenticates…',
+27
View File
@@ -442,6 +442,33 @@ export interface Translations {
localDesc: string
remoteTitle: string
remoteDesc: string
cloudTitle: string
cloudDesc: string
cloudSignInTitle: string
cloudSignIn: string
cloudSignedIn: string
cloudNeedsSignIn: string
cloudSignedInDesc: string
cloudAgentsTitle: string
cloudOrgPickerTitle: string
cloudOrgSelect: string
cloudOrgChange: string
cloudOrgRole: (role: string) => string
cloudLoadingAgents: string
cloudNoAgents: { before: string; linkText: string; after: string }
cloudRefresh: string
cloudConnect: string
cloudConnecting: string
cloudDiscoverFailed: string
cloudConnectFailed: string
cloudSignInFailed: string
cloudSignedOutTitle: string
cloudSignedOutMessage: string
cloudConnectedTitle: string
cloudConnectedPill: string
cloudConnectedTo: (name: string) => string
cloudAgentProvisioning: string
cloudStatusLabel: (status: string) => string
remoteUrlTitle: string
remoteUrlDesc: string
probing: string
+31
View File
@@ -720,6 +720,37 @@ export const zh: Translations = {
remoteTitle: '远程网关',
remoteDesc:
'将此桌面外壳连接到远程 Hermes 后端。托管网关使用 OAuth 或用户名密码;自托管网关也可能使用会话 token。',
cloudTitle: 'Hermes Cloud',
cloudDesc: '只需登录 Hermes Cloud 一次,即可从你账户下的智能体中选择——无需粘贴 URL。连接到你所选的那一个。',
cloudSignInTitle: 'Hermes Cloud',
cloudSignIn: '登录 Hermes Cloud',
cloudSignedIn: '已登录 Hermes Cloud',
cloudNeedsSignIn: '登录 Hermes Cloud 以发现你账户下的智能体。',
cloudSignedInDesc: '你已登录。在下方选择一个智能体;会话会自动刷新。',
cloudAgentsTitle: '你的智能体',
cloudOrgPickerTitle: '选择一个组织',
cloudOrgSelect: '选择',
cloudOrgChange: '切换组织',
cloudOrgRole: role => `角色:${role}`,
cloudLoadingAgents: '正在加载你的智能体…',
cloudNoAgents: {
before: '此账户下未找到智能体。请在',
linkText: 'Nous 门户',
after: '中创建一个,然后刷新。'
},
cloudRefresh: '刷新',
cloudConnect: '连接',
cloudConnecting: '正在连接…',
cloudDiscoverFailed: '无法加载你的 Hermes Cloud 智能体',
cloudConnectFailed: '无法连接到该智能体',
cloudSignInFailed: 'Hermes Cloud 登录失败',
cloudSignedOutTitle: '已退出 Hermes Cloud',
cloudSignedOutMessage: '已清除 Hermes Cloud 会话。',
cloudConnectedTitle: '已连接',
cloudConnectedPill: '已连接',
cloudConnectedTo: name => `已连接到 ${name}。`,
cloudAgentProvisioning: '正在配置…',
cloudStatusLabel: status => `状态:${status}`,
remoteUrlTitle: '远程 URL',
remoteUrlDesc: '远程 dashboard 后端的基础 URL。支持路径前缀,例如 /hermes。',
probing: '正在检查此网关的认证方式…',
+2
View File
@@ -26,6 +26,7 @@ import {
IconCircle as CircleIcon,
IconClipboard as Clipboard,
IconClock as Clock,
IconCloud as Cloud,
IconCommand as Command,
IconCopy as Copy,
IconCopy as CopyIcon,
@@ -139,6 +140,7 @@ export {
CircleIcon,
Clipboard,
Clock,
Cloud,
Command,
Copy,
CopyIcon,
+7 -49
View File
@@ -1,27 +1,15 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { BUILTIN_THEMES, DEFAULT_SKIN_NAME } from './presets'
import {
$marketplaceInstalls,
$userThemes,
installUserTheme,
isUserTheme,
listAllThemes,
marketplaceIdOf,
removeUserTheme,
resolveTheme
} from './user-themes'
import { $userThemes, installUserTheme, isUserTheme, listAllThemes, removeUserTheme, resolveTheme } from './user-themes'
import { convertVscodeColorTheme } from './vscode'
const makeTheme = (label: string, source?: string) =>
convertVscodeColorTheme(
{
name: label,
type: 'dark',
colors: { 'editor.background': '#101014', 'editor.foreground': '#fafafa', focusBorder: '#7aa2f7' }
},
source ? { source } : undefined
).theme
const makeTheme = (label: string) =>
convertVscodeColorTheme({
name: label,
type: 'dark',
colors: { 'editor.background': '#101014', 'editor.foreground': '#fafafa', focusBorder: '#7aa2f7' }
}).theme
describe('user theme registry', () => {
beforeEach(() => {
@@ -73,33 +61,3 @@ describe('user theme registry', () => {
expect(() => installUserTheme(broken)).toThrow(/colors/)
})
})
describe('marketplace install tracking', () => {
beforeEach(() => {
window.localStorage.clear()
$userThemes.set({})
})
it('recovers the extension id only from Marketplace-sourced themes', () => {
expect(marketplaceIdOf(makeTheme('Dracula', 'dracula-theme.theme-dracula'))).toBe('dracula-theme.theme-dracula')
// A pasted (non-Marketplace) import has no extension id to report.
expect(marketplaceIdOf(makeTheme('Pasted'))).toBeNull()
})
it('maps installed Marketplace extension ids to their theme, reactively', () => {
expect($marketplaceInstalls.get().size).toBe(0)
const theme = installUserTheme(makeTheme('Dracula', 'dracula-theme.theme-dracula'))
const map = $marketplaceInstalls.get()
expect(map.get('dracula-theme.theme-dracula')).toEqual(theme)
removeUserTheme(theme.name)
expect($marketplaceInstalls.get().has('dracula-theme.theme-dracula')).toBe(false)
})
it('omits pasted imports (no extension id) from the map', () => {
installUserTheme(makeTheme('Pasted'))
expect($marketplaceInstalls.get().size).toBe(0)
})
})
+1 -32
View File
@@ -10,18 +10,13 @@
* React mounts) can resolve a user theme synchronously, same as built-ins.
*/
import { atom, computed } from 'nanostores'
import { atom } from 'nanostores'
import { BUILTIN_THEMES } from './presets'
import type { DesktopTheme, DesktopThemeColors } from './types'
const USER_THEMES_KEY = 'hermes-desktop-user-themes-v1'
// Marketplace imports stamp their description "VS Code · <publisher.extension>"
// (see `convertVscodeColorTheme`). This is the one place that convention is read
// back out, so every install surface can tell what's already installed.
const MARKETPLACE_DESC_PREFIX = 'VS Code · '
// The minimal set of color keys a stored theme must carry to be usable. We keep
// this loose — `applyTheme` tolerates missing optionals via fallbacks — but a
// theme with no background/foreground/primary is junk and gets dropped.
@@ -116,32 +111,6 @@ export function removeUserTheme(name: string): void {
export const isUserTheme = (name: string): boolean => Boolean($userThemes.get()[name])
/** The Marketplace extension id an installed theme came from, or null. */
export function marketplaceIdOf(theme: DesktopTheme): string | null {
return theme.description.startsWith(MARKETPLACE_DESC_PREFIX)
? theme.description.slice(MARKETPLACE_DESC_PREFIX.length)
: null
}
/**
* Reactive `extensionId → installed theme` map, so the install UIs can mark
* Marketplace rows you already have (and re-activate them without re-downloading)
* from one memoized source instead of re-deriving the set on every render.
*/
export const $marketplaceInstalls = computed($userThemes, themes => {
const map = new Map<string, DesktopTheme>()
for (const theme of Object.values(themes)) {
const id = marketplaceIdOf(theme)
if (id) {
map.set(id, theme)
}
}
return map
})
/** Resolve a theme by name across the merged registry (built-in + user). */
export function resolveTheme(name: string): DesktopTheme | undefined {
return BUILTIN_THEMES[name] ?? $userThemes.get()[name]
-49
View File
@@ -646,25 +646,6 @@ agent:
# force it on or off; the HERMES_VERIFY_ON_STOP env var (1/0) takes precedence.
# verify_on_stop: auto
# Standing operator instructions for the coding posture (when Hermes is in a
# code workspace). Appended to the coding brief as an extra system block, so
# you can pin project-wide workflow rules without editing the shipped brief.
# Accepts a string or a list of strings. Takes effect next session.
# coding_instructions:
# - "For UI work, don't run tsc/lint until I approve the look."
# - "Clean the diff before you commit and push."
# When verify-on-stop finds edited code without fresh verification evidence,
# append guidance for creative UI work (avoid broad tsc/lint/test before visual
# approval) and clean-diff expectations. Set false to keep that nudge terse.
# verify_guidance: true
# A `pre_verify` hook (plugin or shell, see Event Hooks docs) can keep the
# agent going one more turn to verify/clean before finishing. This caps how
# many times one turn may be nudged to continue, so a hook can't trap the loop.
# Default 3.
# max_verify_nudges: 3
# Enable verbose logging
verbose: false
@@ -1295,33 +1276,3 @@ updates:
# # default — works on Fly.io out of the box).
# #
# # public_url: "https://example.com/hermes"
#
# -----------------------------------------------------------------------------
# Self-hosted OIDC dashboard auth (generic OpenID Connect — Authentik,
# Keycloak, Zitadel, Authelia, Auth0, Okta, Google, …). Use this INSTEAD of the
# nous block above when gating the dashboard with your own identity provider.
# Each setting can be overridden by an environment variable:
#
# dashboard.oauth.self_hosted.issuer <- HERMES_DASHBOARD_OIDC_ISSUER
# dashboard.oauth.self_hosted.client_id <- HERMES_DASHBOARD_OIDC_CLIENT_ID
# dashboard.oauth.self_hosted.scopes <- HERMES_DASHBOARD_OIDC_SCOPES
# dashboard.oauth.self_hosted.client_secret <- HERMES_DASHBOARD_OIDC_CLIENT_SECRET
#
# dashboard:
# oauth:
# provider: self-hosted
# self_hosted:
# issuer: "https://auth.example.com/application/o/hermes/" # required
# client_id: "hermes-dashboard" # required
# scopes: "openid profile email" # optional
#
# # OPTIONAL — set ONLY if your IDP registered the client as
# # *confidential* (Authentik / Keycloak often default to this). When
# # set, Hermes authenticates the client at the token endpoint
# # (client_secret_basic or client_secret_post, auto-selected from the
# # IDP's discovery doc) IN ADDITION to PKCE. Leave unset for a public
# # (PKCE-only) client — the common case.
# #
# # This is a CREDENTIAL: prefer setting HERMES_DASHBOARD_OIDC_CLIENT_SECRET
# # in ~/.hermes/.env over putting it here in config.yaml.
# # client_secret: ""
-8
View File
@@ -751,14 +751,6 @@ try:
except Exception:
pass
# Initialize friendly tool labels from config (default on)
try:
from agent.display import set_friendly_tool_labels
_ftl = CLI_CONFIG.get("display", {}).get("friendly_tool_labels", True)
set_friendly_tool_labels(bool(_ftl))
except Exception:
pass
# Neuter AsyncHttpxClientWrapper.__del__ before any AsyncOpenAI clients are
# created. The SDK's __del__ schedules aclose() on asyncio.get_running_loop()
# which, during CLI idle time, finds prompt_toolkit's event loop and tries to
-20
View File
@@ -345,15 +345,6 @@ class PlatformConfig:
# noise; keep True for back-channels where the operator wants them.
gateway_restart_notification: bool = True
# Whether the gateway shows a "typing…" / "is thinking…" status indicator
# while the agent processes a message on this platform. Default True
# preserves prior behavior. Set False on platforms where the indicator is
# unwanted (e.g. Slack's assistant.threads.setStatus "is thinking…", which
# disables the compose box, or any platform where users find the bubble
# noisy). Drives the per-message _keep_typing refresh loop in
# gateway/platforms/base.py.
typing_indicator: bool = True
# Platform-specific settings
extra: Dict[str, Any] = field(default_factory=dict)
@@ -363,7 +354,6 @@ class PlatformConfig:
"extra": self.extra,
"reply_to_mode": self.reply_to_mode,
"gateway_restart_notification": self.gateway_restart_notification,
"typing_indicator": self.typing_indicator,
}
if self.token:
result["token"] = self.token
@@ -387,13 +377,6 @@ class PlatformConfig:
if _grn is None:
_grn = data.get("extra", {}).get("gateway_restart_notification")
# typing_indicator mirrors gateway_restart_notification: it may arrive
# top-level or bridged into extra by the shared-key loop in
# load_gateway_config(), so check both.
_typing = data.get("typing_indicator")
if _typing is None:
_typing = data.get("extra", {}).get("typing_indicator")
return cls(
enabled=_coerce_bool(data.get("enabled"), False),
token=data.get("token"),
@@ -401,7 +384,6 @@ class PlatformConfig:
home_channel=home_channel,
reply_to_mode=data.get("reply_to_mode", "first"),
gateway_restart_notification=_coerce_bool(_grn, True),
typing_indicator=_coerce_bool(_typing, True),
extra=data.get("extra", {}),
)
@@ -1050,8 +1032,6 @@ def load_gateway_config() -> GatewayConfig:
bridged["channel_prompts"] = channel_prompts
if "gateway_restart_notification" in platform_cfg:
bridged["gateway_restart_notification"] = platform_cfg["gateway_restart_notification"]
if "typing_indicator" in platform_cfg:
bridged["typing_indicator"] = platform_cfg["typing_indicator"]
enabled_was_explicit = _cfg_toplevel and "enabled" in platform_cfg
if not bridged and not enabled_was_explicit:
continue
+13 -18
View File
@@ -4562,26 +4562,21 @@ class BasePlatformAdapter(ABC):
interrupt_event = self._active_sessions.get(session_key) or asyncio.Event()
self._active_sessions[session_key] = interrupt_event
# Start continuous typing indicator (refreshes every 2 seconds).
# Gated per-platform: when typing_indicator=False the refresh loop is
# never spawned, so no "typing…" / "is thinking…" status is shown.
# typing_task stays None; _stop_typing_refresh already no-ops on None.
# Start continuous typing indicator (refreshes every 2 seconds)
_thread_metadata = _thread_metadata_for_source(event.source, _reply_anchor_for_event(event))
typing_task: Optional[asyncio.Task] = None
if getattr(self.config, "typing_indicator", True):
_keep_typing_kwargs: Dict[str, Any] = {"metadata": _thread_metadata}
try:
_keep_typing_sig = inspect.signature(self._keep_typing)
except (TypeError, ValueError):
_keep_typing_sig = None
if _keep_typing_sig is None or "stop_event" in _keep_typing_sig.parameters:
_keep_typing_kwargs["stop_event"] = interrupt_event
typing_task = asyncio.create_task(
self._keep_typing(
event.source.chat_id,
**_keep_typing_kwargs,
)
_keep_typing_kwargs = {"metadata": _thread_metadata}
try:
_keep_typing_sig = inspect.signature(self._keep_typing)
except (TypeError, ValueError):
_keep_typing_sig = None
if _keep_typing_sig is None or "stop_event" in _keep_typing_sig.parameters:
_keep_typing_kwargs["stop_event"] = interrupt_event
typing_task = asyncio.create_task(
self._keep_typing(
event.source.chat_id,
**_keep_typing_kwargs,
)
)
async def _stop_typing_task() -> None:
await self._stop_typing_refresh(
+2 -27
View File
@@ -15385,14 +15385,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception:
pass
# Apply friendly tool labels config (default on) — per-platform aware
try:
from agent.display import set_friendly_tool_labels
_ftl = resolve_display_setting(user_config, platform_key, "friendly_tool_labels", True)
set_friendly_tool_labels(bool(_ftl))
except Exception:
pass
# Tool progress mode — resolved per-platform with env var fallback
_resolved_tp = resolve_display_setting(user_config, platform_key, "tool_progress")
_env_tp = os.getenv("HERMES_TOOL_PROGRESS_MODE")
@@ -15686,29 +15678,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
msg = _code_block_short
last_was_terminal_block[0] = True
elif preview:
from agent.display import (
get_tool_preview_max_len,
get_tool_verb,
tool_verb_connector,
verb_drops_preview,
)
from agent.display import get_tool_preview_max_len
_pl = get_tool_preview_max_len()
_cap = _pl if _pl > 0 else 40
if len(preview) > _cap:
preview = preview[:_cap - 3] + "..."
# Friendly labels: render a human-phrased line for built-in
# tools ("🔍 Searching the web for ...") by prefixing the verb
# onto the preview the callback already computed (so the
# command/url/query is preserved). Custom/plugin/MCP tools
# have no verb and fall back to the raw "tool_name: ..." form.
_verb = get_tool_verb(tool_name)
if _verb:
if verb_drops_preview(tool_name):
msg = f"{emoji} {_verb}"
else:
msg = f"{emoji} {_verb}{tool_verb_connector(tool_name)}{preview}"
else:
msg = f"{emoji} {tool_name}: \"{preview}\""
msg = f"{emoji} {tool_name}: \"{preview}\""
last_was_terminal_block[0] = False
else:
msg = f"{emoji} {tool_name}..."
-50
View File
@@ -3455,47 +3455,6 @@ class GatewaySlashCommandsMixin:
lines.append("Complete your top-up in the browser — credits will appear in /credits shortly.")
return "\n".join(lines)
def _context_breakdown_lines(self, agent, source) -> list[str]:
"""Render the per-category context breakdown for /usage.
Estimated (chars/4) — same engine the desktop popover uses. Returns an
empty list and never raises on failure so /usage stays robust.
"""
try:
from agent.context_breakdown import compute_session_context_breakdown
history: list[dict] = []
try:
entry = self.session_store.get_or_create_session(source)
history = self.session_store.load_transcript(entry.session_id) or []
except Exception:
history = []
payload = compute_session_context_breakdown(agent, history)
categories = payload.get("categories") or []
if not categories:
return []
total = payload.get("estimated_total") or 0
out = [t("gateway.usage.breakdown_header")]
for cat in categories:
tokens = int(cat.get("tokens") or 0)
if tokens <= 0:
continue
cat_id = str(cat.get("id") or "")
label = t(f"gateway.usage.breakdown_cat_{cat_id}")
# Missing key → t() echoes the key back; fall back to the
# English label the engine already provides.
if label.endswith(f"breakdown_cat_{cat_id}"):
label = str(cat.get("label") or cat_id)
pct = round(tokens / total * 100) if total else 0
out.append(
t("gateway.usage.breakdown_line", label=label, count=f"{tokens:,}", pct=pct)
)
return out if len(out) > 1 else []
except Exception:
return []
async def _handle_usage_command(self, event: MessageEvent) -> str:
"""Handle /usage command -- show token usage for the current session.
@@ -3595,15 +3554,6 @@ class GatewaySlashCommandsMixin:
if ctx.compression_count:
lines.append(t("gateway.usage.label_compressions", count=ctx.compression_count))
# Per-category context breakdown (estimated — chars/4 heuristic).
# Same engine the desktop popover uses (PR #54907). The system
# prompt / tools / skills / memory slices read off the live agent;
# the conversation slice is estimated from the session transcript.
breakdown_lines = self._context_breakdown_lines(agent, source)
if breakdown_lines:
lines.append("")
lines.extend(breakdown_lines)
if account_lines:
lines.append("")
lines.extend(account_lines)
-20
View File
@@ -999,21 +999,6 @@ DEFAULT_CONFIG = {
# "on" — force the prompt posture everywhere.
# "off" — disable entirely.
"coding_context": "auto",
# Standing operator instructions for the coding posture. A string (or
# list of strings) appended to the coding brief as an extra stable
# system block — pin project-wide workflow rules here instead of editing
# the shipped brief, e.g. "For UI work, don't run tsc/lint until I
# approve. Clean the diff before you commit and push." Cache-safe:
# takes effect next session. Empty by default.
"coding_instructions": "",
# When verify-on-stop finds edited code without fresh verification
# evidence, append guidance for creative UI work (avoid broad
# tsc/lint/test before visual approval) and clean-diff expectations.
# Set false to keep the evidence nudge terse.
"verify_guidance": True,
# Upper bound on consecutive `pre_verify` "continue" nudges in a single
# turn, so a user/plugin hook can never trap the loop.
"max_verify_nudges": 3,
# Verification closure: after the agent edits files in a code workspace,
# do not accept a final answer until fresh verification evidence exists
# or the agent explains why it cannot run checks. The loop is bounded
@@ -1740,11 +1725,6 @@ DEFAULT_CONFIG = {
"tool_progress_command": False, # Enable /verbose command in messaging gateway
"tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
# Human-phrased tool status labels for built-in tools: "Searching the
# web for ...", "Reading <file>", "Browsing <url>" instead of the raw
# tool name. Applies to CLI spinner + gateway/desktop tool-progress.
# Custom/plugin/MCP tools always fall back to the raw preview.
"friendly_tool_labels": True,
# How gateway tool-progress is grouped on platforms that support message
# editing: "accumulate" (default) edits one bubble in place; "separate"
# sends one message per tool (the pre-v0.9 behavior, noisier). Only
+1 -43
View File
@@ -38,36 +38,6 @@ log = logging.getLogger(__name__)
# durable start/stop intent across pod/container recreation.
_AUTOSTART_STATES = frozenset({"running"})
# Transient runtime sub-states of a RUNNING gateway. A gateway only ever
# reaches these while it is up and serving, so they are NOT an operator stop
# and NOT a failed boot:
# - `draining` — written by the drain watcher / scale-to-zero go-dormant
# path when an in-flight quiesce begins (gateway/run.py).
# - `degraded` — written when the gateway comes up with some platforms
# queued for retry, then "falls through to the normal
# running state" (gateway/run.py #5196): the process is up,
# serving cron + whatever platforms connected, and the
# reconnect watcher takes the rest from there.
#
# When a gateway is hard-killed *while in one of these states* (a container/VM
# recreate SIGTERMs it before `_stop_impl` reaches its terminal-state persist),
# the last value left in gateway_state.json is the transient sub-state. With no
# explicit `desired_state` to fall back to, treating that literal value as the
# autostart intent would leave the gateway DOWN on every subsequent boot — the
# gateway never comes back, the dashboard is up but messaging stays dark
# (observed on a relay-opted-in staging instance stranded at `draining`,
# 2026-06; `degraded` is the same wedge class). Map these transient sub-states
# to `running` so a stranded marker reads as the run-intent it actually
# represents. This mirrors gateway/run.py's #42675 handling, which persists
# `running` (not the mid-shutdown `draining`) when an unexpected signal tears
# the gateway down — extended here to the case where the gateway died before it
# could persist anything at all.
#
# `starting` / `startup_failed` are deliberately NOT included: those mean the
# gateway died mid-boot or failed to come up, so auto-restarting them would
# reintroduce the crash-loop the down-marker guard exists to prevent.
_TRANSIENT_RUNNING_STATES = frozenset({"draining", "degraded"})
# Stale runtime files we sweep before recreating service slots. These
# all hold container-namespaced state (PIDs, process tables) that's
# garbage post-restart — a numerically-equal PID in the new container
@@ -354,15 +324,6 @@ def _read_desired_state(profile_dir: Path) -> str | None:
that as a compatibility fallback so existing running/stopped profiles
preserve their behavior until the next explicit start/stop.
When falling back to ``gateway_state`` (no explicit ``desired_state``),
a transient running sub-state (``draining``) is normalised to ``running``
— see ``_TRANSIENT_RUNNING_STATES``. A gateway hard-killed mid-drain
leaves ``draining`` as its last persisted value; without this it would be
treated as a non-autostart state and the gateway would stay DOWN forever.
An explicit ``desired_state`` is always honoured verbatim (it is the
operator's durable intent), so this normalisation only affects the
legacy/transient fallback path.
Missing or unparseable files count as "no desired state" so we don't
bork the whole reconciliation on a corrupt file.
"""
@@ -374,10 +335,7 @@ def _read_desired_state(profile_dir: Path) -> str | None:
desired_state = data.get("desired_state")
if desired_state is not None:
return desired_state
gateway_state = data.get("gateway_state")
if gateway_state in _TRANSIENT_RUNNING_STATES:
return "running"
return gateway_state
return data.get("gateway_state")
except (OSError, json.JSONDecodeError):
log.warning(
"could not read %s; treating as no prior state", state_file,
-9
View File
@@ -139,15 +139,6 @@ _DEFAULT_PAYLOADS = {
"model": "gpt-4",
"platform": "cli",
},
"pre_verify": {
"session_id": "test-session",
"platform": "cli",
"model": "gpt-4",
"coding": True,
"attempt": 0,
"final_response": "All done — the change is applied.",
"changed_paths": ["src/app.tsx"],
},
"on_session_start": {"session_id": "test-session"},
"on_session_end": {"session_id": "test-session"},
"on_session_finalize": {"session_id": "test-session"},
-62
View File
@@ -136,17 +136,6 @@ VALID_HOOKS: Set[str] = {
"transform_llm_output",
"pre_llm_call",
"post_llm_call",
# Verification-loop gate. Fired once per turn when the agent has edited code
# and is about to verify/finish (after the verify-on-stop guard). A callback
# may keep the agent going — run a check, defer it, tidy the diff — instead
# of stopping by returning:
# {"action": "continue", "message": "<follow-up instruction>"}
# The Claude-Code Stop shape {"decision": "block", "reason": "..."} (block
# the stop == keep going) is accepted too. Anything else lets the turn
# finish. Hermes' shipped guidance lives in the evidence-based
# verification-stop nudge; this hook is for user/plugin policy and is
# bounded by agent.max_verify_nudges.
"pre_verify",
"pre_api_request",
"post_api_request",
"api_request_error",
@@ -2040,57 +2029,6 @@ def get_pre_tool_call_block_message(
return None
def get_pre_verify_continue_message(
*,
session_id: str = "",
platform: str = "",
model: str = "",
coding: bool = False,
attempt: int = 0,
final_response: str = "",
changed_paths: Optional[List[str]] = None,
) -> Optional[str]:
"""Check user ``pre_verify`` hooks for a directive to keep the agent going.
Fired once per turn when the agent edited code and is about to verify/finish.
A hook keeps the turn going (run a check, defer it, tidy the diff) by
returning::
{"action": "continue", "message": "<follow-up for the model>"}
The Claude-Code Stop shape ``{"decision": "block", "reason": "..."}`` (block
the stop == keep going) is accepted too. The first directive carrying a
non-empty message wins; any other return lets the turn finish. Mirrors
:func:`get_pre_tool_call_block_message` — the call site stays a one-liner.
``coding`` / ``attempt`` let a hook scope itself (``if not coding`` …) and
self-throttle (``if attempt`` …), the same way a ``pre_tool_call`` hook
scopes on ``tool_name``.
"""
hook_results = invoke_hook(
"pre_verify",
session_id=session_id,
platform=platform,
model=model,
coding=coding,
attempt=attempt,
final_response=final_response,
changed_paths=list(changed_paths or []),
)
for result in hook_results:
if not isinstance(result, dict):
continue
action = str(result.get("action") or result.get("decision") or "").strip().lower()
if action not in ("continue", "block"):
continue
message = result.get("message") or result.get("reason")
if isinstance(message, str) and message.strip():
return message.strip()
return None
def _ensure_plugins_discovered(force: bool = False) -> PluginManager:
"""Return the global manager after ensuring plugin discovery has run.
+1 -48
View File
@@ -66,7 +66,7 @@ CONFIGURABLE_TOOLSETS = [
("vision", "👁️ Vision / Image Analysis", "vision_analyze"),
("video", "🎬 Video Analysis", "video_analyze (requires video-capable model)"),
("image_gen", "🎨 Image Generation", "image_generate"),
("video_gen", "🎬 Video Generation", "video_generate (text/image/reference)"),
("video_gen", "🎬 Video Generation", "video_generate (text-to-video + image-to-video)"),
("x_search", "🐦 X (Twitter) Search", "x_search (requires xAI OAuth or XAI_API_KEY)"),
("tts", "🔊 Text-to-Speech", "text_to_speech"),
("skills", "📚 Skills", "list, view, manage"),
@@ -2785,49 +2785,6 @@ def _configure_imagegen_model_for_plugin(plugin_name: str, config: dict) -> None
_print_success(f" Model set to: {chosen}")
def _configure_xai_imagine_storage(section_name: str, config: dict) -> None:
"""Prompt for xAI Imagine stored public URL behavior."""
section = config.setdefault(section_name, {})
if not isinstance(section, dict):
section = {}
config[section_name] = section
xai_cfg = section.setdefault("xai", {})
if not isinstance(xai_cfg, dict):
xai_cfg = {}
section["xai"] = xai_cfg
storage_cfg = xai_cfg.setdefault("storage", {})
if not isinstance(storage_cfg, dict):
storage_cfg = {}
xai_cfg["storage"] = storage_cfg
_print_warning(
" xAI Imagine can store generated media and create reusable public URLs. "
"xAI may bill for stored files and public URL hosting."
)
idx = _prompt_choice(
" Stored public URLs:",
[
"Enable public URLs without automatic expiry (recommended)",
"Disable stored public URLs",
"Enable public URLs for 2 days",
],
default=0,
)
if idx == 1:
storage_cfg["enabled"] = False
_print_success(" xAI stored public URLs disabled")
elif idx == 2:
storage_cfg["enabled"] = True
storage_cfg["public_url"] = True
storage_cfg["expires_after"] = 2 * 24 * 60 * 60
_print_success(" xAI stored public URLs enabled for 2 days")
else:
storage_cfg["enabled"] = True
storage_cfg["public_url"] = True
storage_cfg["expires_after"] = None
_print_success(" xAI stored public URLs enabled without automatic expiry")
def _select_plugin_image_gen_provider(plugin_name: str, config: dict) -> None:
"""Persist a plugin-backed image generation provider selection."""
img_cfg = config.setdefault("image_gen", {})
@@ -2838,8 +2795,6 @@ def _select_plugin_image_gen_provider(plugin_name: str, config: dict) -> None:
img_cfg["use_gateway"] = False
_print_success(f" image_gen.provider set to: {plugin_name}")
_configure_imagegen_model_for_plugin(plugin_name, config)
if plugin_name == "xai":
_configure_xai_imagine_storage("image_gen", config)
# ─── Video Generation Model Pickers ───────────────────────────────────────────
@@ -2940,8 +2895,6 @@ def _select_plugin_video_gen_provider(plugin_name: str, config: dict, *, use_gat
vid_cfg["use_gateway"] = use_gateway
_print_success(f" video_gen.provider set to: {plugin_name}")
_configure_videogen_model_for_plugin(plugin_name, config)
if plugin_name == "xai":
_configure_xai_imagine_storage("video_gen", config)
def _write_provider_config(provider: dict, config: dict, *, managed_feature) -> None:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 900 KiB

-10
View File
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Koste: ingesluit"
label_context: "Konteks: {used} / {total} ({pct}%)"
label_compressions: "Saamperserings: {count}"
breakdown_header: "🧩 **Konteksverdeling** _(geskat)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "Stelselopdrag"
breakdown_cat_tool_definitions: "Nutsmiddeldefinisies"
breakdown_cat_rules: "Reëls"
breakdown_cat_skills: "Vaardighede"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Subagent-definisies"
breakdown_cat_memory: "Geheue"
breakdown_cat_conversation: "Gesprek"
header_session_info: "📊 **Sessie-inligting**"
label_messages: "Boodskappe: {count}"
label_estimated_context: "Geskatte konteks: ~{count} tokens"
-10
View File
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Kosten: inbegriffen"
label_context: "Kontext: {used} / {total} ({pct}%)"
label_compressions: "Kompressionen: {count}"
breakdown_header: "🧩 **Kontextaufschlüsselung** _(geschätzt)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "System-Prompt"
breakdown_cat_tool_definitions: "Tool-Definitionen"
breakdown_cat_rules: "Regeln"
breakdown_cat_skills: "Fähigkeiten"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Subagent-Definitionen"
breakdown_cat_memory: "Speicher"
breakdown_cat_conversation: "Konversation"
header_session_info: "📊 **Sitzungsinfo**"
label_messages: "Nachrichten: {count}"
label_estimated_context: "Geschätzter Kontext: ~{count} Tokens"
-10
View File
@@ -344,16 +344,6 @@ gateway:
label_cost_included: "Cost: included"
label_context: "Context: {used} / {total} ({pct}%)"
label_compressions: "Compressions: {count}"
breakdown_header: "🧩 **Context breakdown** _(estimated)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "System prompt"
breakdown_cat_tool_definitions: "Tool definitions"
breakdown_cat_rules: "Rules"
breakdown_cat_skills: "Skills"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Subagent definitions"
breakdown_cat_memory: "Memory"
breakdown_cat_conversation: "Conversation"
header_session_info: "📊 **Session Info**"
label_messages: "Messages: {count}"
label_estimated_context: "Estimated context: ~{count} tokens"
-10
View File
@@ -329,16 +329,6 @@ gateway:
label_cost_included: "Costo: incluido"
label_context: "Contexto: {used} / {total} ({pct}%)"
label_compressions: "Compresiones: {count}"
breakdown_header: "🧩 **Desglose del contexto** _(estimado)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "Prompt del sistema"
breakdown_cat_tool_definitions: "Definiciones de herramientas"
breakdown_cat_rules: "Reglas"
breakdown_cat_skills: "Habilidades"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Definiciones de subagentes"
breakdown_cat_memory: "Memoria"
breakdown_cat_conversation: "Conversación"
header_session_info: "📊 **Información de la sesión**"
label_messages: "Mensajes: {count}"
label_estimated_context: "Contexto estimado: ~{count} tokens"
-10
View File
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Coût : inclus"
label_context: "Contexte : {used} / {total} ({pct}%)"
label_compressions: "Compressions : {count}"
breakdown_header: "🧩 **Répartition du contexte** _(estimée)_"
breakdown_line: "• {label} : ~{count} ({pct} %)"
breakdown_cat_system_prompt: "Invite système"
breakdown_cat_tool_definitions: "Définitions des outils"
breakdown_cat_rules: "Règles"
breakdown_cat_skills: "Compétences"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Définitions des sous-agents"
breakdown_cat_memory: "Mémoire"
breakdown_cat_conversation: "Conversation"
header_session_info: "📊 **Infos de session**"
label_messages: "Messages : {count}"
label_estimated_context: "Contexte estimé : ~{count} jetons"
-10
View File
@@ -336,16 +336,6 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Costas: san áireamh"
label_context: "Comhthéacs: {used} / {total} ({pct}%)"
label_compressions: "Dlúthuithe: {count}"
breakdown_header: "🧩 **Miondealú comhthéacs** _(measta)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "Leid an chórais"
breakdown_cat_tool_definitions: "Sainmhínithe uirlisí"
breakdown_cat_rules: "Rialacha"
breakdown_cat_skills: "Scileanna"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Sainmhínithe fo-ghníomhairí"
breakdown_cat_memory: "Cuimhne"
breakdown_cat_conversation: "Comhrá"
header_session_info: "📊 **Eolas Seisiúin**"
label_messages: "Teachtaireachtaí: {count}"
label_estimated_context: "Comhthéacs measta: ~{count} comhartha"
-10
View File
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Költség: belefoglalva"
label_context: "Kontextus: {used} / {total} ({pct}%)"
label_compressions: "Tömörítések: {count}"
breakdown_header: "🧩 **Kontextus-bontás** _(becsült)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "Rendszerüzenet"
breakdown_cat_tool_definitions: "Eszközdefiníciók"
breakdown_cat_rules: "Szabályok"
breakdown_cat_skills: "Készségek"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Alügynök-definíciók"
breakdown_cat_memory: "Memória"
breakdown_cat_conversation: "Beszélgetés"
header_session_info: "📊 **Munkamenet-információ**"
label_messages: "Üzenetek: {count}"
label_estimated_context: "Becsült kontextus: ~{count} token"
-10
View File
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Costo: incluso"
label_context: "Contesto: {used} / {total} ({pct}%)"
label_compressions: "Compressioni: {count}"
breakdown_header: "🧩 **Suddivisione del contesto** _(stimata)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "Prompt di sistema"
breakdown_cat_tool_definitions: "Definizioni degli strumenti"
breakdown_cat_rules: "Regole"
breakdown_cat_skills: "Competenze"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Definizioni dei subagenti"
breakdown_cat_memory: "Memoria"
breakdown_cat_conversation: "Conversazione"
header_session_info: "📊 **Info sessione**"
label_messages: "Messaggi: {count}"
label_estimated_context: "Contesto stimato: ~{count} token"
-10
View File
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "コスト: 含まれています"
label_context: "コンテキスト: {used} / {total} ({pct}%)"
label_compressions: "圧縮回数: {count}"
breakdown_header: "🧩 **コンテキスト内訳** _(推定)_"
breakdown_line: "• {label}: 約{count} ({pct}%)"
breakdown_cat_system_prompt: "システムプロンプト"
breakdown_cat_tool_definitions: "ツール定義"
breakdown_cat_rules: "ルール"
breakdown_cat_skills: "スキル"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "サブエージェント定義"
breakdown_cat_memory: "メモリ"
breakdown_cat_conversation: "会話"
header_session_info: "📊 **セッション情報**"
label_messages: "メッセージ数: {count}"
label_estimated_context: "推定コンテキスト: ~{count} トークン"
-10
View File
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "비용: 포함됨"
label_context: "컨텍스트: {used} / {total} ({pct}%)"
label_compressions: "압축: {count}"
breakdown_header: "🧩 **컨텍스트 분석** _(추정치)_"
breakdown_line: "• {label}: 약 {count} ({pct}%)"
breakdown_cat_system_prompt: "시스템 프롬프트"
breakdown_cat_tool_definitions: "도구 정의"
breakdown_cat_rules: "규칙"
breakdown_cat_skills: "스킬"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "서브에이전트 정의"
breakdown_cat_memory: "메모리"
breakdown_cat_conversation: "대화"
header_session_info: "📊 **세션 정보**"
label_messages: "메시지: {count}"
label_estimated_context: "예상 컨텍스트: 약 {count} 토큰"
-10
View File
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Custo: incluído"
label_context: "Contexto: {used} / {total} ({pct}%)"
label_compressions: "Compressões: {count}"
breakdown_header: "🧩 **Detalhamento do contexto** _(estimado)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "Prompt do sistema"
breakdown_cat_tool_definitions: "Definições de ferramentas"
breakdown_cat_rules: "Regras"
breakdown_cat_skills: "Habilidades"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Definições de subagentes"
breakdown_cat_memory: "Memória"
breakdown_cat_conversation: "Conversa"
header_session_info: "📊 **Informações da sessão**"
label_messages: "Mensagens: {count}"
label_estimated_context: "Contexto estimado: ~{count} tokens"
-10
View File
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Стоимость: включено"
label_context: "Контекст: {used} / {total} ({pct}%)"
label_compressions: "Сжатий: {count}"
breakdown_header: "🧩 **Разбивка контекста** _(оценка)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "Системный промпт"
breakdown_cat_tool_definitions: "Определения инструментов"
breakdown_cat_rules: "Правила"
breakdown_cat_skills: "Навыки"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Определения субагентов"
breakdown_cat_memory: "Память"
breakdown_cat_conversation: "Разговор"
header_session_info: "📊 **Информация о сеансе**"
label_messages: "Сообщений: {count}"
label_estimated_context: "Ориентировочный контекст: ~{count} токенов"
-10
View File
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Maliyet: dahil"
label_context: "Bağlam: {used} / {total} ({pct}%)"
label_compressions: "Sıkıştırmalar: {count}"
breakdown_header: "🧩 **Bağlam dökümü** _(tahmini)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "Sistem istemi"
breakdown_cat_tool_definitions: "Araç tanımları"
breakdown_cat_rules: "Kurallar"
breakdown_cat_skills: "Beceriler"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Alt aracı tanımları"
breakdown_cat_memory: "Bellek"
breakdown_cat_conversation: "Konuşma"
header_session_info: "📊 **Oturum Bilgisi**"
label_messages: "Mesajlar: {count}"
label_estimated_context: "Tahmini bağlam: ~{count} token"
-10
View File
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "Вартість: включено"
label_context: "Контекст: {used} / {total} ({pct}%)"
label_compressions: "Стиснень: {count}"
breakdown_header: "🧩 **Розбивка контексту** _(оцінка)_"
breakdown_line: "• {label}: ~{count} ({pct}%)"
breakdown_cat_system_prompt: "Системний промпт"
breakdown_cat_tool_definitions: "Визначення інструментів"
breakdown_cat_rules: "Правила"
breakdown_cat_skills: "Навички"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "Визначення субагентів"
breakdown_cat_memory: "Пам'ять"
breakdown_cat_conversation: "Розмова"
header_session_info: "📊 **Інформація про сеанс**"
label_messages: "Повідомлень: {count}"
label_estimated_context: "Орієнтовний контекст: ~{count} токенів"
-10
View File
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "費用:已包含"
label_context: "上下文:{used} / {total}({pct}%)"
label_compressions: "壓縮次數:{count}"
breakdown_header: "🧩 **上下文明細** _(估算)_"
breakdown_line: "• {label}:約 {count} ({pct}%)"
breakdown_cat_system_prompt: "系統提示詞"
breakdown_cat_tool_definitions: "工具定義"
breakdown_cat_rules: "規則"
breakdown_cat_skills: "技能"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "子代理定義"
breakdown_cat_memory: "記憶"
breakdown_cat_conversation: "對話"
header_session_info: "📊 **工作階段資訊**"
label_messages: "訊息數:{count}"
label_estimated_context: "預估上下文:~{count} 個 token"
-10
View File
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
label_cost_included: "费用:已包含"
label_context: "上下文:{used} / {total}({pct}%)"
label_compressions: "压缩次数:{count}"
breakdown_header: "🧩 **上下文明细** _(估算)_"
breakdown_line: "• {label}:约 {count} ({pct}%)"
breakdown_cat_system_prompt: "系统提示词"
breakdown_cat_tool_definitions: "工具定义"
breakdown_cat_rules: "规则"
breakdown_cat_skills: "技能"
breakdown_cat_mcp: "MCP"
breakdown_cat_subagent_definitions: "子代理定义"
breakdown_cat_memory: "记忆"
breakdown_cat_conversation: "对话"
header_session_info: "📊 **会话信息**"
label_messages: "消息数:{count}"
label_estimated_context: "估计上下文:~{count} 个令牌"
+19 -129
View File
@@ -32,17 +32,11 @@ only choice that is universally correct across self-hosted IDPs. (The ``nous``
provider verifies its *access* token because Nous Portal mints a custom JWT
access token with the dashboard claims baked in — a non-OIDC shortcut.)
Both **public** (PKCE-only) and **confidential** (PKCE + ``client_secret``)
clients are supported. A self-hoster who registers a public client configures
no secret and the token-endpoint calls authenticate with PKCE alone (the
default). A self-hoster whose IDP defaults the client to *confidential*
(Authentik and Keycloak commonly do) sets ``client_secret`` and the provider
additionally authenticates the client at the token endpoint, choosing
``client_secret_basic`` (HTTP Basic header) or ``client_secret_post`` (secret
in the form body) from the IDP's advertised
``token_endpoint_auth_methods_supported``. PKCE is sent in **both** modes —
the secret is client authentication layered on top, never a replacement for
PKCE (OAuth 2.1 / RFC 9700 keep PKCE mandatory regardless).
Public PKCE clients only. Confidential clients (with a ``client_secret``) are
not yet supported — see the ``# TODO(confidential-client)`` seam in
``complete_login`` / ``refresh_session``. Self-hosters configuring a CLI/SPA
client almost always register a public + PKCE client, which is the smaller,
simpler surface.
Configuration surfaces (env wins over config.yaml when set non-empty, so a
provisioned-but-not-populated secret can't shadow a valid config.yaml entry —
@@ -56,16 +50,11 @@ same precedence convention as the ``nous`` plugin)::
issuer: https://auth.example.com/application/o/hermes/ # required
client_id: hermes-dashboard # required
scopes: "openid profile email" # optional
# client_secret: set ONLY for a confidential client. It is a
# credential — prefer the env var / ~/.hermes/.env over config.yaml.
# Environment overrides (Docker/Fly secret injection)
HERMES_DASHBOARD_OIDC_ISSUER
HERMES_DASHBOARD_OIDC_CLIENT_ID
HERMES_DASHBOARD_OIDC_SCOPES # optional; defaults to "openid profile email"
HERMES_DASHBOARD_OIDC_CLIENT_SECRET # optional; set for a confidential client
# (the .env file is the canonical home —
# it's a secret, not a behavioural setting)
Skip reasons: when the plugin loads but can't register (missing issuer /
client_id), it writes a human-readable reason to the module-level
@@ -183,7 +172,6 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
issuer: str,
client_id: str,
scopes: str = _DEFAULT_SCOPES,
client_secret: str = "",
) -> None:
if not issuer:
raise ValueError("issuer is required")
@@ -198,10 +186,6 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
_require_https_or_loopback(self._issuer, field="issuer")
self._client_id = client_id
self._scopes = scopes.strip() or _DEFAULT_SCOPES
# An empty/whitespace secret means "public client" — strip so a
# provisioned-but-blank secret can't flip us into a broken confidential
# mode that sends an empty client_secret. Non-empty ⇒ confidential.
self._client_secret = (client_secret or "").strip()
# Discovery + JWKS are lazily resolved on first use so plugin
# registration never makes a network call (the IDP may be down at
@@ -261,16 +245,11 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
"client_id": self._client_id,
"code_verifier": code_verifier,
}
# Confidential clients additionally authenticate the client here (basic
# header or post body, per the IDP's advertised methods); public
# clients get ({}, {}) and authenticate with PKCE alone.
extra_data, extra_headers = self._token_endpoint_auth(disco)
data.update(extra_data)
# TODO(confidential-client): when client_secret support lands, add it
# here (and switch to HTTP Basic auth if the IDP's
# token_endpoint_auth_methods_supported prefers client_secret_basic).
return self._exchange(
disco["token_endpoint"],
data,
bad_request_exc=InvalidCodeError,
extra_headers=extra_headers,
disco["token_endpoint"], data, bad_request_exc=InvalidCodeError
)
def refresh_session(self, *, refresh_token: str) -> Session:
@@ -286,17 +265,12 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
# identity claims (some IDPs narrow scope on refresh otherwise).
"scope": self._scopes,
}
# Same client-authentication treatment as complete_login: confidential
# clients must authenticate on the refresh grant too, or the IDP
# rejects the rotation with invalid_client.
extra_data, extra_headers = self._token_endpoint_auth(disco)
data.update(extra_data)
# TODO(confidential-client): add client_secret here when supported.
return self._exchange(
disco["token_endpoint"],
data,
bad_request_exc=RefreshExpiredError,
previous_refresh_token=refresh_token,
extra_headers=extra_headers,
)
def verify_session(self, *, access_token: str) -> Optional[Session]:
@@ -330,23 +304,15 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
endpoint = str(disco.get("revocation_endpoint") or "").strip()
if not endpoint:
return None
data = {
"token": refresh_token,
"token_type_hint": "refresh_token",
"client_id": self._client_id,
}
headers = {"Accept": "application/json"}
# A confidential client must authenticate on revocation too (RFC 7009
# §2.1), or the IDP rejects it with invalid_client. Reuse the same
# method selection as the token endpoint; public clients add nothing.
extra_data, extra_headers = self._token_endpoint_auth(disco)
data.update(extra_data)
headers.update(extra_headers)
try:
httpx.post(
endpoint,
data=data,
headers=headers,
data={
"token": refresh_token,
"token_type_hint": "refresh_token",
"client_id": self._client_id,
},
headers={"Accept": "application/json"},
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
)
except Exception as exc: # noqa: BLE001 — best-effort
@@ -355,50 +321,6 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
# ---- internals: token exchange ----------------------------------------
def _token_endpoint_auth(
self, disco: Dict[str, Any]
) -> tuple[Dict[str, str], Dict[str, str]]:
"""Return ``(extra_data, extra_headers)`` for token-endpoint client auth.
Public client (no ``client_secret`` configured): returns ``({}, {})`` —
the exchange authenticates with PKCE alone, exactly as before this
method existed.
Confidential client (``client_secret`` set): authenticates the client
per RFC 6749 §2.3.1, choosing the method from the IDP's advertised
``token_endpoint_auth_methods_supported``:
* ``client_secret_post`` advertised (and ``client_secret_basic`` not)
→ secret in the form body.
* otherwise → HTTP Basic ``Authorization`` header (the OIDC default;
also the fallback when the IDP advertises nothing).
PKCE's ``code_verifier`` is sent regardless by the callers — the secret
is layered on top, never a replacement.
"""
if not self._client_secret:
return {}, {}
methods = disco.get("token_endpoint_auth_methods_supported") or []
prefer_post = (
"client_secret_post" in methods
and "client_secret_basic" not in methods
)
if prefer_post:
# Secret travels in the application/x-www-form-urlencoded body.
return {"client_secret": self._client_secret}, {}
# HTTP Basic: base64(urlencode(client_id) ":" urlencode(secret)).
# Both halves must be form-url-encoded *before* base64 per RFC 6749
# §2.3.1, or a secret containing ':' / reserved chars corrupts the
# header.
userpass = (
f"{urllib.parse.quote(self._client_id, safe='')}:"
f"{urllib.parse.quote(self._client_secret, safe='')}"
)
encoded = base64.b64encode(userpass.encode("utf-8")).decode("ascii")
return {}, {"Authorization": f"Basic {encoded}"}
def _exchange(
self,
token_endpoint: str,
@@ -406,7 +328,6 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
*,
bad_request_exc: type[Exception],
previous_refresh_token: str = "",
extra_headers: Optional[Dict[str, str]] = None,
) -> Session:
"""POST the token endpoint and turn the response into a Session.
@@ -414,20 +335,12 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
(refresh grant). ``bad_request_exc`` is raised on a 400 —
``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError``
for the refresh path — preserving the middleware's distinct handling.
``extra_headers`` carries the confidential-client ``Authorization``
header when one applies (see ``_token_endpoint_auth``); it is empty for
a public client, so the request is byte-identical to the pre-
confidential-client behaviour in that case.
"""
headers = {"Accept": "application/json"}
if extra_headers:
headers.update(extra_headers)
try:
response = httpx.post(
token_endpoint,
data=data,
headers=headers,
headers={"Accept": "application/json"},
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
)
except httpx.RequestError as exc:
@@ -570,24 +483,12 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
payload.get("revocation_endpoint", "") or ""
).strip()
# Client-authentication methods the IDP advertises for the token
# endpoint. Used to pick client_secret_basic vs client_secret_post for
# a confidential client (see ``_token_endpoint_auth``). Absent/garbage
# → empty list → we fall back to the OIDC default (basic).
auth_methods_raw = payload.get("token_endpoint_auth_methods_supported")
token_endpoint_auth_methods = (
[str(m) for m in auth_methods_raw]
if isinstance(auth_methods_raw, list)
else []
)
return {
"issuer": advertised_issuer or self._issuer,
"authorization_endpoint": authorization_endpoint,
"token_endpoint": token_endpoint,
"jwks_uri": jwks_uri,
"revocation_endpoint": revocation_endpoint,
"token_endpoint_auth_methods_supported": token_endpoint_auth_methods,
}
# ---- internals: JWT verification --------------------------------------
@@ -812,12 +713,6 @@ def register(ctx) -> None:
_resolve_setting("HERMES_DASHBOARD_OIDC_SCOPES", oidc_cfg.get("scopes"))
or _DEFAULT_SCOPES
)
# Optional — set only for a confidential client. A credential, so the
# canonical home is the env var / ~/.hermes/.env; config.yaml is supported
# for precedence symmetry. Empty ⇒ public client (unchanged behaviour).
client_secret = _resolve_setting(
"HERMES_DASHBOARD_OIDC_CLIENT_SECRET", oidc_cfg.get("client_secret")
)
if not issuer or not client_id:
LAST_SKIP_REASON = (
@@ -834,10 +729,7 @@ def register(ctx) -> None:
try:
provider = SelfHostedOIDCProvider(
issuer=issuer,
client_id=client_id,
scopes=scopes,
client_secret=client_secret,
issuer=issuer, client_id=client_id, scopes=scopes
)
except (ValueError, ProviderError) as exc:
LAST_SKIP_REASON = (
@@ -849,10 +741,8 @@ def register(ctx) -> None:
ctx.register_dashboard_auth_provider(provider)
logger.info(
"dashboard-auth-self-hosted: registered provider "
"(issuer=%s, client_id=%s, scopes=%r, confidential=%s)",
"(issuer=%s, client_id=%s, scopes=%r)",
issuer,
client_id,
scopes,
# Log only whether a secret is present, never the secret itself.
bool(client_secret),
)
+24 -99
View File
@@ -19,7 +19,6 @@ from __future__ import annotations
import logging
import os
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import requests
@@ -34,14 +33,7 @@ from agent.image_gen_provider import (
save_url_image,
success_response,
)
from tools.xai_http import (
build_xai_storage_options,
hermes_xai_user_agent,
maybe_mark_xai_storage_notice_seen,
read_xai_imagine_storage_config,
resolve_xai_http_credentials,
xai_storage_notice_text,
)
from tools.xai_http import hermes_xai_user_agent, resolve_xai_http_credentials
logger = logging.getLogger(__name__)
@@ -126,8 +118,10 @@ def _resolve_resolution() -> str:
def _xai_image_field(source: str) -> Dict[str, str]:
"""Build the xAI ``image`` field for an edit request.
xAI's ``/v1/images/edits`` accepts a public HTTPS URL or a base64 data URI.
Local file paths are read and encoded into a ``data:`` URI.
xAI's ``/v1/images/edits`` accepts ``{"url": <ref>, "type": "image_url"}``
where ``<ref>`` is a public URL or a base64 data URI. Public URLs and
existing data URIs pass through unchanged; local file paths are read and
encoded into a ``data:`` URI.
"""
source = source.strip()
lower = source.lower()
@@ -137,7 +131,7 @@ def _xai_image_field(source: str) -> Dict[str, str]:
import base64
import os as _os
with open(_os.path.expanduser(source), "rb") as fh: # windows-footgun: ok
with open(source, "rb") as fh:
raw = fh.read()
ext = (_os.path.splitext(source)[1].lstrip(".") or "png").lower()
if ext == "jpg":
@@ -182,29 +176,19 @@ class XAIImageGenProvider(ImageGenProvider):
# hook (``hermes_cli/tools_config.py``); identical to the TTS / video
# gen entries so users see the same OAuth-or-API-key choice for every
# xAI service.
storage_notice = xai_storage_notice_text("image_gen")
tag = (
"grok-imagine-image - text-to-image & image editing; uses xAI "
"Grok OAuth or XAI_API_KEY"
)
if storage_notice:
tag += f". {storage_notice}"
return {
"name": "xAI Grok Imagine (image)",
"badge": "paid",
"tag": tag,
"tag": "grok-imagine-image — text-to-image & image editing; uses xAI Grok OAuth or XAI_API_KEY",
"env_vars": [],
"post_setup": "xai_grok",
}
def capabilities(self) -> Dict[str, Any]:
# xAI's /v1/images/edits supports image editing via grok-imagine-image
# -quality, including up to 3 total source images.
return {
"modalities": ["text", "image"],
"max_reference_images": 2,
"max_source_images": 3,
}
# -quality. Single primary source image (multi-image editing exists as
# a separate capability but we keep the primary edit surface here).
return {"modalities": ["text", "image"], "max_reference_images": 1}
def generate(
self,
@@ -240,39 +224,16 @@ class XAIImageGenProvider(ImageGenProvider):
resolution = _resolve_resolution()
xai_res = resolution if resolution in _XAI_RESOLUTIONS else DEFAULT_RESOLUTION
source_images: List[str] = []
# Pick the primary source image: explicit image_url wins, else the
# first reference image.
source_image = None
if isinstance(image_url, str) and image_url.strip():
source_images.append(image_url.strip())
refs = normalize_reference_images(reference_image_urls)
if refs:
source_images.extend(refs)
if len(source_images) > 3:
return error_response(
error="xAI image editing supports at most 3 source images",
error_type="too_many_references",
provider=provider_name,
model="grok-imagine-image-quality",
prompt=prompt,
aspect_ratio=aspect,
)
for index, source in enumerate(source_images):
field = "image_url" if index == 0 and image_url and image_url.strip() == source else "reference_image_urls"
lower = source.lower()
if not lower.startswith(("http://", "https://", "data:")):
path = Path(source).expanduser()
if not path.is_file():
return error_response(
error=(
f"{field} must be a public HTTPS URL or data URI "
"(e.g. the `image`/`public_url` from a prior Imagine result)"
),
error_type="invalid_image_url",
provider=provider_name,
model="grok-imagine-image-quality",
prompt=prompt,
aspect_ratio=aspect,
)
is_edit = bool(source_images)
source_image = image_url.strip()
else:
refs = normalize_reference_images(reference_image_urls)
if refs:
source_image = refs[0]
is_edit = bool(source_image)
modality = "image" if is_edit else "text"
headers = {
@@ -282,13 +243,6 @@ class XAIImageGenProvider(ImageGenProvider):
}
base_url = str(creds.get("base_url") or "https://api.x.ai/v1").strip().rstrip("/")
storage_options = build_xai_storage_options(
"image_gen",
filename_prefix="hermes-xai-image",
extension="png",
)
storage_notice = maybe_mark_xai_storage_notice_seen("image_gen")
storage_cfg = read_xai_imagine_storage_config("image_gen")
if is_edit:
# Editing requires the quality model per xAI docs. The source
@@ -296,7 +250,7 @@ class XAIImageGenProvider(ImageGenProvider):
# are converted to a data URI here.
edit_model = "grok-imagine-image-quality"
try:
image_fields = [_xai_image_field(source) for source in source_images]
image_field = _xai_image_field(source_image)
except Exception as exc:
return error_response(
error=f"Could not load source image for editing: {exc}",
@@ -309,11 +263,8 @@ class XAIImageGenProvider(ImageGenProvider):
payload: Dict[str, Any] = {
"model": edit_model,
"prompt": prompt,
"image": image_field,
}
if len(image_fields) == 1:
payload["image"] = image_fields[0]
else:
payload["images"] = image_fields
endpoint_url = f"{base_url}/images/edits"
model_id = edit_model
else:
@@ -324,8 +275,6 @@ class XAIImageGenProvider(ImageGenProvider):
"resolution": xai_res,
}
endpoint_url = f"{base_url}/images/generations"
if storage_options is not None:
payload["storage_options"] = storage_options
try:
response = requests.post(
@@ -382,8 +331,7 @@ class XAIImageGenProvider(ImageGenProvider):
aspect_ratio=aspect,
)
# Parse response - xAI returns data[0].b64_json, data[0].url, and
# optionally data[0].file_output when storage_options were requested.
# Parse response — xAI returns data[0].b64_json or data[0].url
data = result.get("data", [])
if not data:
return error_response(
@@ -398,13 +346,8 @@ class XAIImageGenProvider(ImageGenProvider):
first = data[0]
b64 = first.get("b64_json")
url = first.get("url")
file_output = first.get("file_output") if isinstance(first, dict) else None
file_output = file_output if isinstance(file_output, dict) else {}
public_url = file_output.get("public_url") if isinstance(file_output.get("public_url"), str) else None
if public_url:
image_ref = public_url
elif b64:
if b64:
try:
saved_path = save_b64_image(b64, prefix=f"xai_{model_id}")
except Exception as exc:
@@ -446,27 +389,9 @@ class XAIImageGenProvider(ImageGenProvider):
aspect_ratio=aspect,
)
extra: Dict[str, Any] = {
"storage_enabled": bool(storage_cfg["enabled"]),
}
extra: Dict[str, Any] = {}
if not is_edit:
extra["resolution"] = xai_res
if storage_notice:
extra["storage_notice"] = storage_notice
if public_url:
extra["public_url"] = public_url
if file_output:
for key in (
"filename",
"expires_at",
"public_url_expires_at",
"public_url_error",
"storage_error",
):
if key in file_output:
extra[key] = file_output[key]
if result.get("usage"):
extra["usage"] = result["usage"]
return success_response(
image=image_ref,
-65
View File
@@ -874,70 +874,6 @@ class SlackAdapter(BasePlatformAdapter):
except Exception: # pragma: no cover - diagnostics must never break connect
pass
def _warn_if_not_bot_token(self, auth_response, team_name: str) -> None:
"""Warn when the configured token authenticates as a human, not a bot.
``auth.test`` returns the ``user_id`` of *whatever principal owns the
token*. For a real bot token (``xoxb-…``) that is the app's bot user
and the response carries a ``bot_id``. For a **user** token
(``xoxp-…`` / a legacy/personal OAuth token) it is the *installing
human's* member ID and there is **no** ``bot_id``.
When that happens, ``self._bot_user_id`` becomes a human's member ID,
and every "is this the bot?" check downstream misfires: that one
person's ``<@…>`` mentions wake the bot (``is_mentioned`` in
``_handle_slack_message``) and get stripped as if they were the bot's
own mention — so the agent is genuinely told it was @mentioned and
replies to messages merely *addressed to that human*. There is no
runtime API error to catch; the only detectable moment is here at
connect time, by noticing ``bot_id`` is absent from ``auth.test``.
Warning-only: a user token can still send/receive, and we don't want
to hard-fail a working-but-misconfigured install on connect. We log
exactly what is wrong and how to fix it, once per workspace per
process.
"""
try:
warned = getattr(self, "_user_token_warned", None)
if warned is None:
warned = set()
self._user_token_warned = warned
team_key = team_name or ""
if team_key in warned:
return
# ``auth.test`` includes ``bot_id`` only for bot tokens. Its
# absence (with a resolved user_id) means a user/legacy token.
bot_id = ""
user_id = ""
try:
bot_id = auth_response.get("bot_id", "") or ""
user_id = auth_response.get("user_id", "") or ""
except Exception:
# Some response shapes are attribute-only; fall back to .data.
data = getattr(auth_response, "data", None) or {}
bot_id = data.get("bot_id", "") or ""
user_id = data.get("user_id", "") or ""
if not user_id:
return # Nothing resolved — don't guess.
if not bot_id:
warned.add(team_key)
logger.warning(
"[Slack] The configured Slack token for workspace %s "
"authenticated as a USER (member %s), not a bot — the "
"auth.test response has no 'bot_id'. This is almost "
"certainly a user token (xoxp-...) instead of a Bot User "
"OAuth Token (xoxb-...). The bot's identity is now bound "
"to that member's ID, so mentions OF THAT PERSON will be "
"misrouted as mentions of the bot (the bot replies to "
"messages merely addressed to them). Use the 'Bot User "
"OAuth Token' (xoxb-...) from your Slack app's 'OAuth & "
"Permissions' page in SLACK_BOT_TOKEN.",
team_key or "this workspace",
user_id,
)
except Exception: # pragma: no cover - diagnostics must never break connect
pass
async def connect(self, *, is_reconnect: bool = False) -> bool:
"""Connect to Slack via Socket Mode."""
if not SLACK_AVAILABLE:
@@ -1067,7 +1003,6 @@ class SlackAdapter(BasePlatformAdapter):
)
self._warn_if_missing_group_dm_scopes(auth_response, team_name)
self._warn_if_not_bot_token(auth_response, team_name)
# Register message event handler
@self._app.event("message")
+196 -597
View File
@@ -1,7 +1,10 @@
"""xAI Grok-Imagine video generation backend.
Surface: text-to-video, image-to-video, and reference-to-video through the
unified video provider. xAI edit/extend are exposed through separate tools.
Surface: text-to-video and image-to-video (animate an input image)
through xAI's ``/videos/generations`` endpoint. Edit and extend are not
exposed in this unified surface — xAI is the only backend that supports
them and the inconsistency would force per-backend prose in the agent's
tool description.
Originally salvaged from PR #10600 by @Jaaneek; reshaped into the
:class:`VideoGenProvider` plugin interface and trimmed to the
@@ -11,9 +14,8 @@ Authentication: xAI Grok OAuth tokens (preferred — billed against the
user's SuperGrok or X Premium+ subscription) or ``XAI_API_KEY``. Both routes are
resolved through ``tools.xai_http.resolve_xai_http_credentials`` so a
single login covers chat + TTS + image gen + video gen + transcription.
When xAI storage is enabled, the primary ``video`` / ``public_url`` fields are the
stored files-cdn HTTPS link. Pass that public MP4 URL as ``video_url`` for
edit/extend; it is sent to xAI as ``video.url``.
Output is an HTTPS URL from xAI's CDN; the gateway downloads and
delivers it.
"""
from __future__ import annotations
@@ -44,14 +46,13 @@ logger = logging.getLogger(__name__)
DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
DEFAULT_TEXT_TO_VIDEO_MODEL = "grok-imagine-video"
DEFAULT_IMAGE_TO_VIDEO_MODEL = "grok-imagine-video-1.5"
DEFAULT_IMAGE_TO_VIDEO_MODEL = "grok-imagine-video-1.5-preview"
DEFAULT_MODEL = DEFAULT_TEXT_TO_VIDEO_MODEL
DEFAULT_DURATION = 8
DEFAULT_ASPECT_RATIO = "16:9"
DEFAULT_RESOLUTION = "720p"
DEFAULT_TIMEOUT_SECONDS = 240
DEFAULT_POLL_INTERVAL_SECONDS = 5
DEFAULT_EXTEND_DURATION = 6
VALID_ASPECT_RATIOS = {"1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"}
VALID_RESOLUTIONS = {"480p", "720p"}
@@ -66,20 +67,16 @@ _MODELS: Dict[str, Dict[str, Any]] = {
"price": "see https://docs.x.ai/developers/models/grok-imagine-video",
"modalities": ["text", "image"],
},
"grok-imagine-video-1.5": {
"display": "Grok Imagine Video 1.5",
"grok-imagine-video-1.5-preview": {
"display": "Grok Imagine Video 1.5 Preview",
"speed": "~60-240s",
"strengths": "Latest xAI image-to-video model.",
"price": "see https://docs.x.ai/developers/pricing",
"price": "see https://docs.x.ai/developers/models/grok-imagine-video-1.5-preview",
"modalities": ["image"],
"aliases": ["grok-imagine-video-1.5-2026-05-30"],
},
}
_IMAGE_TO_VIDEO_COMPAT_MODEL_IDS = {
"grok-imagine-video-1.5-preview",
"grok-imagine-video-1.5-2026-05-30",
}
# ---------------------------------------------------------------------------
# HTTP helpers
@@ -148,114 +145,21 @@ def _image_ref_to_xai_url(value: str) -> str:
return f"data:{mime};base64,{encoded}"
def _image_ref_to_xai_input(value: str) -> Optional[Dict[str, str]]:
ref = _image_ref_to_xai_url(value)
if not ref:
return None
lower = ref.lower()
if lower.startswith(("http://", "https://", "data:image/")):
return {"url": ref}
return None
def _xai_video_output_urls(
video: Dict[str, Any],
) -> Tuple[str, Optional[str], Optional[str]]:
"""Return ``(public_video_url, temporary_url, stored_public_url)``.
``public_video_url`` is the stored files-cdn HTTPS MP4 (``public_url``) when
storage is enabled; otherwise xAI's temporary ``video.url``. Pass this value
as ``video_url`` for edit/extend chaining.
"""
file_output = video.get("file_output") if isinstance(video.get("file_output"), dict) else {}
file_output = file_output or {}
stored_public = file_output.get("public_url")
stored_public = stored_public.strip() if isinstance(stored_public, str) else None
temporary = video.get("url")
temporary = temporary.strip() if isinstance(temporary, str) else None
public_video_url = stored_public or temporary or ""
temporary_out = (
temporary
if temporary and stored_public and temporary != stored_public
else None
)
return public_video_url, temporary_out, stored_public
def _video_ref_to_xai_url(value: str) -> str:
"""Return a URL/data URI accepted by xAI for video inputs."""
ref = (value or "").strip()
if not ref:
return ""
lower = ref.lower()
if lower.startswith(("http://", "https://", "data:video/")):
return ref
path = Path(ref).expanduser()
if not path.is_file():
return ref
mime = mimetypes.guess_type(path.name)[0] or "video/mp4"
if not mime.startswith("video/"):
return ref
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:{mime};base64,{encoded}"
async def _video_input_from_public_url(
value: str,
*,
api_key: str,
base_url: str,
) -> Optional[Dict[str, str]]:
"""Build xAI ``video`` input using a public HTTPS URL (``url`` field only)."""
ref = (value or "").strip()
if not ref:
return None
path = Path(ref).expanduser()
if path.is_file():
data_ref = _video_ref_to_xai_url(ref)
return {"url": data_ref} if data_ref else None
lower = ref.lower()
if not lower.startswith(("http://", "https://")):
return None
return {"url": ref}
def _normalize_reference_images(
reference_image_urls: Optional[List[str]],
) -> Tuple[Optional[List[Dict[str, str]]], Optional[str]]:
refs: List[Dict[str, str]] = []
def _normalize_reference_images(reference_image_urls: Optional[List[str]]):
refs = []
for url in reference_image_urls or []:
cleaned = (url or "").strip()
if not cleaned:
continue
normalized = _image_ref_to_xai_input(cleaned)
if not normalized:
return None, (
"reference_image_urls must be public HTTPS URLs or data URIs "
"(e.g. the `image`/`public_url` from a prior Imagine result)"
)
refs.append(normalized)
return (refs if refs else None), None
normalized = _image_ref_to_xai_url(url)
if normalized:
refs.append({"url": normalized})
return refs or None
def _clamp_duration(
duration: Optional[int],
*,
has_reference_images: bool = False,
max_seconds: int = 15,
default: int = DEFAULT_DURATION,
) -> int:
value = duration if duration is not None else default
def _clamp_duration(duration: Optional[int], has_reference_images: bool) -> int:
value = duration if duration is not None else DEFAULT_DURATION
if value < 1:
value = 1
if value > max_seconds:
value = max_seconds
if value > 15:
value = 15
if has_reference_images and value > 10:
value = 10
return value
@@ -269,7 +173,7 @@ def _resolve_model_for_modality(
) -> str:
"""Select xAI's text/video model without treating config as a prompt override.
``grok-imagine-video-1.5`` currently rejects text-only video
``grok-imagine-video-1.5-preview`` currently rejects text-only video
generation, but it is the desired image-to-video backend. Explicit tool
``model=`` still wins for users who intentionally request another model.
"""
@@ -278,7 +182,7 @@ def _resolve_model_for_modality(
return requested
if modality == "image":
return DEFAULT_IMAGE_TO_VIDEO_MODEL
if requested == DEFAULT_IMAGE_TO_VIDEO_MODEL or requested in _IMAGE_TO_VIDEO_COMPAT_MODEL_IDS:
if requested == DEFAULT_IMAGE_TO_VIDEO_MODEL:
return DEFAULT_TEXT_TO_VIDEO_MODEL
return requested or DEFAULT_TEXT_TO_VIDEO_MODEL
@@ -289,11 +193,11 @@ async def _submit(
*,
api_key: str,
base_url: str,
endpoint: str = "generations",
) -> str:
"""POST to one of xAI's async video endpoints and return request_id."""
"""POST to /videos/generations — xAI's only public endpoint for our
text-to-video and image-to-video surface."""
response = await client.post(
f"{base_url}/videos/{endpoint}",
f"{base_url}/videos/generations",
headers={**_xai_headers(api_key), "x-idempotency-key": str(uuid.uuid4())},
json=payload,
timeout=60,
@@ -344,7 +248,7 @@ async def _poll(
class XAIVideoGenProvider(VideoGenProvider):
"""xAI Grok Imagine video backend."""
"""xAI Grok Imagine video backend (text-to-video + image-to-video)."""
@property
def name(self) -> str:
@@ -371,25 +275,10 @@ class XAIVideoGenProvider(VideoGenProvider):
# Grok OAuth (SuperGrok / Premium+) — TTS / image gen / video gen
# all share the same credential resolver. The hook offers an
# OAuth-vs-API-key choice when neither is configured.
try:
from tools.xai_http import xai_storage_notice_text
storage_notice = xai_storage_notice_text("video_gen")
except Exception:
storage_notice = ""
tag = (
"grok-imagine-video for text/reference; "
"grok-imagine-video-1.5 for image-to-video; "
"edit/extend: pass the stored public HTTPS MP4 (`video` / "
"`public_url` from a prior Imagine result); uses xAI Grok OAuth "
"or XAI_API_KEY"
)
if storage_notice:
tag += f". {storage_notice}"
return {
"name": "xAI Grok Imagine",
"badge": "paid",
"tag": tag,
"tag": "grok-imagine-video for text-to-video; grok-imagine-video-1.5-preview for image-to-video; uses xAI Grok OAuth or XAI_API_KEY",
"env_vars": [],
"post_setup": "xai_grok",
}
@@ -421,479 +310,189 @@ class XAIVideoGenProvider(VideoGenProvider):
seed: Optional[int] = None,
**kwargs: Any,
) -> Dict[str, Any]:
return run_xai_video_generation(
prompt=prompt,
model=model,
explicit_model=bool(kwargs.get("_model_override_explicit")),
image_url=image_url,
reference_image_urls=reference_image_urls,
duration=duration,
aspect_ratio=aspect_ratio,
resolution=resolution,
)
def has_xai_video_credentials() -> bool:
api_key, _ = _resolve_xai_credentials()
return bool(api_key)
def run_xai_video_generation(
*,
prompt: str,
model: Optional[str],
explicit_model: bool,
image_url: Optional[str],
reference_image_urls: Optional[List[str]],
duration: Optional[int],
aspect_ratio: str,
resolution: str,
) -> Dict[str, Any]:
return _run_xai_video_coroutine(
_generate_xai_video_async(
prompt=prompt,
model=model,
explicit_model=explicit_model,
image_url=image_url,
reference_image_urls=reference_image_urls,
duration=duration,
aspect_ratio=aspect_ratio,
resolution=resolution,
),
operation_label="generation",
model=model,
prompt=prompt,
aspect_ratio=aspect_ratio,
)
def run_xai_video_edit(
*,
prompt: str,
video_url: str,
model: Optional[str] = None,
) -> Dict[str, Any]:
return _run_xai_video_coroutine(
_edit_xai_video_async(prompt=prompt, video_url=video_url, model=model),
operation_label="edit",
model=model,
prompt=prompt,
aspect_ratio=DEFAULT_ASPECT_RATIO,
)
def run_xai_video_extend(
*,
prompt: str,
video_url: str,
duration: Optional[int] = None,
model: Optional[str] = None,
) -> Dict[str, Any]:
return _run_xai_video_coroutine(
_extend_xai_video_async(
prompt=prompt,
video_url=video_url,
duration=duration,
model=model,
),
operation_label="extend",
model=model,
prompt=prompt,
aspect_ratio=DEFAULT_ASPECT_RATIO,
)
def _run_xai_video_coroutine(
coro,
*,
operation_label: str,
model: Optional[str],
prompt: str,
aspect_ratio: str,
) -> Dict[str, Any]:
try:
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(coro)
finally:
loop.close()
except Exception as exc:
logger.warning("xAI video %s unexpected failure: %s", operation_label, exc, exc_info=True)
return error_response(
error=f"xAI video {operation_label} failed: {exc}",
error_type="api_error",
provider="xai",
model=model or DEFAULT_MODEL,
prompt=prompt,
aspect_ratio=aspect_ratio,
)
async def _generate_xai_video_async(
*,
prompt: str,
model: Optional[str],
explicit_model: bool,
image_url: Optional[str],
reference_image_urls: Optional[List[str]],
duration: Optional[int],
aspect_ratio: str,
resolution: str,
) -> Dict[str, Any]:
api_key, base_url = _resolve_xai_credentials()
if not api_key:
return _auth_required_response(prompt)
prompt = (prompt or "").strip()
image_input = None
if (image_url or "").strip():
image_input = _image_ref_to_xai_input(image_url)
if not image_input:
return error_response(
error=(
"image_url must be a public HTTPS URL or data URI "
"(e.g. the `image`/`public_url` from a prior Imagine result)"
),
error_type="invalid_image_url",
provider="xai",
prompt=prompt,
)
normalized_aspect_ratio = (aspect_ratio or DEFAULT_ASPECT_RATIO).strip()
normalized_resolution = (resolution or DEFAULT_RESOLUTION).strip().lower()
refs, refs_error = _normalize_reference_images(reference_image_urls)
if refs_error:
return error_response(
error=refs_error,
error_type="invalid_reference_image_urls",
provider="xai",
prompt=prompt,
)
if not prompt:
return error_response(
error="prompt is required for xAI video generation",
error_type="missing_prompt",
provider="xai", prompt=prompt,
)
if refs and len(refs) > MAX_REFERENCE_IMAGES:
return error_response(
error=f"reference_image_urls supports at most {MAX_REFERENCE_IMAGES} images on xAI",
error_type="too_many_references",
provider="xai", prompt=prompt,
)
if image_input and refs:
return error_response(
error="image_url and reference_image_urls cannot be combined on xAI",
error_type="conflicting_inputs",
provider="xai", prompt=prompt,
)
if normalized_aspect_ratio not in VALID_ASPECT_RATIOS:
normalized_aspect_ratio = DEFAULT_ASPECT_RATIO
if normalized_resolution not in VALID_RESOLUTIONS:
normalized_resolution = DEFAULT_RESOLUTION
modality_used = "reference" if refs else ("image" if image_input else "text")
resolved_model = _resolve_model_for_modality(
model,
modality=modality_used,
explicit_model=explicit_model,
)
if refs and resolved_model != DEFAULT_TEXT_TO_VIDEO_MODEL:
if explicit_model:
return error_response(
error=(
"xAI reference-to-video requires "
f"{DEFAULT_TEXT_TO_VIDEO_MODEL}; got {resolved_model}"
),
error_type="unsupported_model",
provider="xai",
model=resolved_model,
prompt=prompt,
)
resolved_model = DEFAULT_TEXT_TO_VIDEO_MODEL
clamped_duration = _clamp_duration(duration, has_reference_images=bool(refs))
payload = {
"model": resolved_model,
"prompt": prompt,
"duration": clamped_duration,
"aspect_ratio": normalized_aspect_ratio,
"resolution": normalized_resolution,
}
if image_input:
payload["image"] = image_input
if refs:
payload["reference_images"] = refs
return await _submit_xai_video_payload(
api_key=api_key,
base_url=base_url,
endpoint="generations",
payload=payload,
prompt=prompt,
resolved_model=resolved_model,
modality=modality_used,
aspect_ratio=normalized_aspect_ratio,
duration=clamped_duration,
operation="generate",
resolution=normalized_resolution,
)
async def _run_xai_video_mutation(
*,
prompt: str,
video_url: str,
model: Optional[str],
endpoint: str,
operation: str,
duration: int,
) -> Dict[str, Any]:
"""Edit or extend using a public HTTPS ``video_url`` input (``url`` on the wire)."""
api_key, base_url = _resolve_xai_credentials()
if not api_key:
return _auth_required_response(prompt)
prompt = (prompt or "").strip()
video_input = await _video_input_from_public_url(
video_url or "",
api_key=api_key,
base_url=base_url,
)
if not prompt:
return error_response(
error="prompt is required for xAI video edit/extend",
error_type="missing_prompt",
provider="xai",
prompt=prompt,
)
if not video_input:
return error_response(
error=(
"video_url must be a public HTTPS MP4 URL "
"(the `video`/`public_url` from a prior Imagine result)"
),
error_type="missing_video",
provider="xai",
prompt=prompt,
)
resolved_model = _resolve_model_for_modality(
model,
modality="text",
explicit_model=bool(model),
)
payload: Dict[str, Any] = {
"model": resolved_model,
"prompt": prompt,
"video": video_input,
}
if endpoint == "extensions":
payload["duration"] = duration
return await _submit_xai_video_payload(
api_key=api_key,
base_url=base_url,
endpoint=endpoint,
payload=payload,
prompt=prompt,
resolved_model=resolved_model,
modality=operation,
aspect_ratio=DEFAULT_ASPECT_RATIO,
duration=duration,
operation=operation,
)
async def _edit_xai_video_async(
*,
prompt: str,
video_url: str,
model: Optional[str],
) -> Dict[str, Any]:
return await _run_xai_video_mutation(
prompt=prompt,
video_url=video_url,
model=model,
endpoint="edits",
operation="edit",
duration=DEFAULT_DURATION,
)
async def _extend_xai_video_async(
*,
prompt: str,
video_url: str,
duration: Optional[int],
model: Optional[str],
) -> Dict[str, Any]:
clamped_duration = _clamp_duration(
duration,
max_seconds=10,
default=DEFAULT_EXTEND_DURATION,
)
return await _run_xai_video_mutation(
prompt=prompt,
video_url=video_url,
model=model,
endpoint="extensions",
operation="extend",
duration=clamped_duration,
)
def _auth_required_response(prompt: str) -> Dict[str, Any]:
return error_response(
error=(
"No xAI credentials found. Sign in via `hermes auth add xai-oauth` "
"(SuperGrok / Premium+) or set XAI_API_KEY from "
"https://console.x.ai/."
),
error_type="auth_required",
provider="xai", prompt=prompt,
)
async def _submit_xai_video_payload(
*,
api_key: str,
base_url: str,
endpoint: str,
payload: Dict[str, Any],
prompt: str,
resolved_model: str,
modality: str,
aspect_ratio: str,
duration: int,
operation: str,
resolution: Optional[str] = None,
) -> Dict[str, Any]:
try:
from tools.xai_http import (
build_xai_storage_options,
maybe_mark_xai_storage_notice_seen,
read_xai_imagine_storage_config,
)
storage_options = build_xai_storage_options(
"video_gen",
filename_prefix="hermes-xai-video",
extension="mp4",
)
storage_notice = maybe_mark_xai_storage_notice_seen("video_gen")
storage_cfg = read_xai_imagine_storage_config("video_gen")
except Exception:
storage_options = None
storage_notice = None
storage_cfg = {"enabled": False}
if storage_options is not None:
payload["storage_options"] = storage_options
async with httpx.AsyncClient() as client:
try:
request_id = await _submit(
client, payload, api_key=api_key, base_url=base_url,
endpoint=endpoint,
)
except httpx.HTTPStatusError as exc:
detail = ""
loop = asyncio.new_event_loop()
try:
detail = exc.response.text[:500]
except Exception:
pass
return loop.run_until_complete(self._generate_async(
prompt=prompt,
model=model,
explicit_model=bool(kwargs.get("_model_override_explicit")),
image_url=image_url,
reference_image_urls=reference_image_urls,
duration=duration,
aspect_ratio=aspect_ratio,
resolution=resolution,
))
finally:
loop.close()
except Exception as exc:
logger.warning("xAI video gen unexpected failure: %s", exc, exc_info=True)
return error_response(
error=f"xAI submit failed ({exc.response.status_code}): {detail or exc}",
error=f"xAI video generation failed: {exc}",
error_type="api_error",
provider="xai",
model=model or DEFAULT_MODEL,
prompt=prompt,
aspect_ratio=aspect_ratio,
)
async def _generate_async(
self,
*,
prompt: str,
model: Optional[str],
explicit_model: bool,
image_url: Optional[str],
reference_image_urls: Optional[List[str]],
duration: Optional[int],
aspect_ratio: str,
resolution: str,
) -> Dict[str, Any]:
api_key, base_url = _resolve_xai_credentials()
if not api_key:
return error_response(
error=(
"No xAI credentials found. Sign in via `hermes auth add xai-oauth` "
"(SuperGrok / Premium+) or set XAI_API_KEY from "
"https://console.x.ai/."
),
error_type="auth_required",
provider="xai", prompt=prompt,
)
prompt = (prompt or "").strip()
image_url_norm = _image_ref_to_xai_url(image_url or "") or None
normalized_aspect_ratio = (aspect_ratio or DEFAULT_ASPECT_RATIO).strip()
normalized_resolution = (resolution or DEFAULT_RESOLUTION).strip().lower()
modality_used = "image" if image_url_norm else "text"
resolved_model = _resolve_model_for_modality(
model,
modality=modality_used,
explicit_model=explicit_model,
)
if not prompt:
return error_response(
error=(
"prompt is required for xAI video generation "
"(text-to-video or image-to-video)"
),
error_type="missing_prompt",
provider="xai", prompt=prompt,
)
refs = _normalize_reference_images(reference_image_urls)
if refs and len(refs) > MAX_REFERENCE_IMAGES:
return error_response(
error=f"reference_image_urls supports at most {MAX_REFERENCE_IMAGES} images on xAI",
error_type="too_many_references",
provider="xai", prompt=prompt,
)
if image_url_norm and refs:
return error_response(
error="image_url and reference_image_urls cannot be combined on xAI",
error_type="conflicting_inputs",
provider="xai", prompt=prompt,
)
clamped_duration = _clamp_duration(duration, has_reference_images=bool(refs))
if normalized_aspect_ratio not in VALID_ASPECT_RATIOS:
normalized_aspect_ratio = DEFAULT_ASPECT_RATIO
if normalized_resolution not in VALID_RESOLUTIONS:
normalized_resolution = DEFAULT_RESOLUTION
payload: Dict[str, Any] = {
"model": resolved_model,
"prompt": prompt,
"duration": clamped_duration,
"aspect_ratio": normalized_aspect_ratio,
"resolution": normalized_resolution,
}
if image_url_norm:
payload["image"] = {"url": image_url_norm}
if refs:
payload["reference_images"] = refs
async with httpx.AsyncClient() as client:
try:
request_id = await _submit(
client, payload, api_key=api_key, base_url=base_url
)
except httpx.HTTPStatusError as exc:
detail = ""
try:
detail = exc.response.text[:500]
except Exception:
pass
return error_response(
error=f"xAI submit failed ({exc.response.status_code}): {detail or exc}",
error_type="api_error",
provider="xai",
model=resolved_model,
prompt=prompt,
)
poll_result = await _poll(
client, request_id,
api_key=api_key, base_url=base_url,
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
poll_interval=DEFAULT_POLL_INTERVAL_SECONDS,
)
status = poll_result["status"]
body = poll_result["body"]
if status == "done":
video = body.get("video") or {}
url = video.get("url")
if not url:
return error_response(
error="xAI video generation completed without a video URL",
error_type="empty_response",
provider="xai",
model=body.get("model") or resolved_model,
prompt=prompt,
)
extra: Dict[str, Any] = {
"request_id": request_id,
"resolution": normalized_resolution,
}
if body.get("usage"):
extra["usage"] = body["usage"]
return success_response(
video=url,
model=body.get("model") or resolved_model,
prompt=prompt,
modality=modality_used,
aspect_ratio=normalized_aspect_ratio,
duration=video.get("duration") or clamped_duration,
provider="xai",
extra=extra,
)
if status == "timeout":
return error_response(
error=f"Timed out waiting for video generation after {DEFAULT_TIMEOUT_SECONDS}s",
error_type="timeout",
provider="xai",
model=resolved_model,
prompt=prompt,
)
poll_result = await _poll(
client, request_id,
api_key=api_key, base_url=base_url,
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
poll_interval=DEFAULT_POLL_INTERVAL_SECONDS,
message = (
(body.get("error", {}) or {}).get("message")
or body.get("message")
or f"xAI video generation ended with status '{status}'"
)
status = poll_result["status"]
body = poll_result["body"]
if status == "done":
video = body.get("video") or {}
if not isinstance(video, dict):
video = {}
file_output = video.get("file_output") if isinstance(video.get("file_output"), dict) else {}
file_output = file_output or {}
public_video_url, temporary_url, stored_public_url = _xai_video_output_urls(video)
if not public_video_url:
return error_response(
error="xAI video request completed without a video URL",
error_type="empty_response",
provider="xai",
model=body.get("model") or resolved_model,
prompt=prompt,
)
extra: Dict[str, Any] = {
"request_id": request_id,
"operation": operation,
"storage_enabled": bool(storage_cfg.get("enabled")),
}
if resolution:
extra["resolution"] = resolution
if storage_notice:
extra["storage_notice"] = storage_notice
if stored_public_url:
extra["public_url"] = stored_public_url
if temporary_url:
extra["temporary_url"] = temporary_url
if file_output:
for key in (
"filename",
"expires_at",
"public_url_expires_at",
"public_url_error",
"storage_error",
):
if key in file_output:
extra[key] = file_output[key]
if body.get("usage"):
extra["usage"] = body["usage"]
return success_response(
video=public_video_url,
model=body.get("model") or resolved_model,
prompt=prompt,
modality=modality,
aspect_ratio=aspect_ratio,
duration=video.get("duration") or duration,
provider="xai",
extra=extra,
)
if status == "timeout":
return error_response(
error=f"Timed out waiting for xAI video request after {DEFAULT_TIMEOUT_SECONDS}s",
error_type="timeout",
error=message,
error_type=f"xai_{status}",
provider="xai",
model=resolved_model,
prompt=prompt,
)
message = (
(body.get("error", {}) or {}).get("message")
or body.get("message")
or f"xAI video request ended with status '{status}'"
)
return error_response(
error=message,
error_type=f"xai_{status}",
provider="xai",
model=resolved_model,
prompt=prompt,
)
# ---------------------------------------------------------------------------
# Plugin entry point
+1 -1
View File
@@ -1,6 +1,6 @@
name: xai
version: 1.0.0
description: "xAI Grok Imagine video generation backend. Supports text-to-video, image-to-video, reference-to-video, video editing, video extension, and stored public URLs via the xAI async videos API."
description: "xAI Grok Imagine video generation backend. Supports text-to-video, image-to-video, and reference-image-guided generation via the xAI async videos API."
author: NousResearch
kind: backend
requires_env:
-782
View File
@@ -1,782 +0,0 @@
#!/usr/bin/env python3
"""Collect CI job/step timings from the GitHub API and generate an HTML diff report.
In CI, the script reads GITHUB_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, and
GITHUB_SHA from the environment to collect timings via the REST API.
If a baseline JSON file (ci-timings-baseline.json by default) exists, the
report includes a diff with per-job and per-step deltas, plus a gantt chart
overlaying current vs baseline bars.
Usage:
# Collect from API (CI mode):
python scripts/ci/timings_report.py
# Regenerate HTML from saved JSON (testing):
python scripts/ci/timings_report.py --from-json ci-timings.json
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from html import escape
API_BASE = "https://api.github.com"
# ---------------------------------------------------------------------------
# GitHub API helpers
# ---------------------------------------------------------------------------
def api_get(path: str, token: str, params: dict | None = None,
list_key: str | None = None) -> list | dict:
"""Authenticated GitHub API GET with automatic pagination.
For list endpoints, pass list_key to extract items from the paginated
wrapper response (e.g. list_key='jobs' for {'total_count': N, 'jobs': [...]}).
When list_key is omitted, a non-list response is returned as-is (single object).
"""
url = f"{API_BASE}{path}"
if params:
url += "?" + urllib.parse.urlencode(params)
results: list = []
while url:
req = urllib.request.Request(url, headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "ci-timings-report",
})
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
link_header = resp.headers.get("Link", "")
if list_key:
results.extend(data.get(list_key, []))
elif isinstance(data, list):
results.extend(data)
else:
return data
next_url = None
for part in link_header.split(","):
part = part.strip()
if 'rel="next"' in part:
next_url = part[part.find("<") + 1:part.find(">")]
break
url = next_url
return results
def parse_ts(ts: str | None) -> datetime | None:
if not ts:
return None
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
def dur_s(started: str | None, completed: str | None) -> float | None:
s = parse_ts(started)
e = parse_ts(completed)
if not s or not e:
return None
return (e - s).total_seconds()
# ---------------------------------------------------------------------------
# Timings collection
# ---------------------------------------------------------------------------
def _normalize_job(raw: dict) -> dict:
steps = []
for step in (raw.get("steps") or []):
steps.append({
"name": step.get("name", ""),
"number": step.get("number", 0),
"status": step.get("status", ""),
"conclusion": step.get("conclusion", ""),
"started_at": step.get("started_at"),
"completed_at": step.get("completed_at"),
"duration_s": dur_s(step.get("started_at"), step.get("completed_at")),
})
return {
"name": raw.get("name", "unknown"),
"workflow_name": raw.get("_workflow_name", ""),
"job_id": raw.get("id"),
"status": raw.get("status", ""),
"conclusion": raw.get("conclusion", ""),
"started_at": raw.get("started_at"),
"completed_at": raw.get("completed_at"),
"duration_s": dur_s(raw.get("started_at"), raw.get("completed_at")),
"html_url": raw.get("html_url", ""),
"steps": steps,
}
def collect_timings(token: str, repo: str, run_id: str, head_sha: str) -> dict:
"""Collect job/step timings from the GitHub API.
1. Get orchestrator run's direct jobs (detect, all-checks-pass, etc.).
Skip workflow-call placeholder jobs (step name starts with "Run ./.github/").
2. Find sub-workflow runs via head_sha + event=workflow_call.
3. Get each sub-workflow run's jobs with full step timing.
"""
owner, repo_name = repo.split("/")
# Orchestrator run info
run_info = api_get(f"/repos/{owner}/{repo_name}/actions/runs/{run_id}", token)
created_at = run_info.get("created_at", "")
# Orchestrator direct jobs
orch_jobs = api_get(f"/repos/{owner}/{repo_name}/actions/runs/{run_id}/jobs",
token, list_key="jobs")
direct = []
for job in orch_jobs:
steps = job.get("steps") or []
if any(s.get("name", "").startswith("Run ./.github/") for s in steps):
continue # workflow-call placeholder
if job.get("status") in ("in_progress", "queued"):
continue # skip self / unfinished
direct.append(job)
# Sub-workflow runs
sub_runs = api_get(f"/repos/{owner}/{repo_name}/actions/runs", token, params={
"head_sha": head_sha,
"event": "workflow_call",
"per_page": 100,
}, list_key="workflow_runs")
sub_runs = [r for r in sub_runs if r.get("created_at", "") >= created_at]
sub_jobs_raw = []
for sr in sub_runs:
sr_id = sr["id"]
sr_name = sr.get("name", "")
sr_jobs = api_get(f"/repos/{owner}/{repo_name}/actions/runs/{sr_id}/jobs",
token, list_key="jobs")
for j in sr_jobs:
j["_workflow_name"] = sr_name
j["_workflow_run_id"] = sr_id
sub_jobs_raw.append(j)
# Normalize + sort
all_jobs = [_normalize_job(j) for j in direct + sub_jobs_raw]
all_jobs = [j for j in all_jobs if j["status"] not in ("in_progress", "queued")]
all_jobs.sort(key=lambda j: j.get("started_at") or "")
return {
"run_id": run_id,
"head_sha": head_sha,
"created_at": created_at,
"jobs": all_jobs,
}
# ---------------------------------------------------------------------------
# Formatting helpers
# ---------------------------------------------------------------------------
def fmt_dur(seconds: float | None) -> str:
if seconds is None:
return "—"
if seconds < 60:
return f"{seconds:.1f}s"
m = int(seconds // 60)
s = seconds % 60
if s == 0:
return f"{m}m"
return f"{m}m{s:.0f}s"
def fmt_delta(current: float | None, baseline: float | None) -> tuple[str, str]:
"""Return (text, css_class) for a delta."""
if current is None or baseline is None:
return ("—", "neutral")
delta = current - baseline
if baseline == 0:
pct_str = "new" if delta > 0 else "0%"
else:
pct = (delta / baseline) * 100
pct_str = f"{pct:+.1f}%"
if abs(delta) < 1.0:
cls = "neutral"
elif delta > 0:
cls = "slower"
else:
cls = "faster"
sign = "+" if delta >= 0 else ""
return (f"{sign}{delta:.1f}s ({pct_str})", cls)
def nice_ticks(max_seconds: float, num_ticks: int = 8) -> list[int]:
if max_seconds <= 0:
return [0]
raw = max_seconds / num_ticks
for nice in [5, 10, 15, 30, 60, 120, 180, 300, 600, 900, 1800, 3600, 7200]:
if nice >= raw:
step = nice
break
else:
step = max(int(raw), 3600)
return list(range(0, int(max_seconds) + step + 1, step))
def fmt_tick(seconds: int) -> str:
if seconds < 60:
return f"{seconds}s"
m, s = divmod(seconds, 60)
if s == 0:
return f"{m}m"
return f"{m}m{s}s"
# ---------------------------------------------------------------------------
# Stats computation
# ---------------------------------------------------------------------------
def compute_stats(timings: dict, baseline: dict | None = None) -> dict:
jobs = timings.get("jobs", [])
bl_jobs = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
# Wall time
starts = [s for s in (parse_ts(j.get("started_at")) for j in jobs) if s is not None]
ends = [e for e in (parse_ts(j.get("completed_at")) for j in jobs) if e is not None]
wall = (max(ends) - min(starts)).total_seconds() if starts and ends else 0
compute = sum(j.get("duration_s") or 0 for j in jobs)
# Baseline wall/compute
bl_wall = None
bl_compute = None
if baseline:
bl_starts = [s for s in (parse_ts(j.get("started_at")) for j in baseline.get("jobs", [])) if s is not None]
bl_ends = [e for e in (parse_ts(j.get("completed_at")) for j in baseline.get("jobs", [])) if e is not None]
if bl_starts and bl_ends:
bl_wall = (max(bl_ends) - min(bl_starts)).total_seconds()
bl_compute = sum(j.get("duration_s") or 0 for j in baseline.get("jobs", []))
# Per-job deltas
faster = 0
slower = 0
unchanged = 0
no_baseline = 0
for j in jobs:
bl = bl_jobs.get(j["name"])
if not bl:
no_baseline += 1
continue
cur_d = j.get("duration_s") or 0
bl_d = bl.get("duration_s") or 0
if abs(cur_d - bl_d) < 1.0:
unchanged += 1
elif cur_d > bl_d:
slower += 1
else:
faster += 1
return {
"wall": wall,
"compute": compute,
"bl_wall": bl_wall,
"bl_compute": bl_compute,
"faster": faster,
"slower": slower,
"unchanged": unchanged,
"no_baseline": no_baseline,
"total_jobs": len(jobs),
}
# ---------------------------------------------------------------------------
# HTML generation
# ---------------------------------------------------------------------------
CSS = """
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: #0d1117; color: #e6edf3; line-height: 1.5; padding: 24px;
}
h1 { font-size: 24px; border-bottom: 1px solid #30363d; padding-bottom: 12px; margin-bottom: 8px; }
.meta { color: #8b949e; font-size: 13px; margin-bottom: 24px; }
h2 { font-size: 18px; margin: 32px 0 12px; }
/* Stats cards */
.stats { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 24px; }
.stat-card {
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
padding: 14px 18px; min-width: 140px;
}
.stat-label { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-value { font-size: 22px; font-weight: 600; margin: 4px 0; }
.stat-delta { font-size: 13px; }
.faster { color: #3fb950; }
.slower { color: #f85149; }
.neutral { color: #8b949e; }
/* Gantt */
.gantt-wrap { overflow-x: auto; }
.gantt { min-width: 700px; }
.gantt-row { display: flex; align-items: center; height: 28px; }
.gantt-label {
width: 220px; padding-right: 12px; text-align: right;
font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.gantt-track { flex: 1; position: relative; height: 100%; border-left: 1px solid #21262d; }
.gantt-bar {
position: absolute; height: 18px; border-radius: 3px;
display: flex; align-items: center; justify-content: center;
font-size: 10px; color: transparent; overflow: hidden;
transition: color 0.15s;
}
.gantt-bar:hover { color: #fff; z-index: 10; }
.gantt-bar.current { background: #1f6feb; top: 5px; z-index: 2; }
.gantt-bar.baseline {
background: transparent; border: 1px dashed #8b949e; top: 2px; height: 24px; z-index: 1;
}
.gantt-axis { display: flex; height: 20px; position: relative; border-top: 1px solid #30363d; margin-top: 4px; }
.gantt-tick { position: absolute; font-size: 10px; color: #8b949e; transform: translateX(-50%); top: 4px; }
.gantt-tick::before { content: ''; position: absolute; top: -4px; left: 50%; width: 1px; height: 4px; background: #30363d; }
.legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: #8b949e; }
.legend-swatch { display: inline-block; width: 16px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: middle; }
/* Tables */
table { border-collapse: collapse; width: 100%; font-size: 13px; margin-bottom: 16px; }
th, td { border: 1px solid #30363d; padding: 6px 10px; text-align: left; }
th { background: #161b22; font-weight: 600; position: sticky; top: 0; }
tr:hover td { background: #161b22; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.job-name { font-weight: 500; }
/* Step details */
details { margin-bottom: 8px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; }
summary { padding: 8px 12px; cursor: pointer; font-weight: 500; font-size: 14px; user-select: none; }
summary:hover { background: #21262d; }
details[open] summary { border-bottom: 1px solid #30363d; }
details table { border: none; margin: 0; }
details td, details th { font-size: 12px; }
/* Worst regressions */
.regressions { margin-bottom: 24px; }
.regressions table { font-size: 13px; }
.tag {
display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 11px; font-weight: 500;
}
.tag.slow { background: rgba(248,81,73,0.15); color: #f85149; }
.tag.fast { background: rgba(63,185,80,0.15); color: #3fb950; }
"""
def _gantt_bars(timings: dict, baseline: dict | None) -> str:
"""Render the gantt chart HTML.
Both current and baseline timelines are normalized to start at t=0
(relative to each run's earliest job start). The axis scale spans
0..max_end across both runs so bars are directly comparable.
"""
jobs = [j for j in timings.get("jobs", []) if j.get("started_at") and j.get("completed_at")]
bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
# Current run: relative offsets from earliest start
cur_starts = [s for s in (parse_ts(j.get("started_at")) for j in jobs) if s is not None]
cur_ends = [e for e in (parse_ts(j.get("completed_at")) for j in jobs) if e is not None]
if not cur_starts or not cur_ends:
return '<p style="color:#8b949e">No timing data available.</p>'
cur_t0 = min(cur_starts)
cur_max = (max(cur_ends) - cur_t0).total_seconds()
# Baseline run: relative offsets from its earliest start
bl_t0 = None
bl_max = 0.0
bl_jobs_timed = []
for bl_j in bl_map.values():
s = parse_ts(bl_j.get("started_at"))
e = parse_ts(bl_j.get("completed_at"))
if s is not None and e is not None:
bl_jobs_timed.append((bl_j, s, e))
if bl_t0 is None or s < bl_t0:
bl_t0 = s
rel_end = (e - s).total_seconds() + (s - (bl_t0 or s)).total_seconds()
if bl_t0 is not None:
bl_max = max((e - bl_t0).total_seconds() for _, _, e in bl_jobs_timed) if bl_jobs_timed else 0
total_s = max(cur_max, bl_max)
if total_s <= 0:
total_s = 1
rows = []
for j in jobs:
s = parse_ts(j.get("started_at"))
e = parse_ts(j.get("completed_at"))
if s is None or e is None:
continue
left = (s - cur_t0).total_seconds() / total_s * 100
width = max((e - s).total_seconds() / total_s * 100, 0.5) # min 0.5% for visibility
dur = j.get("duration_s") or 0
bl = bl_map.get(j["name"])
bl_bar = ""
if bl and bl_t0 is not None:
bl_s = parse_ts(bl.get("started_at"))
bl_e = parse_ts(bl.get("completed_at"))
if bl_s is not None and bl_e is not None:
bl_left = (bl_s - bl_t0).total_seconds() / total_s * 100
bl_width = max((bl_e - bl_s).total_seconds() / total_s * 100, 0.5)
bl_dur = bl.get("duration_s") or 0
bl_bar = (
f'<div class="gantt-bar baseline" '
f'style="left:{bl_left:.2f}%;width:{bl_width:.2f}%" '
f'title="baseline: {fmt_dur(bl_dur)}"></div>'
)
name_display = escape(j["name"])
if j.get("workflow_name"):
name_display = f'{escape(j["workflow_name"])} / {escape(j["name"])}'
delta_info = ""
if bl and bl.get("duration_s") is not None:
d_text, d_cls = fmt_delta(dur, bl.get("duration_s"))
delta_info = f' — {d_text}'
rows.append(
f'<div class="gantt-row">'
f'<div class="gantt-label" title="{escape(j["name"])}">{name_display}</div>'
f'<div class="gantt-track">'
f'{bl_bar}'
f'<div class="gantt-bar current" '
f'style="left:{left:.2f}%;width:{width:.2f}%" '
f'title="{escape(j["name"])}: {fmt_dur(dur)}{delta_info}"></div>'
f'</div></div>'
)
# Axis
ticks = nice_ticks(total_s)
tick_html = "".join(
f'<span class="gantt-tick" style="left:{(t / total_s * 100):.1f}%">{fmt_tick(t)}</span>'
for t in ticks
)
axis = f'<div class="gantt-axis"><div class="gantt-track">{tick_html}</div></div>'
legend = (
'<div class="legend">'
'<span><span class="legend-swatch" style="background:#1f6feb"></span>Current</span>'
)
if baseline:
legend += '<span><span class="legend-swatch" style="border:1px dashed #8b949e"></span>Baseline (main)</span>'
legend += '</div>'
return f'<div class="gantt-wrap"><div class="gantt">{"".join(rows)}{axis}</div></div>{legend}'
def _stats_cards(stats: dict) -> str:
wall_text = fmt_dur(stats["wall"])
wall_delta = ""
if stats["bl_wall"] is not None:
d, cls = fmt_delta(stats["wall"], stats["bl_wall"])
wall_delta = f'<span class="stat-delta {cls}">{d}</span>'
compute_text = fmt_dur(stats["compute"])
compute_delta = ""
if stats["bl_compute"] is not None:
d, cls = fmt_delta(stats["compute"], stats["bl_compute"])
compute_delta = f'<span class="stat-delta {cls}">{d}</span>'
cards = [
f'<div class="stat-card"><span class="stat-label">Wall Time</span>'
f'<div class="stat-value">{wall_text}</div>{wall_delta}</div>',
f'<div class="stat-card"><span class="stat-label">Total Compute</span>'
f'<div class="stat-value">{compute_text}</div>{compute_delta}</div>',
f'<div class="stat-card"><span class="stat-label">Jobs Faster</span>'
f'<div class="stat-value faster">{stats["faster"]}</div></div>',
f'<div class="stat-card"><span class="stat-label">Jobs Slower</span>'
f'<div class="stat-value slower">{stats["slower"]}</div></div>',
f'<div class="stat-card"><span class="stat-label">Unchanged</span>'
f'<div class="stat-value neutral">{stats["unchanged"]}</div></div>',
f'<div class="stat-card"><span class="stat-label">No Baseline</span>'
f'<div class="stat-value neutral">{stats["no_baseline"]}</div></div>',
]
return f'<div class="stats">{"".join(cards)}</div>'
def _job_table(timings: dict, baseline: dict | None) -> str:
bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
rows = []
for j in timings.get("jobs", []):
dur = j.get("duration_s")
bl = bl_map.get(j["name"])
bl_dur = bl.get("duration_s") if bl else None
delta_text, delta_cls = fmt_delta(dur, bl_dur)
name = escape(j["name"])
if j.get("workflow_name"):
name = f'{escape(j["workflow_name"])} / {escape(j["name"])}'
concl = j.get("conclusion", "")
concl_icon = {"success": "✓", "failure": "✗", "skipped": "⊘"}.get(concl, "?")
concl_cls = {"success": "faster", "failure": "slower", "skipped": "neutral"}.get(concl, "neutral")
rows.append(
f'<tr>'
f'<td class="job-name">{name}</td>'
f'<td class="num">{fmt_dur(dur)}</td>'
f'<td class="num">{fmt_dur(bl_dur)}</td>'
f'<td class="num {delta_cls}">{delta_text}</td>'
f'<td class="{concl_cls}" style="text-align:center">{concl_icon}</td>'
f'</tr>'
)
return (
'<table><thead><tr>'
'<th>Job</th><th class="num">Current</th><th class="num">Baseline</th>'
'<th class="num">Delta</th><th>Status</th>'
'</tr></thead><tbody>' + "".join(rows) + '</tbody></table>'
)
def _step_details(timings: dict, baseline: dict | None) -> str:
bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
blocks = []
for j in timings.get("jobs", []):
if not j.get("steps"):
continue
bl = bl_map.get(j["name"], {})
bl_steps = {s["name"]: s for s in bl.get("steps", [])}
dur = j.get("duration_s") or 0
bl_dur = bl.get("duration_s") if bl else None
delta_text, delta_cls = fmt_delta(dur, bl_dur)
summary_text = f'{escape(j["name"])} — {fmt_dur(dur)}'
if bl_dur is not None:
summary_text += f' <span class="{delta_cls}">({delta_text})</span>'
step_rows = []
for s in j["steps"]:
s_dur = s.get("duration_s")
bl_s = bl_steps.get(s["name"])
bl_s_dur = bl_s.get("duration_s") if bl_s else None
s_delta, s_cls = fmt_delta(s_dur, bl_s_dur)
step_rows.append(
f'<tr>'
f'<td>{escape(s["name"])}</td>'
f'<td class="num">{fmt_dur(s_dur)}</td>'
f'<td class="num">{fmt_dur(bl_s_dur)}</td>'
f'<td class="num {s_cls}">{s_delta}</td>'
f'</tr>'
)
blocks.append(
f'<details><summary>{summary_text}</summary>'
f'<table><thead><tr>'
'<th>Step</th><th class="num">Current</th><th class="num">Baseline</th>'
'<th class="num">Delta</th>'
f'</tr></thead><tbody>{"".join(step_rows)}</tbody></table>'
f'</details>'
)
return "".join(blocks) if blocks else '<p style="color:#8b949e">No step data available.</p>'
def _regressions(timings: dict, baseline: dict | None) -> str:
"""Show top 10 biggest absolute regressions/improvements across all steps."""
if not baseline:
return ""
bl_map = {j["name"]: j for j in baseline.get("jobs", [])}
deltas = [] # (abs_delta, job_name, step_name, current, baseline, is_slower)
for j in timings.get("jobs", []):
bl = bl_map.get(j["name"])
if not bl:
continue
bl_steps = {s["name"]: s for s in bl.get("steps", [])}
for s in j.get("steps", []):
bl_s = bl_steps.get(s["name"])
if not bl_s:
continue
cur = s.get("duration_s") or 0
bl_d = bl_s.get("duration_s") or 0
diff = cur - bl_d
if abs(diff) < 1.0:
continue
deltas.append((abs(diff), diff, j["name"], s["name"], cur, bl_d))
deltas.sort(key=lambda x: x[0], reverse=True)
top = deltas[:10]
if not top:
return ""
rows = []
for _, diff, job, step, cur, bl_d in top:
cls = "slower" if diff > 0 else "faster"
tag = f'<span class="tag {"slow" if diff > 0 else "fast"}">{"+" if diff > 0 else ""}{diff:.1f}s</span>'
rows.append(
f'<tr>'
f'<td class="job-name">{escape(job)}</td>'
f'<td>{escape(step)}</td>'
f'<td class="num">{fmt_dur(cur)}</td>'
f'<td class="num">{fmt_dur(bl_d)}</td>'
f'<td>{tag}</td>'
f'</tr>'
)
return (
'<div class="regressions">'
'<table><thead><tr>'
'<th>Job</th><th>Step</th><th class="num">Current</th><th class="num">Baseline</th>'
'<th>Delta</th>'
'</tr></thead><tbody>' + "".join(rows) + '</tbody></table>'
'</div>'
)
def generate_html(timings: dict, baseline: dict | None = None) -> str:
stats = compute_stats(timings, baseline)
sha_short = (timings.get("head_sha") or "")[:7]
run_id = timings.get("run_id", "?")
created = timings.get("created_at", "")
bl_info = ""
if baseline:
bl_sha = (baseline.get("head_sha") or "")[:7]
bl_info = f' | Baseline: <code>{bl_sha}</code> (main)'
html = (
f'<!DOCTYPE html>\n<html lang="en">\n<head>\n'
f'<meta charset="utf-8">\n'
f'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
f'<title>CI Timing Report — {sha_short}</title>\n'
f'<style>{CSS}</style>\n'
f'</head>\n<body>\n'
f'<h1>CI Timing Report</h1>\n'
f'<div class="meta">Run <code>{escape(run_id)}</code> | SHA <code>{sha_short}</code>'
f' | Generated {escape(created)}{bl_info}</div>\n'
)
html += '<h2>Global Stats</h2>\n'
html += _stats_cards(stats)
if baseline:
html += '<h2>Top Regressions & Improvements</h2>\n'
html += _regressions(timings, baseline)
html += '<h2>Gantt Chart</h2>\n'
html += _gantt_bars(timings, baseline)
html += '<h2>Per-Job Comparison</h2>\n'
html += _job_table(timings, baseline)
html += '<h2>Step Details</h2>\n'
html += _step_details(timings, baseline)
html += '</body>\n</html>\n'
return html
# ---------------------------------------------------------------------------
# Markdown summary for $GITHUB_STEP_SUMMARY
# ---------------------------------------------------------------------------
def generate_summary(timings: dict, baseline: dict | None = None) -> str:
stats = compute_stats(timings, baseline)
bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
lines = ["## CI Timing Summary\n"]
# Global stats table
lines.append("| Metric | Current | Baseline | Delta |")
lines.append("|--------|---------|----------|-------|")
wall_d = ""
if stats["bl_wall"] is not None:
d, _ = fmt_delta(stats["wall"], stats["bl_wall"])
wall_d = d
lines.append(f"| Wall time | {fmt_dur(stats['wall'])} | {fmt_dur(stats['bl_wall'])} | {wall_d} |")
compute_d = ""
if stats["bl_compute"] is not None:
d, _ = fmt_delta(stats["compute"], stats["bl_compute"])
compute_d = d
lines.append(f"| Total compute | {fmt_dur(stats['compute'])} | {fmt_dur(stats['bl_compute'])} | {compute_d} |")
lines.append(f"| Jobs faster | {stats['faster']} | — | — |")
lines.append(f"| Jobs slower | {stats['slower']} | — | — |")
lines.append(f"| Jobs unchanged | {stats['unchanged']} | — | — |")
lines.append(f"| Jobs without baseline | {stats['no_baseline']} | — | — |")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def expect_env(var: str) -> str:
val = os.environ.get(var)
if not val:
raise ValueError(f"missing environment variable {var}")
return val
def main():
parser = argparse.ArgumentParser(description="Collect CI timings and generate HTML report")
parser.add_argument("--from-json", help="Read timings from JSON instead of API")
parser.add_argument("--baseline", default="ci-timings-baseline.json",
help="Baseline JSON path (default: ci-timings-baseline.json)")
parser.add_argument("--output", default="ci-timings-report.html",
help="HTML output path (default: ci-timings-report.html)")
parser.add_argument("--json-out", default="ci-timings.json",
help="JSON output path (default: ci-timings.json)")
parser.add_argument("--summary-out", default="ci-timings-summary.md",
help="Markdown summary output path (default: ci-timings-summary.md)")
args = parser.parse_args()
# Collect or load timings
if args.from_json:
with open(args.from_json, encoding="utf-8") as f:
timings = json.load(f)
else:
token = expect_env("GITHUB_TOKEN")
repo = expect_env("GITHUB_REPOSITORY")
run_id = expect_env("GITHUB_RUN_ID")
head_sha = expect_env("GITHUB_SHA")
timings = collect_timings(token, repo, run_id, head_sha)
# Save JSON
with open(args.json_out, "w", encoding="utf-8") as f:
json.dump(timings, f, indent=2)
print(f"Saved timings to {args.json_out} ({len(timings.get('jobs', []))} jobs)")
# Load baseline
baseline = None
if os.path.exists(args.baseline):
with open(args.baseline, encoding="utf-8") as f:
baseline = json.load(f)
print(f"Loaded baseline from {args.baseline}")
else:
print(f"No baseline file at {args.baseline} — generating current-only report")
# Generate HTML
html = generate_html(timings, baseline)
with open(args.output, "w", encoding="utf-8") as f:
f.write(html)
print(f"Generated HTML report: {args.output}")
# Write summary
summary = generate_summary(timings, baseline)
with open(args.summary_out, "a", encoding="utf-8") as f:
f.write(summary)
print(f"Wrote summary to {args.summary_out}")
if __name__ == "__main__":
main()
+6 -65
View File
@@ -1,7 +1,7 @@
---
name: claude-design
description: Design one-off HTML artifacts (landing, deck, prototype).
version: 1.1.0
version: 1.0.0
author: BadTechBandit
license: MIT
platforms: [linux, macos, windows]
@@ -165,31 +165,6 @@ Skip questions when:
When proceeding with assumptions, label only the important ones.
## Surface-First: Commit to a Composition Before Touching Tokens
The single highest-leverage anti-slop rule. Most AI design slop is **compositional, not cosmetic** — the model reaches for a centered hero + three equal-weight feature cards for *every* surface, then decorates. Recoloring or restyling that layout never fixes it, because the layout was wrong before a single color was chosen.
Before you write any colors, type scale, or components, **commit out loud to exactly one surface archetype.** This conditions generation on a high-level plan first, which collapses the entropy of what gets produced — the same reason a chain-of-thought step improves reasoning.
The seven surfaces:
1. **Monitor** — the user is watching state change (dashboards, status pages, observability). Density, glanceable hierarchy, no marketing framing.
2. **Operate** — the user is taking action on things (consoles, admin panels, queues, inboxes). Action affordances and selection state dominate.
3. **Compare** — the user is weighing options against each other (pricing, plans, spec tables, search results). Aligned columns, parity of structure, one differentiator emphasized.
4. **Configure** — the user is setting things up (settings, forms, wizards, onboarding). Progressive disclosure, clear save/validation states, low decoration.
5. **Decide / Learn** — the user is being convinced or taught (landing pages, docs, marketing). One idea lands per section; this is the ONLY surface where a hero is usually correct.
6. **Explore** — the user is browsing an open space (galleries, maps, search-and-filter, catalogs). Filters, result grids, and zoom/peek are the composition.
7. **Command / Inspect** — the user is driving by keyboard or drilling into one object (command bars, inspectors, detail panes, property editors). Speed and focus over breadth.
Rules:
- State the surface in one line before designing (e.g. "This is a **Monitor** surface, so density and glanceability beat a hero").
- A dashboard is a Monitor surface, not a Decide surface — do not give it a centered hero and three feature cards.
- If a screen genuinely spans two surfaces, name the **primary** one and treat the other as secondary; do not average them into mush.
- The hero-plus-three-cards composition is correct for **Decide/Learn only**. Reaching for it anywhere else is the #1 tell.
This one constraint eliminates more generic-looking UI than any aesthetic rule below.
## Workflow
1. **Understand the brief**
@@ -202,11 +177,7 @@ This one constraint eliminates more generic-looking UI than any aesthetic rule b
- Read supplied docs, screenshots, repo files, or design assets.
- Identify the visual vocabulary before writing code.
3. **Commit to a surface** (see "Surface-First")
- Name the one surface archetype before any visual tokens.
- This conditions the composition; everything below inherits from it.
4. **Define the design system for this artifact**
3. **Define the design system for this artifact**
- colors
- type
- spacing
@@ -216,26 +187,25 @@ This one constraint eliminates more generic-looking UI than any aesthetic rule b
- component treatment
- interaction rules
5. **Choose the right format**
4. **Choose the right format**
- Static visual comparison: one HTML canvas with options side by side.
- Interaction/flow: clickable prototype.
- Presentation: fixed-size HTML deck with slide navigation.
- Component exploration: component lab with variants.
- Motion: timeline or state-based animation.
6. **Build the artifact**
5. **Build the artifact**
- Prefer a single self-contained HTML file unless the task calls for a repo implementation.
- Preserve prior versions for major revisions.
- Avoid unnecessary dependencies.
7. **Verify**
6. **Verify**
- Confirm files exist.
- Run any available syntax/static checks.
- If browser tools are available, open the file and check console errors.
- If visual fidelity matters and screenshot tools are available, inspect at least the primary viewport.
- Run the slop self-audit (see "Slop Diagnostic") and repair only what it flags.
8. **Report briefly**
7. **Report briefly**
- exact file path
- what was created
- caveats
@@ -433,35 +403,6 @@ Avoid common AI design sludge:
Minimal is not automatically good. Dense is not automatically cluttered. Choose intentionally.
## Slop Diagnostic: Score Before You Fix
AI design slop has a tiny, predictable failure distribution — designers asked to label AI UIs collapse the "this is AI" signal down to about ten tells. Before polishing or repairing an artifact, run this as an explicit self-audit and write a short report. **Diagnose first, treat second** — auditing and fixing in one breath fails, because the model's prior outweighs the instruction and it repeats the mistake (recolors when it needed re-layout, polishes type on a composition problem).
The ten tells (presence of each = one point of slop; lower is better):
1. **Tech gradient** — blue/violet/indigo glossy gradient on everything.
2. **Generic tech hue** — the default accent is indigo/violet (not chosen for the brand, just the model's favorite).
3. **Feature-tile grid** — icon + heading + sentence × 3, all equal weight, nothing prioritized.
4. **Accent rail** — a colored left strip on cards: decoration pretending to be organization.
5. **Unearned blur** — glassmorphism with no real depth/elevation system behind it.
6. **Monument stat** — oversized numbers filling space that should carry product story.
7. **Icon topper** — a rounded-square icon centered above every heading (Tailwind-template filler).
8. **Center stack** — everything centered because no real composition was committed to.
9. **Default type** — Inter (or system-ui) used by default rather than chosen.
10. **Wrong surface** — the composition doesn't match the surface (e.g. a hero on a Monitor surface). This is the root cause behind most of the others.
How to run it:
- Score the artifact out of 10 (10 = maximum slop). State the score and list which tells fired, in one short report.
- Treat the report as **context, not a to-do list** — it tells you *where* to spend repair effort, it does not dictate edits.
- Then repair, matched to the diagnosis:
- tells 3, 8, 10 → **re-layout / re-compose** (revisit the surface choice — do not recolor).
- tells 1, 2, 9 → **recolor / re-typeset** (palette and type are genuinely the problem here).
- tells 4, 5, 6, 7 → **remove the decoration**; replace it with real hierarchy (scale, weight, spacing).
- Re-score after repairing. Do not declare done while compositional tells (3, 8, 10) are still firing — those are causes, the rest are usually symptoms.
The point of separating diagnosis from treatment: let the audit complain first, then fix only what it complained about, in the register the complaint calls for.
## Typography
Use the existing type system if one exists.
-33
View File
@@ -353,39 +353,6 @@ class TestRuntimeMode:
assert any("coding agent" in b for b in blocks)
assert any("Workspace" in b for b in blocks)
def test_coding_instructions_append_their_own_block(self, tmp_path):
_git_init(tmp_path)
cfg = {
"agent": {
"coding_context": "on",
"coding_instructions": "Clean the diff before commit.",
}
}
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config=cfg)
blocks = mode.system_blocks()
# The brief stays block 0 (byte-stable, cache-keyed independently); the
# operator instructions ride a separate trailing block.
assert blocks[0] == cc.CODING_AGENT_GUIDANCE
assert any("Clean the diff before commit." in b for b in blocks[1:])
def test_coding_instructions_accept_a_list(self, tmp_path):
_git_init(tmp_path)
cfg = {
"agent": {
"coding_context": "on",
"coding_instructions": ["No tsc/lint on UI.", "Clean the diff."],
}
}
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config=cfg)
instr_block = mode.system_blocks()[-1]
assert "No tsc/lint on UI." in instr_block
assert "Clean the diff." in instr_block
def test_no_instructions_block_when_unset(self, tmp_path):
_git_init(tmp_path)
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": "on"}})
assert not any("Operator instructions" in b for b in mode.system_blocks())
def test_toolset_selection_gated_on_focus(self, tmp_path):
_git_init(tmp_path)
focus = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": "focus"}})
-79
View File
@@ -401,82 +401,3 @@ class TestEditDiffPreview:
assert any("a/file2.py" in line for line in rendered)
assert not any("a/file7.py" in line for line in rendered)
assert "additional file" in rendered[-1]
class TestBuildToolLabel:
"""Friendly human-phrased tool labels for built-in tools."""
@pytest.fixture(autouse=True)
def _enable_friendly(self):
from agent.display import set_friendly_tool_labels
set_friendly_tool_labels(True)
yield
set_friendly_tool_labels(True)
def test_web_search_uses_for_connector(self):
from agent.display import build_tool_label
label = build_tool_label("web_search", {"query": "weather in NYC"})
assert label == 'Searching the web for weather in NYC'
def test_web_extract_reads_url(self):
from agent.display import build_tool_label
label = build_tool_label("web_extract", {"urls": ["https://example.com/page"]})
assert label is not None
assert label.startswith("Reading ")
assert "example.com/page" in label
def test_browser_navigate_browses_url(self):
from agent.display import build_tool_label
label = build_tool_label("browser_navigate", {"url": "https://news.site"})
assert label == "Browsing https://news.site"
def test_read_file_uses_basename(self):
from agent.display import build_tool_label
label = build_tool_label("read_file", {"path": "/home/u/project/main.py"})
assert label is not None
assert label.startswith("Reading ")
assert "main.py" in label
def test_search_files_uses_for_connector(self):
from agent.display import build_tool_label
label = build_tool_label("search_files", {"pattern": "TODO"})
assert label == "Searching files for TODO"
def test_verb_only_for_no_preview_tools(self):
from agent.display import build_tool_label
# session_search is verb-only — no redundant query echo
label = build_tool_label("session_search", {"query": "auth refactor"})
assert label == "Searching past sessions"
def test_verb_only_when_no_preview_available(self):
from agent.display import build_tool_label
# image_generate with empty args still yields the verb (no preview)
label = build_tool_label("image_generate", {})
assert label == "Generating image"
def test_unknown_tool_falls_back_to_preview(self):
from agent.display import build_tool_label, build_tool_preview
args = {"some_arg": "value"}
# A custom/plugin/MCP tool with no verb entry → raw preview behavior
label = build_tool_label("custom_mcp_tool", args)
assert label == build_tool_preview("custom_mcp_tool", args)
def test_disabled_falls_back_to_preview(self):
from agent.display import (
build_tool_label,
build_tool_preview,
set_friendly_tool_labels,
)
set_friendly_tool_labels(False)
args = {"query": "weather in NYC"}
label = build_tool_label("web_search", args)
# With the feature off, must match the raw preview exactly
assert label == build_tool_preview("web_search", args)
assert "Searching the web" not in (label or "")
def test_every_known_verb_renders_without_error(self):
from agent.display import build_tool_label, _TOOL_VERBS
# Each built-in verb must produce a non-empty label given minimal args.
for tool_name in _TOOL_VERBS:
label = build_tool_label(tool_name, {"query": "x", "path": "x", "url": "x"})
assert label, f"{tool_name} produced empty label"
-18
View File
@@ -97,24 +97,6 @@ class TestParseResponse:
)
assert r is None
def test_pre_verify_continue_canonical(self):
r = shell_hooks._parse_response(
"pre_verify", '{"action": "continue", "message": "run checks"}',
)
assert r == {"action": "continue", "message": "run checks"}
def test_pre_verify_block_is_continue_claude_style(self):
# Claude-Code Stop hooks: block the stop == keep going; reason → message.
r = shell_hooks._parse_response(
"pre_verify", '{"decision": "block", "reason": "run the formatter"}',
)
assert r == {"action": "continue", "message": "run the formatter"}
def test_pre_verify_without_message_is_noop(self):
# A continue with nothing to tell the model lets the turn finish.
assert shell_hooks._parse_response("pre_verify", '{"action": "continue"}') is None
assert shell_hooks._parse_response("pre_verify", '{"decision": "allow"}') is None
def test_block_action_without_message_uses_default(self):
"""Block is honored even when message/reason is absent."""
r = shell_hooks._parse_response("pre_tool_call", '{"action": "block"}')
-18
View File
@@ -215,7 +215,6 @@ def test_nudge_after_unverified_edit_with_known_command(tmp_path, monkeypatch):
assert "fresh passing verification evidence" in nudge
assert "`pnpm run test`" in nudge
assert changed in nudge
assert "creative UI/visual work" in nudge
def test_nudge_includes_failed_output_summary(tmp_path, monkeypatch):
@@ -250,23 +249,6 @@ def test_no_suite_nudge_requests_temp_script(tmp_path, monkeypatch):
assert tempfile.gettempdir() in nudge
assert "ad-hoc verification" in nudge
assert "suite green" in nudge
assert "creative UI/visual work" in nudge
def test_verify_guidance_can_be_disabled(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
_node_project(tmp_path)
changed = str(tmp_path / "src" / "app.ts")
from agent import verify_hooks
monkeypatch.setattr(verify_hooks, "coding_verify_guidance", lambda: None)
nudge = build_verify_on_stop_nudge(session_id="s1", changed_paths=[changed])
assert nudge is not None
assert "fresh passing verification evidence" in nudge
assert "creative UI/visual work" not in nudge
def test_ad_hoc_pass_satisfies_no_suite_stop_loop(tmp_path, monkeypatch):
-53
View File
@@ -1,53 +0,0 @@
"""Unit tests for the verification-loop policy (agent/verify_hooks.py).
The `pre_verify` user-hook aggregation lives in `hermes_cli.plugins`
(`get_pre_verify_continue_message`) and is tested in
`tests/hermes_cli/test_plugins.py`, alongside `get_pre_tool_call_block_message`.
"""
from __future__ import annotations
from agent import verify_hooks
class TestMaxVerifyNudges:
def test_default_when_unset(self):
assert (
verify_hooks.max_verify_nudges({})
== verify_hooks.DEFAULT_MAX_VERIFY_NUDGES
)
assert (
verify_hooks.max_verify_nudges({"agent": {}})
== verify_hooks.DEFAULT_MAX_VERIFY_NUDGES
)
def test_reads_and_coerces(self):
assert verify_hooks.max_verify_nudges({"agent": {"max_verify_nudges": 5}}) == 5
assert verify_hooks.max_verify_nudges({"agent": {"max_verify_nudges": "2"}}) == 2
assert verify_hooks.max_verify_nudges({"agent": {"max_verify_nudges": -1}}) == 0
def test_bad_value_falls_back(self):
assert (
verify_hooks.max_verify_nudges({"agent": {"max_verify_nudges": "x"}})
== verify_hooks.DEFAULT_MAX_VERIFY_NUDGES
)
class TestCodingVerifyGuidance:
def test_enabled_by_default(self):
assert (
verify_hooks.coding_verify_guidance({})
== verify_hooks.CODING_VERIFY_GUIDANCE
)
assert (
verify_hooks.coding_verify_guidance({"agent": {}})
== verify_hooks.CODING_VERIFY_GUIDANCE
)
def test_reads_truthy_config(self):
cfg = {"agent": {"verify_guidance": "yes"}}
assert verify_hooks.coding_verify_guidance(cfg) == verify_hooks.CODING_VERIFY_GUIDANCE
def test_opt_out_via_config(self):
off = {"agent": {"verify_guidance": False}}
assert verify_hooks.coding_verify_guidance(off) is None
-19
View File
@@ -71,25 +71,6 @@ class TestPlatformConfigRoundtrip:
restored = PlatformConfig.from_dict({"gateway_restart_notification": "false"})
assert restored.gateway_restart_notification is False
def test_typing_indicator_defaults_true(self):
assert PlatformConfig().typing_indicator is True
assert PlatformConfig.from_dict({}).typing_indicator is True
def test_typing_indicator_roundtrip_false(self):
pc = PlatformConfig(enabled=True, typing_indicator=False)
restored = PlatformConfig.from_dict(pc.to_dict())
assert restored.typing_indicator is False
def test_typing_indicator_coerces_quoted_false(self):
restored = PlatformConfig.from_dict({"typing_indicator": "false"})
assert restored.typing_indicator is False
def test_typing_indicator_resolved_from_extra(self):
# The shared-key loop in load_gateway_config bridges the flag into
# extra; from_dict must honor it there too (mirrors _grn fallback).
restored = PlatformConfig.from_dict({"extra": {"typing_indicator": False}})
assert restored.typing_indicator is False
class TestGetConnectedPlatforms:
def test_returns_enabled_with_token(self):
+12 -28
View File
@@ -308,7 +308,7 @@ async def test_run_agent_progress_stays_in_originating_topic(monkeypatch, tmp_pa
assert adapter.sent == [
{
"chat_id": "-1001",
"content": '💻 Running pwd',
"content": '💻 terminal: "pwd"',
"reply_to": None,
"metadata": {"thread_id": "17585"},
}
@@ -496,27 +496,6 @@ async def test_run_agent_feishu_progress_replies_inside_existing_thread(monkeypa
# ---------------------------------------------------------------------------
def _extract_progress_preview(content: str) -> str | None:
"""Extract the argument-preview portion from a tool-progress message.
Handles both render styles:
- Legacy / custom tools: ``🔧 tool_name: "<preview>"`` (quoted)
- Friendly built-in verb: ``💻 Running <preview>`` (verb prefix, no quotes)
"""
import re
# Legacy quoted form takes precedence when present.
match = re.search(r'"(.+)"', content)
if match:
return match.group(1)
# Friendly form: "<emoji> <verb> <preview>". The terminal verb is "Running".
marker = " Running "
idx = content.find(marker)
if idx != -1:
return content[idx + len(marker):].strip()
return None
def _run_long_preview_helper(monkeypatch, tmp_path, preview_length=0):
"""Shared setup for long-preview truncation tests.
@@ -573,10 +552,13 @@ def test_all_mode_default_truncation_40_chars(monkeypatch, tmp_path):
assert result["final_response"] == "done"
assert adapter.sent
content = adapter.sent[0]["content"]
# The long command should be truncated — the preview portion <= 40 chars.
# The long command should be truncated — total preview <= 40 chars
assert "..." in content
preview_text = _extract_progress_preview(content)
assert preview_text is not None, f"No preview found in: {content}"
# Extract the preview part between quotes
import re
match = re.search(r'"(.+)"', content)
assert match, f"No quoted preview found in: {content}"
preview_text = match.group(1)
assert len(preview_text) <= 40, f"Preview too long ({len(preview_text)}): {preview_text}"
@@ -586,9 +568,11 @@ def test_all_mode_respects_custom_preview_length(monkeypatch, tmp_path):
assert result["final_response"] == "done"
assert adapter.sent
content = adapter.sent[0]["content"]
# With 120-char cap, the command (165 chars) should still be truncated but longer.
preview_text = _extract_progress_preview(content)
assert preview_text is not None, f"No preview found in: {content}"
# With 120-char cap, the command (165 chars) should still be truncated but longer
import re
match = re.search(r'"(.+)"', content)
assert match, f"No quoted preview found in: {content}"
preview_text = match.group(1)
# Should be longer than the 40-char default
assert len(preview_text) > 40, f"Preview suspiciously short ({len(preview_text)}): {preview_text}"
# But still capped at 120
@@ -1,120 +0,0 @@
"""
Tests for the connect-time user-token (vs bot-token) nudge.
``auth.test`` returns the ``user_id`` of whatever principal owns the configured
token. A real bot token (``xoxb-…``) resolves to the app's bot user and the
response carries a ``bot_id``; a user/legacy token (``xoxp-…``) resolves to the
installing *human's* member ID with **no** ``bot_id``. In the latter case the
adapter binds its identity to a human's member ID, so that person's ``<@…>``
mentions are misrouted as mentions of the bot. ``_warn_if_not_bot_token``
detects the missing ``bot_id`` at connect time — the only point where this is
observable, since a user token still sends/receives without any runtime error —
and logs an actionable, warning-only nudge.
"""
import logging
import sys
from unittest.mock import MagicMock
# ---------------------------------------------------------------------------
# Mock slack-bolt if not installed (same pattern as test_slack_mention.py)
# ---------------------------------------------------------------------------
def _ensure_slack_mock():
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
return
slack_bolt = MagicMock()
slack_bolt.async_app.AsyncApp = MagicMock
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
slack_sdk = MagicMock()
slack_sdk.web.async_client.AsyncWebClient = MagicMock
for name, mod in [
("slack_bolt", slack_bolt),
("slack_bolt.async_app", slack_bolt.async_app),
("slack_bolt.adapter", slack_bolt.adapter),
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
("slack_bolt.adapter.socket_mode.async_handler",
slack_bolt.adapter.socket_mode.async_handler),
("slack_sdk", slack_sdk),
("slack_sdk.web", slack_sdk.web),
("slack_sdk.web.async_client", slack_sdk.web.async_client),
]:
sys.modules.setdefault(name, mod)
_ensure_slack_mock()
import plugins.platforms.slack.adapter as _slack_mod # noqa: E402
_slack_mod.SLACK_AVAILABLE = True
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
class _DictAuthResponse(dict):
"""Mimics slack_sdk's AsyncSlackResponse — dict-like with .get(), like the
real object the adapter already calls ``.get()`` on in ``connect``."""
class _AttrAuthResponse:
"""A response shape that is NOT dict-like; values live on ``.data``."""
def __init__(self, data):
self.data = data
def _make_adapter():
# object.__new__ skips __init__ (heavy setup) — established slack-test pattern.
return object.__new__(SlackAdapter)
def test_warns_when_bot_id_absent(caplog):
# User token: auth.test resolves a human member but carries no bot_id.
adapter = _make_adapter()
resp = _DictAuthResponse(team_id="T1", user_id="U_HUMAN", user="trevor")
with caplog.at_level(logging.WARNING):
adapter._warn_if_not_bot_token(resp, "Acme")
matched = [r for r in caplog.records
if "authenticated as a USER" in r.message and "U_HUMAN" in r.message]
assert matched
def test_no_warning_when_bot_id_present(caplog):
# Real bot token: auth.test carries a bot_id.
adapter = _make_adapter()
resp = _DictAuthResponse(team_id="T1", user_id="U_BOT", bot_id="B123", user="hermes")
with caplog.at_level(logging.WARNING):
adapter._warn_if_not_bot_token(resp, "Acme")
assert not any("authenticated as a USER" in r.message for r in caplog.records)
def test_no_warning_when_user_id_unresolved(caplog):
# Nothing resolved (e.g. odd/empty response) — don't guess, stay silent.
adapter = _make_adapter()
resp = _DictAuthResponse(team_id="T1")
with caplog.at_level(logging.WARNING):
adapter._warn_if_not_bot_token(resp, "Acme")
assert not any("authenticated as a USER" in r.message for r in caplog.records)
def test_warns_only_once_per_workspace(caplog):
adapter = _make_adapter()
resp = _DictAuthResponse(user_id="U_HUMAN")
with caplog.at_level(logging.WARNING):
adapter._warn_if_not_bot_token(resp, "Acme")
adapter._warn_if_not_bot_token(resp, "Acme")
warnings = [r for r in caplog.records if "authenticated as a USER" in r.message]
assert len(warnings) == 1
def test_handles_attribute_only_response_shape(caplog):
# Response without dict .get(): values must be read off .data.
adapter = _make_adapter()
resp = _AttrAuthResponse({"user_id": "U_HUMAN", "user": "trevor"})
with caplog.at_level(logging.WARNING):
adapter._warn_if_not_bot_token(resp, "Acme")
assert any("authenticated as a USER" in r.message and "U_HUMAN" in r.message
for r in caplog.records)
@@ -1,99 +0,0 @@
"""Per-platform typing-indicator toggle (PlatformConfig.typing_indicator).
The "typing…" / "is thinking…" status bubble is driven by the generic
``_keep_typing`` refresh loop that ``_process_message_background`` spawns for
every inbound message on every platform. ``typing_indicator`` (default True)
gates that spawn: when False, the loop is never started, so ``send_typing``
is never called and no status indicator is shown — while message delivery is
otherwise unchanged.
These are behavioral tests against the real dispatch path, not snapshots.
"""
import asyncio
from unittest.mock import AsyncMock
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
)
from gateway.session import SessionSource, build_session_key
class _StubAdapter(BasePlatformAdapter):
async def connect(self, *, is_reconnect: bool = False):
pass
async def disconnect(self):
pass
async def send(self, chat_id, text, **kwargs):
return None
async def get_chat_info(self, chat_id):
return {}
def _make_adapter(typing_indicator: bool) -> _StubAdapter:
adapter = _StubAdapter(
PlatformConfig(enabled=True, token="t", typing_indicator=typing_indicator),
Platform.SLACK,
)
# Record send_typing calls without performing any platform I/O.
adapter.send_typing = AsyncMock(return_value=None)
adapter._send_with_retry = AsyncMock(return_value=None)
# Handler returns immediately; the typing loop only fires if it was spawned.
adapter._message_handler = AsyncMock(return_value="ok")
return adapter
def _make_event(chat_id="C123"):
return MessageEvent(
text="hi",
message_type=MessageType.TEXT,
source=SessionSource(platform=Platform.SLACK, chat_id=chat_id, chat_type="dm"),
)
def _sk(chat_id="C123"):
return build_session_key(
SessionSource(platform=Platform.SLACK, chat_id=chat_id, chat_type="dm")
)
@pytest.mark.asyncio
async def test_typing_indicator_enabled_spawns_refresh_loop():
"""Default (typing_indicator=True): the refresh loop calls send_typing."""
adapter = _make_adapter(typing_indicator=True)
# Real handlers take time (tool calls); yield long enough for the spawned
# refresh loop to fire at least one send_typing before delivery completes.
async def _slow_handler(_event):
await asyncio.sleep(0.05)
return "ok"
adapter._message_handler = _slow_handler
event = _make_event()
adapter._active_sessions[_sk()] = asyncio.Event()
await adapter._process_message_background(event, _sk())
assert adapter.send_typing.await_count >= 1
@pytest.mark.asyncio
async def test_typing_indicator_disabled_never_calls_send_typing():
"""typing_indicator=False: the loop is never spawned, send_typing unused."""
adapter = _make_adapter(typing_indicator=False)
event = _make_event()
adapter._active_sessions[_sk()] = asyncio.Event()
await adapter._process_message_background(event, _sk())
adapter.send_typing.assert_not_awaited()
# Delivery still happened — disabling typing must not suppress the reply.
adapter._send_with_retry.assert_awaited()
-62
View File
@@ -243,65 +243,3 @@ class TestUsageAccountSection:
assert account_call["kwargs"]["base_url"] == "https://chatgpt.com/backend-api/codex"
assert "📊 **Session Info**" in result
assert "📈 **Account limits**" in result
class TestUsageContextBreakdown:
"""The /usage output includes the per-category context breakdown."""
@pytest.mark.asyncio
async def test_breakdown_lines_rendered_for_live_agent(self):
agent = _make_mock_agent()
runner = _make_runner(SK, cached_agent=agent)
session_entry = MagicMock()
session_entry.session_id = "sess-bd"
runner.session_store.get_or_create_session.return_value = session_entry
runner.session_store.load_transcript.return_value = [
{"role": "user", "content": "hi"},
]
event = MagicMock()
fake_payload = {
"categories": [
{"id": "system_prompt", "label": "System prompt", "tokens": 4000, "color": "x"},
{"id": "tool_definitions", "label": "Tool definitions", "tokens": 6000, "color": "x"},
{"id": "conversation", "label": "Conversation", "tokens": 0, "color": "x"},
],
"estimated_total": 10000,
"context_max": 200000,
"context_percent": 5,
"context_used": 30000,
"model": "anthropic/claude-sonnet-4.6",
}
with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \
patch("agent.context_breakdown.compute_session_context_breakdown", return_value=fake_payload):
result = await runner._handle_usage_command(event)
# Localized header + at least the two non-zero category labels appear,
# each labelled as a percentage of the estimated total.
assert "Context breakdown" in result
assert "System prompt" in result
assert "Tool definitions" in result
assert "4,000" in result # system prompt tokens, comma-formatted
assert "40%" in result # 4000 / 10000
assert "60%" in result # 6000 / 10000
# Zero-token category is dropped, not rendered.
assert "Conversation" not in result
@pytest.mark.asyncio
async def test_breakdown_failure_is_non_fatal(self):
"""A breakdown engine error must not break the rest of /usage."""
agent = _make_mock_agent()
runner = _make_runner(SK, cached_agent=agent)
runner.session_store.get_or_create_session.side_effect = RuntimeError("boom")
event = MagicMock()
with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \
patch("agent.context_breakdown.compute_session_context_breakdown",
side_effect=RuntimeError("engine down")):
result = await runner._handle_usage_command(event)
# Core usage lines still render; no breakdown header.
assert "📊 **Session Token Usage**" in result
assert "50,000" in result # total tokens
assert "Context breakdown" not in result
-87
View File
@@ -229,93 +229,6 @@ def test_starting_state_does_not_autostart(tmp_path: Path) -> None:
assert named[0].action == "registered"
def test_draining_runtime_state_autostarts(tmp_path: Path) -> None:
"""A gateway hard-killed mid-drain leaves `gateway_state=draining` as its
last persisted value (the recreate SIGTERMs it before `_stop_impl` can
write a terminal `stopped`/`running`). `draining` is a transient sub-state
of RUNNING, not an operator stop, so with no explicit `desired_state` it
must normalise to running-intent and auto-start — otherwise the gateway
stays DOWN forever and messaging silently goes dark (the relay-opted-in
staging wedge, 2026-06)."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "drained", state="draining")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert _named_actions(actions) == [ReconcileAction(
profile="drained", prior_state="running", action="started",
)]
# Autostart means NO down-marker — the gateway comes back up.
assert not (scandir / "gateway-drained" / "down").exists()
def test_degraded_runtime_state_autostarts(tmp_path: Path) -> None:
"""`degraded` is the same wedge class as `draining`: the gateway came up
with some platforms queued for retry, then fell through to the normal
running state (gateway/run.py #5196) and is serving cron + connected
platforms. A hard-kill there strands `gateway_state=degraded`, which is
NOT an operator stop and NOT a failed boot. With no explicit
`desired_state` it must normalise to running-intent and auto-start —
otherwise the gateway stays DOWN forever exactly like the draining wedge."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(tmp_path, "degraded-box", state="degraded")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert _named_actions(actions) == [ReconcileAction(
profile="degraded-box", prior_state="running", action="started",
)]
assert not (scandir / "gateway-degraded-box" / "down").exists()
def test_draining_default_root_autostarts(tmp_path: Path) -> None:
"""The hosted-agent path: the default (root) profile, not a named one.
A managed Fly instance runs the root profile; a stranded `draining` there
is exactly what wedged the relay-opted-in staging instance. Mirror the
named-profile case for the default slot."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_seed_default_root(tmp_path, state="draining")
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
default_action = next(a for a in actions if a.profile == "default")
assert default_action.prior_state == "running"
assert default_action.action == "started"
assert not (scandir / "gateway-default" / "down").exists()
def test_desired_state_stopped_overrides_draining_runtime(tmp_path: Path) -> None:
"""An explicit operator stop must survive even when the transient runtime
state is `draining`. The `desired_state` is the durable intent and is
honoured verbatim — the draining→running normalisation only applies to the
legacy/transient `gateway_state` fallback, never over an explicit
`desired_state`."""
scandir = tmp_path / "run-service"; scandir.mkdir()
_make_profile(
tmp_path,
"stopped-while-draining",
state="draining",
desired_state="stopped",
)
actions = reconcile_profile_gateways(
hermes_home=tmp_path, scandir=scandir, dry_run=False,
)
assert _named_actions(actions) == [ReconcileAction(
profile="stopped-while-draining",
prior_state="stopped",
action="registered",
)]
assert (scandir / "gateway-stopped-while-draining" / "down").exists()
def test_stale_runtime_files_are_removed(tmp_path: Path) -> None:
scandir = tmp_path / "run-service"; scandir.mkdir()
profile = _make_profile(tmp_path, "coder", state="running", with_pid=True)
-68
View File
@@ -18,7 +18,6 @@ from hermes_cli.plugins import (
get_plugin_command_handler,
get_plugin_commands,
get_pre_tool_call_block_message,
get_pre_verify_continue_message,
has_middleware,
resolve_plugin_command_result,
)
@@ -859,73 +858,6 @@ class TestPreToolCallBlocking:
assert get_pre_tool_call_block_message("terminal", {}) == "first blocker"
class TestGetPreVerifyContinueMessage:
"""`pre_verify` directive aggregation — mirrors the pre_tool_call block path."""
def test_continue_canonical(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [{"action": "continue", "message": "run checks"}],
)
assert get_pre_verify_continue_message(session_id="s") == "run checks"
def test_claude_block_means_continue(self, monkeypatch):
# Claude-Code Stop: "block" the stop == keep going; reason → message.
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [{"decision": "block", "reason": "run the formatter"}],
)
assert get_pre_verify_continue_message() == "run the formatter"
def test_first_actionable_directive_wins(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [
"noise", # not a dict
{"action": "continue"}, # no message → skipped
{"action": "continue", "message": "second"},
{"action": "continue", "message": "third"},
],
)
assert get_pre_verify_continue_message() == "second"
def test_message_is_trimmed(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [{"action": "continue", "message": " tidy up "}],
)
assert get_pre_verify_continue_message() == "tidy up"
def test_invalid_returns_ignored(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.plugins.invoke_hook",
lambda hook_name, **kwargs: [
{"action": "allow"}, # wrong action
{"context": "noise"}, # not a directive
{"action": "continue", "message": " "}, # blank message
{"action": "continue", "message": 42}, # message not str
],
)
assert get_pre_verify_continue_message() is None
def test_none_when_no_hooks(self, monkeypatch):
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda hook_name, **kwargs: [])
assert get_pre_verify_continue_message() is None
def test_forwards_scope_signals_to_hooks(self, monkeypatch):
seen = {}
def capture(hook_name, **kwargs):
seen.update(kwargs)
return []
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", capture)
get_pre_verify_continue_message(coding=True, attempt=2, changed_paths=["a.py"])
assert seen["coding"] is True
assert seen["attempt"] == 2
assert seen["changed_paths"] == ["a.py"]
class TestThreadToolWhitelist:
"""Tests for the thread-local tool whitelist used by background review forks."""
@@ -127,34 +127,14 @@ def _mint_id_token(
)
def _make_provider(
rsa_keypair,
*,
scopes: str | None = None,
client_secret: str | None = None,
auth_methods: Any = "__unset__",
):
"""Construct a provider with discovery + JWKS stubbed (no network).
``client_secret`` flips the provider into confidential mode. ``auth_methods``
overrides ``token_endpoint_auth_methods_supported`` in the seeded discovery
doc (pass a list, or ``None`` to omit the key entirely); left unset, the
discovery doc carries no auth-methods key (the absent-key default).
"""
def _make_provider(rsa_keypair, *, scopes: str | None = None):
"""Construct a provider with discovery + JWKS stubbed (no network)."""
kwargs: Dict[str, Any] = {"issuer": _ISSUER, "client_id": _CLIENT_ID}
if scopes is not None:
kwargs["scopes"] = scopes
if client_secret is not None:
kwargs["client_secret"] = client_secret
p = oidc_plugin.SelfHostedOIDCProvider(**kwargs)
# Pre-seed discovery so nothing hits the network.
disco = dict(_DISCOVERY_DOC)
if auth_methods != "__unset__":
if auth_methods is None:
disco.pop("token_endpoint_auth_methods_supported", None)
else:
disco["token_endpoint_auth_methods_supported"] = auth_methods
p._discovery = disco
p._discovery = dict(_DISCOVERY_DOC)
p._discovery_fetched_at = time.time()
# Patch the JWKS client to return our fixture key.
fake_key = MagicMock()
@@ -675,185 +655,6 @@ class TestCompleteLogin:
assert kwargs["data"]["client_id"] == _CLIENT_ID
# ---------------------------------------------------------------------------
# Confidential client (client_secret) — token-endpoint client authentication
# ---------------------------------------------------------------------------
_GOOD_TOKEN_RESP_KEYS = {"token_type": "Bearer", "refresh_token": "rt_initial"}
def _decode_basic(header_value: str) -> tuple[str, str]:
"""Decode a ``Basic <b64>`` Authorization header back to (user, pass)."""
assert header_value.startswith("Basic ")
raw = base64.b64decode(header_value[len("Basic ") :]).decode("utf-8")
user, _, pw = raw.partition(":")
# client_id / secret are form-url-encoded before base64 (RFC 6749 §2.3.1).
return urllib.parse.unquote(user), urllib.parse.unquote(pw)
class TestConfidentialClient:
"""A configured ``client_secret`` authenticates the client at the token
endpoint (basic header or post body, auto-selected from discovery), while
PKCE is still sent. A public client (no secret) is byte-identical to the
pre-confidential-client behaviour — no secret anywhere, no auth header."""
def _complete(self, provider, rsa_keypair):
id_token = _mint_id_token(rsa_keypair)
mock_resp = _mock_post(200, {"id_token": id_token, **_GOOD_TOKEN_RESP_KEYS})
with patch(
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
) as mock_post:
provider.complete_login(
code="the-code",
state="s",
code_verifier="the-verifier",
redirect_uri="https://hermes.example/auth/callback",
)
_, kwargs = mock_post.call_args
return kwargs
# -- public client: nothing changes ------------------------------------
def test_public_client_sends_no_secret_or_auth_header(self, rsa_keypair):
# No client_secret configured → no Authorization header, no
# client_secret in the body. Pins the unchanged public-client contract.
provider = _make_provider(rsa_keypair) # public
kwargs = self._complete(provider, rsa_keypair)
assert "Authorization" not in kwargs["headers"]
assert "client_secret" not in kwargs["data"]
# PKCE still present.
assert kwargs["data"]["code_verifier"] == "the-verifier"
# Header is exactly the pre-feature value.
assert kwargs["headers"] == {"Accept": "application/json"}
# -- basic (default & explicit) ----------------------------------------
def test_confidential_defaults_to_basic_when_methods_absent(self, rsa_keypair):
# Discovery advertises no auth methods → OIDC default is Basic.
provider = _make_provider(
rsa_keypair, client_secret="s3cr3t", auth_methods=None
)
kwargs = self._complete(provider, rsa_keypair)
assert "client_secret" not in kwargs["data"] # not in body for basic
user, pw = _decode_basic(kwargs["headers"]["Authorization"])
assert (user, pw) == (_CLIENT_ID, "s3cr3t")
# PKCE still sent alongside the secret.
assert kwargs["data"]["code_verifier"] == "the-verifier"
def test_confidential_basic_when_explicitly_advertised(self, rsa_keypair):
provider = _make_provider(
rsa_keypair,
client_secret="s3cr3t",
auth_methods=["client_secret_basic", "client_secret_post"],
)
kwargs = self._complete(provider, rsa_keypair)
# When both are advertised we prefer basic (secret stays out of body).
assert "client_secret" not in kwargs["data"]
user, pw = _decode_basic(kwargs["headers"]["Authorization"])
assert (user, pw) == (_CLIENT_ID, "s3cr3t")
# -- post --------------------------------------------------------------
def test_confidential_post_when_only_post_advertised(self, rsa_keypair):
provider = _make_provider(
rsa_keypair,
client_secret="s3cr3t",
auth_methods=["client_secret_post"],
)
kwargs = self._complete(provider, rsa_keypair)
assert kwargs["data"]["client_secret"] == "s3cr3t"
assert "Authorization" not in kwargs["headers"]
assert kwargs["data"]["code_verifier"] == "the-verifier"
# -- url-encoding of reserved chars ------------------------------------
def test_basic_url_encodes_reserved_chars_in_secret(self, rsa_keypair):
# A secret with ':' / '@' / space must round-trip through the Basic
# header exactly — these are exactly the chars that corrupt a naive
# "id:secret" concatenation.
tricky = "p@ss:wo rd/+="
provider = _make_provider(
rsa_keypair, client_secret=tricky, auth_methods=["client_secret_basic"]
)
kwargs = self._complete(provider, rsa_keypair)
user, pw = _decode_basic(kwargs["headers"]["Authorization"])
assert user == _CLIENT_ID
assert pw == tricky
# -- blank secret is treated as public ---------------------------------
def test_whitespace_secret_is_public(self, rsa_keypair):
# A provisioned-but-blank secret must NOT flip us into confidential
# mode (which would send an empty secret and break the exchange).
provider = _make_provider(
rsa_keypair, client_secret=" ", auth_methods=["client_secret_basic"]
)
kwargs = self._complete(provider, rsa_keypair)
assert "Authorization" not in kwargs["headers"]
assert "client_secret" not in kwargs["data"]
# -- refresh grant also authenticates ----------------------------------
def test_refresh_grant_authenticates_confidential_client(self, rsa_keypair):
provider = _make_provider(
rsa_keypair, client_secret="s3cr3t", auth_methods=["client_secret_post"]
)
id_token = _mint_id_token(rsa_keypair)
mock_resp = _mock_post(
200, {"id_token": id_token, "token_type": "Bearer", "refresh_token": "rt2"}
)
with patch(
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
) as mock_post:
provider.refresh_session(refresh_token="rt_old")
_, kwargs = mock_post.call_args
assert kwargs["data"]["grant_type"] == "refresh_token"
assert kwargs["data"]["client_secret"] == "s3cr3t"
def test_refresh_grant_basic_header_confidential_client(self, rsa_keypair):
provider = _make_provider(
rsa_keypair, client_secret="s3cr3t", auth_methods=["client_secret_basic"]
)
id_token = _mint_id_token(rsa_keypair)
mock_resp = _mock_post(
200, {"id_token": id_token, "token_type": "Bearer", "refresh_token": "rt2"}
)
with patch(
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
) as mock_post:
provider.refresh_session(refresh_token="rt_old")
_, kwargs = mock_post.call_args
user, pw = _decode_basic(kwargs["headers"]["Authorization"])
assert (user, pw) == (_CLIENT_ID, "s3cr3t")
# -- revocation also authenticates -------------------------------------
def test_revoke_authenticates_confidential_client(self, rsa_keypair):
provider = _make_provider(
rsa_keypair, client_secret="s3cr3t", auth_methods=["client_secret_post"]
)
with patch(
"plugins.dashboard_auth.self_hosted.httpx.post"
) as mock_post:
provider.revoke_session(refresh_token="rt_old")
_, kwargs = mock_post.call_args
assert kwargs["data"]["client_secret"] == "s3cr3t"
# -- the secret never appears in logs ----------------------------------
def test_secret_not_in_repr_or_log(self, rsa_keypair, caplog):
import logging
with caplog.at_level(logging.INFO):
provider = _make_provider(
rsa_keypair, client_secret="sup3r-s3cr3t", auth_methods=None
)
# The provider object's repr must not leak the secret.
assert "sup3r-s3cr3t" not in repr(provider)
assert "sup3r-s3cr3t" not in caplog.text
# ---------------------------------------------------------------------------
# verify_session
# ---------------------------------------------------------------------------
@@ -1052,7 +853,6 @@ class TestPluginRegister:
"HERMES_DASHBOARD_OIDC_ISSUER",
"HERMES_DASHBOARD_OIDC_CLIENT_ID",
"HERMES_DASHBOARD_OIDC_SCOPES",
"HERMES_DASHBOARD_OIDC_CLIENT_SECRET",
):
monkeypatch.delenv(var, raising=False)
@@ -1178,87 +978,3 @@ class TestPluginRegister:
oidc_plugin.register(ctx)
ctx.register_dashboard_auth_provider.assert_not_called()
assert "construction failed" in oidc_plugin.LAST_SKIP_REASON
# -- client_secret wiring ----------------------------------------------
def test_registers_public_when_no_secret(self, patch_config, monkeypatch):
patch_config(None)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_ISSUER", _ISSUER)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_ID", _CLIENT_ID)
ctx = MagicMock()
oidc_plugin.register(ctx)
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
assert registered._client_secret == ""
def test_secret_from_env(self, patch_config, monkeypatch):
patch_config(None)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_ISSUER", _ISSUER)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_ID", _CLIENT_ID)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "env-secret")
ctx = MagicMock()
oidc_plugin.register(ctx)
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
assert registered._client_secret == "env-secret"
def test_secret_from_config_yaml(self, patch_config):
patch_config(
{
"self_hosted": {
"issuer": _ISSUER,
"client_id": _CLIENT_ID,
"client_secret": "cfg-secret",
}
}
)
ctx = MagicMock()
oidc_plugin.register(ctx)
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
assert registered._client_secret == "cfg-secret"
def test_env_secret_overrides_config(self, patch_config, monkeypatch):
patch_config(
{
"self_hosted": {
"issuer": _ISSUER,
"client_id": _CLIENT_ID,
"client_secret": "cfg-secret",
}
}
)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "env-secret")
ctx = MagicMock()
oidc_plugin.register(ctx)
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
assert registered._client_secret == "env-secret"
def test_empty_env_secret_does_not_shadow_config(self, patch_config, monkeypatch):
patch_config(
{
"self_hosted": {
"issuer": _ISSUER,
"client_id": _CLIENT_ID,
"client_secret": "cfg-secret",
}
}
)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "")
ctx = MagicMock()
oidc_plugin.register(ctx)
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
assert registered._client_secret == "cfg-secret"
def test_register_log_reports_confidential_not_secret(
self, patch_config, monkeypatch, caplog
):
import logging
patch_config(None)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_ISSUER", _ISSUER)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_ID", _CLIENT_ID)
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "logme-secret")
ctx = MagicMock()
with caplog.at_level(logging.INFO):
oidc_plugin.register(ctx)
# The success log reports confidentiality as a boolean, never the value.
assert "logme-secret" not in caplog.text
assert "confidential=True" in caplog.text
+1 -159
View File
@@ -16,17 +16,9 @@ import pytest
@pytest.fixture(autouse=True)
def _fake_api_key(monkeypatch, tmp_path):
def _fake_api_key(monkeypatch):
"""Ensure XAI_API_KEY is set for all tests."""
monkeypatch.setenv("XAI_API_KEY", "test-key-12345")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
try:
import hermes_cli.config as cfg_mod
if hasattr(cfg_mod, "_invalidate_load_config_cache"):
cfg_mod._invalidate_load_config_cache()
except Exception:
pass
# ---------------------------------------------------------------------------
@@ -88,13 +80,6 @@ class TestXAIImageGenProvider:
assert schema["env_vars"] == []
assert schema["post_setup"] == "xai_grok"
def test_capabilities_expose_total_source_image_limit(self):
from plugins.image_gen.xai import XAIImageGenProvider
caps = XAIImageGenProvider().capabilities()
assert caps["max_reference_images"] == 2
assert caps["max_source_images"] == 3
# ---------------------------------------------------------------------------
# Config tests
@@ -333,131 +318,6 @@ class TestGenerate:
f"resolution must be the literal '1k' or '2k', got {payload['resolution']!r}"
)
def test_image_edit_rejects_bare_file_id_input(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/edited.png"}]}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
patch("plugins.image_gen.xai.save_url_image", return_value="/tmp/edited.png"):
provider = XAIImageGenProvider()
result = provider.generate(
prompt="make the robot red",
image_url="file_03eb65b1-aa97-482f-9ef0-b04f9172ea00",
)
assert result["success"] is False
assert result["error_type"] == "invalid_image_url"
mock_post.assert_not_called()
def test_image_edit_accepts_public_https_url(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/edited.png"}]}
public_url = "https://files-cdn.x.ai/token/file_abc.png"
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
patch("plugins.image_gen.xai.save_url_image", return_value="/tmp/edited.png"):
provider = XAIImageGenProvider()
result = provider.generate(
prompt="make the robot red",
image_url=public_url,
)
assert result["success"] is True
payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
assert payload["image"] == {"url": public_url, "type": "image_url"}
def test_multi_image_edit_rejects_bare_file_id_inputs(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/edited.png"}]}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
patch("plugins.image_gen.xai.save_url_image", return_value="/tmp/edited.png"):
provider = XAIImageGenProvider()
result = provider.generate(
prompt="combine these robots into one product shot",
image_url="file_03eb65b1-aa97-482f-9ef0-b04f9172ea00",
reference_image_urls=[
"file_54b48d6d-28ad-4982-9d72-bd3ac677c9bc",
"file_aa11bb22-cc33-44dd-88ee-ff0011223344",
],
)
assert result["success"] is False
assert result["error_type"] == "invalid_image_url"
mock_post.assert_not_called()
def test_multi_image_edit_rejects_more_than_three_sources(self):
from plugins.image_gen.xai import XAIImageGenProvider
provider = XAIImageGenProvider()
result = provider.generate(
prompt="combine too many references",
image_url="file_1",
reference_image_urls=["file_2", "file_3", "file_4"],
)
assert result["success"] is False
assert result["error_type"] == "too_many_references"
def test_storage_options_are_sent_by_default(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"data": [{"b64_json": "dGVzdA=="}]}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
patch("plugins.image_gen.xai.save_b64_image", return_value="/tmp/test.png"):
provider = XAIImageGenProvider()
provider.generate(prompt="test")
payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
assert payload["storage_options"]["public_url"] is True
assert "expires_after" not in payload["storage_options"]
assert payload["storage_options"]["filename"].endswith(".png")
def test_public_url_file_output_wins_over_temporary_url(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {
"data": [{
"url": "https://imgen.x.ai/xai-tmp-imgen-test.jpeg",
"file_output": {
"file_id": "file-123",
"filename": "stored.png",
"public_url": "https://xai-files.example/stored.png",
"public_url_expires_at": 1234567890,
},
}],
}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp), \
patch("plugins.image_gen.xai.save_url_image") as mock_save_url:
provider = XAIImageGenProvider()
result = provider.generate(prompt="A cat playing piano")
assert result["success"] is True
assert result["image"] == "https://xai-files.example/stored.png"
assert result["public_url"] == "https://xai-files.example/stored.png"
assert "file_id" not in result
mock_save_url.assert_not_called()
# ---------------------------------------------------------------------------
# Registration test
@@ -474,21 +334,3 @@ class TestRegistration:
provider = mock_ctx.register_image_gen_provider.call_args[0][0]
assert isinstance(provider, XAIImageGenProvider)
assert provider.name == "xai"
def test_xai_image_field_expands_user_home(tmp_path, monkeypatch):
"""A ~-prefixed local image path must load (expanduser), not raise io_error.
Pre-flight validation uses ``Path(source).expanduser()`` so a ``~/...`` path
passes; ``_xai_image_field`` must expand it too or the load fails spuriously.
"""
from plugins.image_gen.xai import _xai_image_field
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
img = tmp_path / "pic.png"
img.write_bytes(b"\x89PNG\r\n\x1a\n")
field = _xai_image_field("~/pic.png")
assert field["type"] == "image_url"
assert field["url"].startswith("data:image/png;base64,")
+8 -46
View File
@@ -32,9 +32,9 @@ def test_xai_provider_lists_text_and_current_image_video_models():
ids = [model["id"] for model in models]
assert ids[0] == "grok-imagine-video"
assert ids[1] == "grok-imagine-video-1.5"
assert ids[1] == "grok-imagine-video-1.5-preview"
assert models[1]["modalities"] == ["image"]
assert "aliases" not in models[1]
assert models[1]["aliases"] == ["grok-imagine-video-1.5-2026-05-30"]
def test_xai_routes_default_models_by_modality():
@@ -49,7 +49,7 @@ def test_xai_routes_default_models_by_modality():
"grok-imagine-video",
modality="image",
explicit_model=False,
) == "grok-imagine-video-1.5"
) == "grok-imagine-video-1.5-preview"
assert _resolve_model_for_modality(
"grok-imagine-video-1.5-preview",
modality="text",
@@ -62,11 +62,15 @@ def test_xai_routes_default_models_by_modality():
) == "grok-imagine-video-1.5-preview"
def test_xai_capabilities_keep_generate_surface_only():
def test_xai_capabilities_text_and_image_only():
"""xAI was previously advertised with edit/extend operations. The
simplified surface only exposes text-to-video and image-to-video —
confirm those are the only modalities advertised."""
from plugins.video_gen.xai import XAIVideoGenProvider
caps = XAIVideoGenProvider().capabilities()
assert caps["modalities"] == ["text", "image"]
# No 'operations' key in the simplified surface
assert "operations" not in caps
assert caps["max_reference_images"] == 7
@@ -144,45 +148,3 @@ def test_xai_no_operation_kwarg():
assert result["success"] is False
# auth_required, NOT some signature error
assert result["error_type"] in {"auth_required", "api_error"}
def test_xai_video_output_urls_prefers_stored_public_url():
from plugins.video_gen.xai import _xai_video_output_urls
public_url, temporary, stored = _xai_video_output_urls({
"url": "https://vidgen.x.ai/xai-vidgen-bucket/out.mp4",
"file_output": {
"public_url": "https://files-cdn.x.ai/token/file_abc.mp4",
"file_id": "file_abc",
},
})
assert public_url == "https://files-cdn.x.ai/token/file_abc.mp4"
assert stored == "https://files-cdn.x.ai/token/file_abc.mp4"
assert temporary == "https://vidgen.x.ai/xai-vidgen-bucket/out.mp4"
@pytest.mark.asyncio
async def test_video_input_from_public_url_uses_url_field():
from plugins.video_gen.xai import _video_input_from_public_url
url = "https://files-cdn.x.ai/kRQVP6PRQlioVAUNC3GAdg/file_1faca9c3-9411-46ad-bb41-b9b8527789e6.mp4"
result = await _video_input_from_public_url(
url,
api_key="test-key",
base_url="https://api.x.ai/v1",
)
assert result == {"url": url}
def test_video_input_from_public_url_rejects_bare_file_id():
import asyncio
from plugins.video_gen.xai import _video_input_from_public_url
result = asyncio.run(
_video_input_from_public_url(
"file_1faca9c3-9411-46ad-bb41-b9b8527789e6",
api_key="test-key",
base_url="https://api.x.ai/v1",
)
)
assert result is None
@@ -122,7 +122,7 @@ class TestXAIPayload:
provider, captured = xai_provider
provider.generate("animate this", image_url="https://example.com/cat.png")
payload = _last_post(captured)["json"]
assert payload["model"] == "grok-imagine-video-1.5"
assert payload["model"] == "grok-imagine-video-1.5-preview"
assert payload["image"] == {"url": "https://example.com/cat.png"}
def test_local_image_path_is_sent_as_data_uri(self, xai_provider, tmp_path):
@@ -133,7 +133,7 @@ class TestXAIPayload:
provider.generate("animate this", image_url=str(image_path))
payload = _last_post(captured)["json"]
assert payload["model"] == "grok-imagine-video-1.5"
assert payload["model"] == "grok-imagine-video-1.5-preview"
assert payload["image"]["url"].startswith("data:image/png;base64,")
def test_explicit_model_override_is_honored_for_image(self, xai_provider):
+6 -16
View File
@@ -854,7 +854,7 @@ def test_history_to_messages_preserves_tool_calls_for_resume_display():
assert server._history_to_messages(history) == [
{"role": "user", "text": "first prompt"},
{"context": "Searching files for resume", "name": "search_files", "role": "tool"},
{"context": "resume", "name": "search_files", "role": "tool"},
{"role": "assistant", "text": "first answer"},
{"role": "user", "text": "second prompt"},
]
@@ -5461,23 +5461,13 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch):
assert built, "agent build did not complete within timeout"
# Build finished without a close race — nothing should have been
# cleaned up by the orphan check. Scope the assertions to THIS
# test's own session_key: a daemon build thread leaked from a prior
# session.create test in the same shard process can fire close/
# unregister against its own (foreign) key after we've patched the
# global hooks, polluting these lists. Filtering by this session's
# key keeps the regression intent (this session's worker/notify must
# survive) while making the test immune to shard composition.
# (flaky under -j 8: foreign key e.g. 20260629_210208_d4f545)
own_key = session["session_key"]
own_closed = [k for k in closed_workers if k == own_key]
own_unregistered = [k for k in unregistered_keys if k == own_key]
# cleaned up by the orphan check.
assert (
own_closed == []
), f"build thread closed its own worker despite no race: {own_closed}"
closed_workers == []
), f"build thread closed its own worker despite no race: {closed_workers}"
assert (
own_unregistered == []
), f"build thread unregistered its own notify despite no race: {own_unregistered}"
unregistered_keys == []
), f"build thread unregistered its own notify despite no race: {unregistered_keys}"
# Session should have the live worker installed.
assert session.get("slash_worker") is not None
+1 -98
View File
@@ -589,108 +589,11 @@ def test_complete_retry_with_corrected_created_cards_succeeds(worker_env):
}))
assert ok.get("ok") is True
def test_complete_goal_mode_rejected_by_judge(monkeypatch, tmp_path):
"""Goal-mode tasks must pass the auxiliary judge before completion.
Regression for #38367: workers bypassing the judge via early kanban_complete."""
from pathlib import Path as _Path
from hermes_cli import kanban_db as kb
from tools import kanban_tools as kt
# Set up isolated HERMES_HOME
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_PROFILE", "test-worker")
monkeypatch.delenv("HERMES_SESSION_ID", raising=False)
monkeypatch.setattr(_Path, "home", lambda: tmp_path)
kb._INITIALIZED_PATHS.clear()
kb.init_db()
conn = kb.connect()
try:
goal_task_id = kb.create_task(
conn, title="goal-mode-test", assignee="test-worker",
body="Must achieve X with verified evidence.", goal_mode=True
)
kb.claim_task(conn, goal_task_id)
assert kb.get_task(conn, worker_env).status == "done"
finally:
conn.close()
monkeypatch.setenv("HERMES_KANBAN_TASK", goal_task_id)
# Mock the judge to reject the completion. The gate only runs when a
# judge is reachable, so force the availability probe True as well.
def mock_judge_goal(goal, last_response, *, timeout=30.0, subgoals=None):
return "continue", "missing verification evidence", False
monkeypatch.setattr("tools.kanban_tools.judge_goal", mock_judge_goal)
monkeypatch.setattr("tools.kanban_tools._goal_judge_available", lambda: True)
# Attempt to complete should be rejected
out = kt._handle_complete({"summary": "I did some stuff but not X"})
d = json.loads(out)
assert "error" in d
assert "Goal completion rejected by judge" in d["error"]
assert "missing verification evidence" in d["error"]
assert f"parents=[{goal_task_id}]" in d["error"]
# Verify the task is NOT completed in the DB
conn2 = kb.connect()
try:
task = kb.get_task(conn2, goal_task_id)
assert task.status == "running" # Should still be running, not done
finally:
conn2.close()
def test_complete_goal_mode_allows_when_judge_unavailable(monkeypatch, tmp_path):
"""Fail-open: an unreachable judge must not wedge a goal_mode worker.
judge_goal returns a "continue" verdict when no auxiliary model is
configured, which is indistinguishable from a real "not done" judgment.
The gate probes availability first, so completion proceeds rather than
being rejected forever when no judge can be reached."""
from pathlib import Path as _Path
from hermes_cli import kanban_db as kb
from tools import kanban_tools as kt
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_PROFILE", "test-worker")
monkeypatch.delenv("HERMES_SESSION_ID", raising=False)
monkeypatch.setattr(_Path, "home", lambda: tmp_path)
kb._INITIALIZED_PATHS.clear()
kb.init_db()
conn = kb.connect()
try:
goal_task_id = kb.create_task(
conn, title="goal-mode-test", assignee="test-worker",
body="Must achieve X with verified evidence.", goal_mode=True
)
kb.claim_task(conn, goal_task_id)
finally:
conn.close()
monkeypatch.setenv("HERMES_KANBAN_TASK", goal_task_id)
# No judge reachable. judge_goal must not even be consulted; if it were,
# this stub would reject — so reaching "done" proves the probe short-circuit.
def fail_if_called(goal, last_response, *, timeout=30.0, subgoals=None):
raise AssertionError("judge_goal must not run when no judge is available")
monkeypatch.setattr("tools.kanban_tools.judge_goal", fail_if_called)
monkeypatch.setattr("tools.kanban_tools._goal_judge_available", lambda: False)
out = kt._handle_complete({"summary": "done enough"})
d = json.loads(out)
assert d.get("ok") is True
conn2 = kb.connect()
try:
assert kb.get_task(conn2, goal_task_id).status == "done"
finally:
conn2.close()
def test_block_happy_path(worker_env):
+4 -18
View File
@@ -35,9 +35,6 @@ class _RecordingProvider(VideoGenProvider):
def default_model(self) -> Optional[str]:
return "model-a"
def capabilities(self) -> Dict[str, Any]:
return {"modalities": ["text", "image"]}
def generate(self, prompt, **kwargs):
self.last_kwargs = {"prompt": prompt, **kwargs}
modality = "image" if kwargs.get("image_url") else "text"
@@ -116,25 +113,14 @@ class TestUnifiedDispatch:
assert "error" in result
assert "prompt" in result["error"].lower()
def test_edit_extend_args_are_rejected_by_generate_tool(self):
provider = _RecordingProvider("rec")
video_gen_registry.register_provider(provider)
result = self._run({
"prompt": "make it rain",
"operation": "edit",
"video_url": "https://example.com/in.mp4",
})
assert "error" in result
assert "provider-specific tool" in result["error"]
def test_provider_exception_caught(self):
video_gen_registry.register_provider(_RaisingProvider())
result = self._run({"prompt": "x"})
assert result["success"] is False
assert result["error_type"] == "provider_exception"
def test_edit_extend_fields_not_in_schema(self):
def test_operation_field_not_in_schema(self):
"""Make sure we removed the operation field from the schema."""
from tools.video_generation_tool import VIDEO_GENERATE_SCHEMA
props = VIDEO_GENERATE_SCHEMA["parameters"]["properties"]
assert "operation" not in props
assert "video_url" not in props
assert "operation" not in VIDEO_GENERATE_SCHEMA["parameters"]["properties"]
assert "video_url" not in VIDEO_GENERATE_SCHEMA["parameters"]["properties"]
@@ -1,4 +1,4 @@
"""Tests for the dynamic schema builder."""
"""Tests for the dynamic schema builder under the simplified surface."""
from __future__ import annotations
@@ -91,13 +91,20 @@ class TestDynamicSchemaBuilder:
assert "No video backend is configured" in desc
assert "hermes tools" in desc
def test_generic_description_keeps_edit_extend_out_of_surface(self, cfg_home):
def test_does_not_mention_edit_or_extend(self, cfg_home):
"""The simplified surface only does text→video and image→video.
The description must not mention edit/extend anywhere."""
from tools.video_generation_tool import _build_dynamic_video_schema, _GENERIC_DESCRIPTION
desc = _build_dynamic_video_schema()["description"]
assert "Video edit/extend workflows are not part of this unified surface" in desc
assert "operation='edit'" not in _GENERIC_DESCRIPTION
assert "operation='extend'" not in _GENERIC_DESCRIPTION
# Block words that would suggest functionality we removed
assert "edit" not in desc.lower() or "audio" in desc.lower() # 'audio' contains 'audi' not 'edit'
# Stronger: no occurrence of the words "edit" or "extend" as standalone
for forbidden in (" edit ", " edits ", " extend ", " extends "):
assert forbidden not in desc.lower(), f"description leaks '{forbidden.strip()}'"
# Sanity: the generic blurb itself is also clean
for forbidden in ("edit", "extend"):
assert forbidden not in _GENERIC_DESCRIPTION.lower()
def test_both_modalities_advertises_auto_routing(self, cfg_home):
from tools.video_generation_tool import _build_dynamic_video_schema
@@ -116,6 +123,7 @@ class TestDynamicSchemaBuilder:
assert "Active backend: Both" in desc
assert "text-to-video" in desc and "image-to-video" in desc
assert "routes automatically" in desc
# operations bullet is gone
assert "operations supported" not in desc
def test_image_only_model_warns_about_required_image_url(self, cfg_home):
@@ -79,21 +79,10 @@ def matrix_env(tmp_path, monkeypatch):
xai_calls.append({"url": url, "json": json})
return _Resp({"request_id": "req-1"})
async def get(self, url, headers=None, timeout=None):
payload = xai_calls[-1]["json"]
storage_options = payload.get("storage_options") or {}
return _Resp({
"status": "done",
"video": {
"url": "https://xai-cdn/out.mp4",
"duration": 8,
"file_output": {
"file_id": "file-123",
"filename": storage_options.get("filename", "out.mp4"),
"public_url": "https://xai-files.example/out.mp4",
"public_url_expires_at": 1234567890,
},
},
"model": payload.get("model", "grok-imagine-video"),
"video": {"url": "https://xai-cdn/out.mp4", "duration": 8},
"model": xai_calls[-1]["json"].get("model", "grok-imagine-video"),
})
import plugins.video_gen.xai as xai_plugin
monkeypatch.setattr(xai_plugin.httpx, "AsyncClient", lambda: _Client())
@@ -111,7 +100,7 @@ def matrix_env(tmp_path, monkeypatch):
return tmp_path, fal_calls, xai_calls
def _invoke_tool(home, cfg: dict, args: dict, tool_name: str = "video_generate") -> dict:
def _invoke_tool(home, cfg: dict, args: dict) -> dict:
"""Write config, invoke the registered tool handler, return parsed JSON."""
(home / "config.yaml").write_text(yaml.safe_dump(cfg))
import hermes_cli.config as cfg_mod
@@ -119,9 +108,9 @@ def _invoke_tool(home, cfg: dict, args: dict, tool_name: str = "video_generate")
cfg_mod._invalidate_load_config_cache()
from tools.registry import discover_builtin_tools, registry
if tool_name not in registry._tools:
if "video_generate" not in registry._tools:
discover_builtin_tools()
handler = registry._tools[tool_name].handler
handler = registry._tools["video_generate"].handler
return json.loads(handler(args))
@@ -216,11 +205,6 @@ def test_xai_text_only_via_tool_surface(matrix_env):
assert payload["model"] == "grok-imagine-video"
assert "image" not in payload
assert "reference_images" not in payload
assert payload["storage_options"]["public_url"] is True
assert "expires_after" not in payload["storage_options"]
assert result["video"] == "https://xai-files.example/out.mp4"
assert result["public_url"] == "https://xai-files.example/out.mp4"
assert result.get("temporary_url") == "https://xai-cdn/out.mp4"
def test_xai_text_plus_image_via_tool_surface(matrix_env):
@@ -238,157 +222,10 @@ def test_xai_text_plus_image_via_tool_surface(matrix_env):
assert len(xai_calls) == 1
assert xai_calls[0]["url"].endswith("/videos/generations")
payload = xai_calls[0]["json"] or {}
assert payload["model"] == "grok-imagine-video-1.5"
assert payload["model"] == "grok-imagine-video-1.5-preview"
assert payload["image"] == {"url": "https://example.com/img.png"}
def test_xai_image_to_video_rejects_bare_file_id_via_tool_surface(matrix_env):
home, _, xai_calls = matrix_env
result = _invoke_tool(
home,
{"video_gen": {"provider": "xai"}},
{
"prompt": "animate this robot waving",
"image_url": "file_03eb65b1-aa97-482f-9ef0-b04f9172ea00",
},
)
assert result["success"] is False
assert result.get("error_type") == "invalid_image_url"
assert len(xai_calls) == 0
def test_xai_reference_to_video_via_tool_surface(matrix_env):
home, _, xai_calls = matrix_env
result = _invoke_tool(
home,
{"video_gen": {"provider": "xai"}},
{
"prompt": "put the jacket from the reference on the runway model",
"reference_image_urls": [
"https://example.com/model.png",
"https://example.com/jacket.png",
],
"duration": 15,
},
)
assert result["success"] is True
assert result["modality"] == "reference"
assert result["provider"] == "xai"
payload = xai_calls[0]["json"] or {}
assert xai_calls[0]["url"].endswith("/videos/generations")
assert payload["model"] == "grok-imagine-video"
assert payload["duration"] == 10
assert payload["reference_images"] == [
{"url": "https://example.com/model.png"},
{"url": "https://example.com/jacket.png"},
]
def test_xai_reference_to_video_rejects_bare_file_ids_via_tool_surface(matrix_env):
home, _, xai_calls = matrix_env
result = _invoke_tool(
home,
{"video_gen": {"provider": "xai"}},
{
"prompt": "use these references for a robot product shot",
"reference_image_urls": [
"file_03eb65b1-aa97-482f-9ef0-b04f9172ea00",
"file_54b48d6d-28ad-4982-9d72-bd3ac677c9bc",
],
},
)
assert result["success"] is False
assert result.get("error_type") == "invalid_reference_image_urls"
assert len(xai_calls) == 0
def test_xai_video_edit_via_tool_surface(matrix_env):
home, _, xai_calls = matrix_env
result = _invoke_tool(
home,
{"video_gen": {"provider": "xai"}},
{
"prompt": "make the sky stormy",
"video_url": "https://example.com/source.mp4",
},
tool_name="xai_video_edit",
)
assert result["success"] is True
assert result["modality"] == "edit"
payload = xai_calls[0]["json"] or {}
assert xai_calls[0]["url"].endswith("/videos/edits")
assert payload["model"] == "grok-imagine-video"
assert payload["video"] == {"url": "https://example.com/source.mp4"}
assert "duration" not in payload
assert "aspect_ratio" not in payload
assert "resolution" not in payload
def test_xai_video_extend_via_tool_surface(matrix_env):
home, _, xai_calls = matrix_env
result = _invoke_tool(
home,
{"video_gen": {"provider": "xai"}},
{
"prompt": "the camera pulls back to reveal the city",
"video_url": "https://example.com/source.mp4",
"duration": 15,
},
tool_name="xai_video_extend",
)
assert result["success"] is True
assert result["modality"] == "extend"
payload = xai_calls[0]["json"] or {}
assert xai_calls[0]["url"].endswith("/videos/extensions")
assert payload["model"] == "grok-imagine-video"
assert payload["video"] == {"url": "https://example.com/source.mp4"}
assert payload["duration"] == 10
def test_xai_video_edit_rejects_bare_file_id_via_tool_surface(matrix_env):
home, _, xai_calls = matrix_env
result = _invoke_tool(
home,
{"video_gen": {"provider": "xai"}},
{
"prompt": "make the sky stormy",
"video_url": "file-123",
},
tool_name="xai_video_edit",
)
assert result.get("success") is not True
assert "error" in result
assert "url" in result["error"].lower()
assert len(xai_calls) == 0
def test_xai_video_extend_rejects_bare_file_id_via_tool_surface(matrix_env):
home, _, xai_calls = matrix_env
result = _invoke_tool(
home,
{"video_gen": {"provider": "xai"}},
{
"prompt": "continue into a sunrise",
"video_url": "file_25ac1c31-d6d8-48b2-8504-a97d282310c4",
},
tool_name="xai_video_extend",
)
assert result.get("success") is not True
assert "error" in result
assert "url" in result["error"].lower()
assert len(xai_calls) == 0
def test_xai_explicit_model_override_via_tool_surface(matrix_env):
home, _, xai_calls = matrix_env
-132
View File
@@ -1,132 +0,0 @@
"""Tests for xAI Imagine storage helper behavior."""
from __future__ import annotations
import yaml
def _invalidate_config_cache():
try:
import hermes_cli.config as cfg_mod
if hasattr(cfg_mod, "_invalidate_load_config_cache"):
cfg_mod._invalidate_load_config_cache()
except Exception:
pass
def test_storage_defaults_to_permanent_public_urls(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_invalidate_config_cache()
from tools.xai_http import build_xai_storage_options
storage = build_xai_storage_options(
"image_gen",
filename_prefix="hermes-xai-image",
extension="png",
)
assert storage is not None
assert storage["public_url"] is True
assert "expires_after" not in storage
assert storage["filename"].startswith("hermes-xai-image-")
assert storage["filename"].endswith(".png")
def test_storage_can_be_disabled(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
"video_gen": {
"xai": {
"storage": {
"enabled": False,
},
},
},
}))
_invalidate_config_cache()
from tools.xai_http import build_xai_storage_options, xai_storage_notice_text
assert build_xai_storage_options(
"video_gen",
filename_prefix="hermes-xai-video",
extension="mp4",
) is None
assert xai_storage_notice_text("video_gen") == ""
def test_storage_can_be_permanent(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
"image_gen": {
"xai": {
"storage": {
"expires_after": "permanent",
},
},
},
}))
_invalidate_config_cache()
from tools.xai_http import build_xai_storage_options
storage = build_xai_storage_options(
"image_gen",
filename_prefix="hermes-xai-image",
extension="png",
)
assert storage is not None
assert "expires_after" not in storage
def test_storage_can_use_finite_retention(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
"image_gen": {
"xai": {
"storage": {
"expires_after": 172800,
},
},
},
}))
_invalidate_config_cache()
from tools.xai_http import build_xai_storage_options
storage = build_xai_storage_options(
"image_gen",
filename_prefix="hermes-xai-image",
extension="png",
)
assert storage is not None
assert storage["expires_after"] == 172800
def test_invalid_storage_retention_falls_back_to_bounded_ttl(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
"video_gen": {
"xai": {
"storage": {
"expires_after": "definitely-not-a-duration",
},
},
},
}))
_invalidate_config_cache()
from tools.xai_http import build_xai_storage_options
storage = build_xai_storage_options(
"video_gen",
filename_prefix="hermes-xai-video",
extension="mp4",
)
assert storage is not None
assert storage["expires_after"] == 172800
-53
View File
@@ -34,7 +34,6 @@ import os
from typing import Any, Optional
from agent.redact import redact_sensitive_text
from hermes_cli.goals import judge_goal
from tools.registry import registry, tool_error
from hermes_cli.config import cfg_get, load_config
@@ -179,27 +178,6 @@ def _connect(board: Optional[str] = None):
return kb, kb.connect(board=board)
def _goal_judge_available() -> bool:
"""True when an auxiliary client is configured for the goal judge.
``judge_goal`` is fail-open at the source: when no auxiliary model can
be reached it returns a ``"continue"`` verdict that is indistinguishable
from a real "not done yet" judgment. The completion gate must not treat
that as a rejection, or an unconfigured/degraded auxiliary model would
wedge every ``goal_mode`` worker (it could never close its own task).
So we probe availability first and only enforce the gate when a judge is
actually reachable. This mirrors the same client lookup ``judge_goal``
performs internally.
"""
try:
from agent.auxiliary_client import get_text_auxiliary_client
client, model = get_text_auxiliary_client("goal_judge")
except Exception:
return False
return client is not None and bool(model)
# ---------------------------------------------------------------------------
# Runtime-activity → board-heartbeat bridge (#31752)
# ---------------------------------------------------------------------------
@@ -589,37 +567,6 @@ def _handle_complete(args: dict, **kw) -> str:
try:
kb, conn = _connect(board=board)
try:
# Goal-mode pre-completion judge gate (Issue #38367).
# Prevent workers from bypassing the auxiliary judge by
# calling kanban_complete before acceptance criteria are met.
# Only enforce when a judge is actually reachable — see
# _goal_judge_available for why an unavailable judge fails open.
task = kb.get_task(conn, tid)
if task and task.goal_mode and _goal_judge_available():
verdict = "done"
reason = ""
try:
verdict, reason, _ = judge_goal(
goal=f"{task.title}\n\n{task.body or ''}".strip(),
last_response=(summary or result or "").strip(),
)
except Exception as judge_exc:
# Defensive: judge_goal swallows its own errors, but if
# it ever raises, fail open rather than wedge the worker.
logger.warning(
"goal judge check failed, allowing completion: %s",
judge_exc,
exc_info=True,
)
if verdict != "done":
return tool_error(
f"Goal completion rejected by judge: {reason}. "
f"To proceed, either: (1) provide explicit acceptance "
f"evidence in your summary matching the task's criteria, "
f"or (2) create continuation tasks with parents=[{tid}] "
f"and keep this task alive."
)
try:
ok = kb.complete_task(
conn, tid,
+25 -46
View File
@@ -18,11 +18,13 @@ Generation.
Unified surface
---------------
One tool covers the common cases - text-to-video, image-to-video, and
reference-to-video - with a compact schema:
One tool covers the common cases — text-to-video, image-to-video, video
edit, video extend — with a compact schema:
prompt text instruction (required)
image_url drives image-to-video
prompt text instruction (required for generate/edit)
operation "generate" | "edit" | "extend"
image_url drives image-to-video when operation=generate
video_url source video for edit/extend
reference_image_urls list, up to provider-declared cap
duration seconds (provider clamps)
aspect_ratio "16:9" | "9:16" | "1:1" | ...
@@ -36,9 +38,6 @@ Providers ignore parameters they do not support. The tool layer does
**lightweight** validation (type/required-prompt) and lets each provider
do its own clamping inside :meth:`VideoGenProvider.generate` — that keeps
the tool surface stable as new providers ship with different capabilities.
Video edit and video extend are intentionally not exposed here; providers with
those workflows should expose separate tools.
"""
from __future__ import annotations
@@ -81,20 +80,21 @@ VIDEO_GENERATE_SCHEMA: Dict[str, Any] = {
"image_url": {
"type": "string",
"description": (
"Optional public HTTPS URL of a still image. When provided, "
"Optional public URL of a still image. When provided, "
"the active backend routes to its image-to-video "
"endpoint (animate the image); when omitted, it routes "
"to text-to-video. For xAI chaining, use the `image` or "
"`public_url` HTTPS URL from a prior Imagine result."
"to text-to-video. Pass either a URL the user supplied "
"or a path/URL from the conversation."
),
},
"reference_image_urls": {
"type": "array",
"items": {"type": "string"},
"description": (
"Optional list of public HTTPS reference image URLs "
"(style or character refs). For xAI chaining, use "
"`image` or `public_url` from prior Imagine results."
"Optional list of reference image URLs (style or "
"character refs). Only supported by some backends; "
"the active backend's description below indicates whether "
"this is honored and what the max is."
),
},
"duration": {
@@ -324,11 +324,6 @@ def _handle_video_generate(args: Dict[str, Any], **_kw: Any) -> str:
# endpoint but our surface always needs a prompt.
if not prompt:
return tool_error("prompt is required for video generation")
if "operation" in args or "video_url" in args:
return tool_error(
"video_generate only supports text-to-video, image-to-video, and "
"reference-to-video; use a provider-specific tool for video edit/extend"
)
# Resolve the active provider.
configured = _read_configured_video_provider()
@@ -403,13 +398,13 @@ def _handle_video_generate(args: Dict[str, Any], **_kw: Any) -> str:
# Dynamic schema — reflect the active backend's actual capabilities
# ---------------------------------------------------------------------------
#
# Why dynamic: the user's configured backend determines which modalities
# (text / image / refs), aspect ratios, resolutions, durations, and
# audio/negative-prompt flags are real. A model that calls video_generate
# without knowing the active backend wastes a turn on something like
# "fal-ai/veo3.1/image-to-video requires image_url". Surfacing the per-model
# surface in the description means the model usually gets the call right on
# the first try.
# Why dynamic: the user's configured backend determines which operations
# (generate/edit/extend), modalities (text / image / refs), aspect ratios,
# resolutions, durations, and audio/negative-prompt flags are real. A model
# that calls video_generate without knowing the active backend wastes a
# turn on something like "fal-ai/veo3.1/image-to-video requires image_url".
# Surfacing the per-model surface in the description means the model
# usually gets the call right on the first try.
#
# Memoization: model_tools.get_tool_definitions() keys its cache on
# config.yaml mtime, so when the user changes provider/model via
@@ -417,12 +412,11 @@ def _handle_video_generate(args: Dict[str, Any], **_kw: Any) -> str:
_GENERIC_DESCRIPTION = (
"Generate a video from a text prompt (text-to-video), animate a "
"still image (image-to-video), or guide generation with reference images. "
"Pass `image_url` to animate an image or `reference_image_urls` for "
"reference-to-video. Video edit/extend workflows are not part of this "
"unified surface; use a dedicated provider-specific tool when one is "
"available. The backend and model family are user-configured via "
"Generate a video from a text prompt (text-to-video) or animate a "
"still image (image-to-video) using the user's configured video "
"generation backend. Pass `image_url` to animate that image; omit it "
"to generate from text alone. The backend auto-routes to the right "
"endpoint. The backend and model family are user-configured via "
"`hermes tools` → Video Generation; the agent does not pick them. "
"Long-running generations may take 30 seconds to several minutes — "
"the call blocks until the video is ready. Returns the result in the "
@@ -548,21 +542,6 @@ def _build_dynamic_video_schema() -> Dict[str, Any]:
max_refs = caps.get("max_reference_images") or 0
if max_refs:
parts.append(f"- reference_image_urls: up to {max_refs} images")
if configured == "xai":
parts.append(
"- chaining: for edit/extend pass the public HTTPS MP4 in `video` "
"or `public_url` from the prior Imagine result (files-cdn). For "
"image-to-video / reference-to-video pass public image URLs the "
"same way"
)
try:
from tools.xai_http import xai_storage_notice_text
notice = xai_storage_notice_text("video_gen")
except Exception:
notice = ""
if notice:
parts.append(f"- storage: {notice}")
return {"description": "\n".join(parts)}
+16 -165
View File
@@ -2,15 +2,9 @@
from __future__ import annotations
import datetime
import json
import os
import uuid
from typing import Any, Dict, Optional
MAX_XAI_STORAGE_EXPIRES_AFTER_SECONDS = 30 * 24 * 60 * 60
SAFE_XAI_STORAGE_EXPIRES_AFTER_SECONDS = 2 * 24 * 60 * 60
from typing import Dict
def has_xai_credentials() -> bool:
@@ -78,149 +72,6 @@ def hermes_xai_user_agent() -> str:
return f"Hermes-Agent/{__version__}"
def _load_config_section(section_name: str) -> Dict[str, Any]:
"""Return a top-level Hermes config section as a dict, or empty."""
try:
from hermes_cli.config import load_config
cfg = load_config()
section = cfg.get(section_name) if isinstance(cfg, dict) else None
return section if isinstance(section, dict) else {}
except Exception:
return {}
def _coerce_bool(value: Any, default: bool) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on", "enabled"}:
return True
if normalized in {"0", "false", "no", "off", "disabled"}:
return False
return default
def _coerce_expires_after(value: Any) -> Optional[int]:
"""Normalize an xAI storage TTL.
Returns:
int seconds for an expiring file,
None for permanent storage (omit expires_after on the wire).
"""
if value is None:
return None
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"", "default"}:
return None
if normalized in {"none", "null", "never", "permanent", "forever", "0"}:
return None
try:
value = int(normalized)
except ValueError:
return SAFE_XAI_STORAGE_EXPIRES_AFTER_SECONDS
if isinstance(value, (int, float)):
seconds = int(value)
if seconds <= 0:
return None
return min(seconds, MAX_XAI_STORAGE_EXPIRES_AFTER_SECONDS)
return SAFE_XAI_STORAGE_EXPIRES_AFTER_SECONDS
def read_xai_imagine_storage_config(section_name: str) -> Dict[str, Any]:
"""Read storage settings for xAI Imagine under image_gen/video_gen config.
Supported config shape:
image_gen:
xai:
storage:
enabled: true
public_url: true
expires_after: null # omit for permanent public URLs
The same shape is accepted under ``video_gen.xai.storage``. Storage is on
by default so xAI returns permanent public URLs instead of short-lived CDN URLs.
"""
section = _load_config_section(section_name)
xai_section = section.get("xai") if isinstance(section, dict) else None
storage = xai_section.get("storage") if isinstance(xai_section, dict) else None
storage = storage if isinstance(storage, dict) else {}
enabled = _coerce_bool(storage.get("enabled"), True)
public_url = _coerce_bool(storage.get("public_url"), True)
expires_after = _coerce_expires_after(storage.get("expires_after"))
return {
"enabled": enabled,
"public_url": public_url,
"expires_after": expires_after,
}
def build_xai_storage_options(
section_name: str,
*,
filename_prefix: str,
extension: str,
) -> Optional[Dict[str, Any]]:
"""Return an xAI ``storage_options`` payload, or None when disabled."""
cfg = read_xai_imagine_storage_config(section_name)
if not cfg["enabled"]:
return None
now = datetime.datetime.now(datetime.UTC)
ts = now.strftime("%Y%m%d-%H%M%S")
short = uuid.uuid4().hex[:8]
ext = extension.lstrip(".") or "bin"
payload: Dict[str, Any] = {
"filename": f"{filename_prefix}-{ts}-{short}.{ext}",
"public_url": bool(cfg["public_url"]),
}
if cfg["expires_after"] is not None:
payload["expires_after"] = cfg["expires_after"]
return payload
def xai_storage_notice_text(section_name: str) -> str:
"""User-facing notice for first xAI Imagine storage use."""
cfg = read_xai_imagine_storage_config(section_name)
if not cfg["enabled"]:
return ""
if cfg["expires_after"] is None:
retention = "without an automatic expiry"
else:
days = cfg["expires_after"] / (24 * 60 * 60)
retention = f"for about {days:g} day{'s' if days != 1 else ''}"
return (
"xAI Imagine storage is enabled so generated media gets a reusable "
f"public URL {retention}. xAI may bill for stored files and public URL "
f"hosting. Disable this with `{section_name}.xai.storage.enabled: false` "
"or set `expires_after` to change the retention."
)
def maybe_mark_xai_storage_notice_seen(section_name: str) -> Optional[str]:
"""Return the storage notice once per Hermes home, then mark it seen."""
notice = xai_storage_notice_text(section_name)
if not notice:
return None
try:
from hermes_constants import get_hermes_home
marker_dir = get_hermes_home() / "state"
marker_dir.mkdir(parents=True, exist_ok=True)
marker = marker_dir / f"{section_name}_xai_storage_notice_seen"
if marker.exists():
return None
marker.write_text(datetime.datetime.now(datetime.UTC).isoformat() + "\n")
return notice
except Exception:
return notice
def resolve_xai_http_credentials(*, force_refresh: bool = False) -> Dict[str, str]:
"""Resolve bearer credentials for direct xAI HTTP endpoints.
@@ -237,21 +88,6 @@ def resolve_xai_http_credentials(*, force_refresh: bool = False) -> Dict[str, st
tokens where the proactive JWT check is a no-op, etc.), not as a default —
the auth-store lock is held for the duration of the refresh.
"""
try:
from hermes_cli.auth import resolve_xai_oauth_runtime_credentials
creds = resolve_xai_oauth_runtime_credentials(force_refresh=force_refresh)
access_token = str(creds.get("api_key") or "").strip()
base_url = str(creds.get("base_url") or "").strip().rstrip("/")
if access_token:
return {
"provider": "xai-oauth",
"api_key": access_token,
"base_url": base_url or "https://api.x.ai/v1",
}
except Exception:
pass
if not force_refresh:
try:
from hermes_cli.runtime_provider import resolve_runtime_provider
@@ -268,6 +104,21 @@ def resolve_xai_http_credentials(*, force_refresh: bool = False) -> Dict[str, st
except Exception:
pass
try:
from hermes_cli.auth import resolve_xai_oauth_runtime_credentials
creds = resolve_xai_oauth_runtime_credentials(force_refresh=force_refresh)
access_token = str(creds.get("api_key") or "").strip()
base_url = str(creds.get("base_url") or "").strip().rstrip("/")
if access_token:
return {
"provider": "xai-oauth",
"api_key": access_token,
"base_url": base_url or "https://api.x.ai/v1",
}
except Exception:
pass
api_key = str(get_env_value("XAI_API_KEY") or "").strip()
base_url = str(get_env_value("XAI_BASE_URL") or "https://api.x.ai/v1").strip().rstrip("/")
return {
-209
View File
@@ -1,209 +0,0 @@
#!/usr/bin/env python3
"""xAI-specific Imagine video edit and extend tools."""
from __future__ import annotations
import json
from typing import Any, Dict, Optional
from hermes_cli.config import load_config
from plugins.video_gen.xai import (
has_xai_video_credentials,
run_xai_video_edit,
run_xai_video_extend,
)
from tools.registry import registry, tool_error
def _configured_for_xai_video() -> bool:
try:
cfg = load_config()
except Exception:
return False
section = cfg.get("video_gen") if isinstance(cfg, dict) else None
return isinstance(section, dict) and section.get("provider") == "xai"
def _check_xai_video_requirements() -> bool:
return _configured_for_xai_video() and has_xai_video_credentials()
def _clean_string(value: Any) -> Optional[str]:
if isinstance(value, str) and value.strip():
return value.strip()
return None
def _coerce_int(value: Any) -> Optional[int]:
if value is None:
return None
if isinstance(value, bool):
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _provider_not_configured_error() -> str:
return json.dumps({
"success": False,
"error": (
"xAI video edit/extend tools require `video_gen.provider` to be "
"configured as `xai` via `hermes tools` -> Video Generation."
),
"error_type": "provider_not_configured",
"provider": "xai",
})
def _normalize_public_video_url(video_url: Any) -> Optional[str]:
"""Require a public HTTPS MP4 URL (``http``/``https`` only)."""
cleaned = _clean_string(video_url)
if not cleaned:
return None
if cleaned.lower().startswith(("http://", "https://")):
return cleaned
return None
XAI_VIDEO_EDIT_SCHEMA: Dict[str, Any] = {
"name": "xai_video_edit",
"description": (
"Edit an existing video with xAI Imagine. This is separate from "
"`video_generate` because video editing is provider-specific. "
"`video_url` must be the public HTTPS MP4 URL from a prior Imagine "
"result (`video` or `public_url` on files-cdn)."
),
"parameters": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "Instruction for how xAI should modify the source video.",
},
"video_url": {
"type": "string",
"description": (
"Public HTTPS MP4 URL of the source video — the `video` or "
"`public_url` from a prior xAI Imagine result."
),
},
"model": {
"type": "string",
"description": "Optional xAI Imagine model override.",
},
},
"required": ["prompt", "video_url"],
},
}
XAI_VIDEO_EXTEND_SCHEMA: Dict[str, Any] = {
"name": "xai_video_extend",
"description": (
"Extend an existing video with xAI Imagine. This is separate from "
"`video_generate` because video extension is provider-specific. "
"`video_url` must be the public HTTPS MP4 URL from a prior Imagine "
"result (`video` or `public_url` on files-cdn)."
),
"parameters": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "Instruction for how xAI should continue the source video.",
},
"video_url": {
"type": "string",
"description": (
"Public HTTPS MP4 URL of the source video — the `video` or "
"`public_url` from a prior xAI Imagine result."
),
},
"duration": {
"type": "integer",
"description": (
"Desired extension duration in seconds. xAI clamps this "
"to its supported range."
),
},
"model": {
"type": "string",
"description": "Optional xAI Imagine model override.",
},
},
"required": ["prompt", "video_url"],
},
}
def _handle_xai_video_edit(args: Dict[str, Any], **_kw: Any) -> str:
prompt = _clean_string(args.get("prompt"))
video_url = _normalize_public_video_url(args.get("video_url"))
model = _clean_string(args.get("model"))
if not prompt:
return tool_error("prompt is required for xAI video edit")
if not video_url:
return tool_error(
"video_url must be a public HTTPS MP4 URL (the `video`/`public_url` "
"from a prior Imagine result)"
)
if not _configured_for_xai_video():
return _provider_not_configured_error()
result = run_xai_video_edit(
prompt=prompt,
video_url=video_url,
model=model,
)
return json.dumps(result)
def _handle_xai_video_extend(args: Dict[str, Any], **_kw: Any) -> str:
prompt = _clean_string(args.get("prompt"))
video_url = _normalize_public_video_url(args.get("video_url"))
model = _clean_string(args.get("model"))
duration = _coerce_int(args.get("duration"))
if not prompt:
return tool_error("prompt is required for xAI video extend")
if not video_url:
return tool_error(
"video_url must be a public HTTPS MP4 URL (the `video`/`public_url` "
"from a prior Imagine result)"
)
if not _configured_for_xai_video():
return _provider_not_configured_error()
result = run_xai_video_extend(
prompt=prompt,
video_url=video_url,
duration=duration,
model=model,
)
return json.dumps(result)
registry.register(
name="xai_video_edit",
toolset="video_gen",
schema=XAI_VIDEO_EDIT_SCHEMA,
handler=_handle_xai_video_edit,
check_fn=_check_xai_video_requirements,
requires_env=[],
is_async=False,
emoji="video",
)
registry.register(
name="xai_video_extend",
toolset="video_gen",
schema=XAI_VIDEO_EXTEND_SCHEMA,
handler=_handle_xai_video_extend,
check_fn=_check_xai_video_requirements,
requires_env=[],
is_async=False,
emoji="video",
)
+2 -3
View File
@@ -139,11 +139,10 @@ TOOLSETS = {
"description": (
"Video generation tools. Single ``video_generate`` tool covers "
"text-to-video (prompt only) and image-to-video (prompt + "
"image_url), plus reference-to-video. Provider-specific edit/"
"extend workflows may appear as separate tools. Configure via "
"image_url) — the active backend auto-routes. Configure via "
"``hermes tools`` → Video Generation."
),
"tools": ["video_generate", "xai_video_edit", "xai_video_extend"],
"tools": ["video_generate"],
"includes": []
},
+2 -2
View File
@@ -3160,9 +3160,9 @@ def _session_info(agent, session: dict | None = None) -> dict:
def _tool_ctx(name: str, args: dict) -> str:
try:
from agent.display import build_tool_label
from agent.display import build_tool_preview
return build_tool_label(name, args, max_len=80) or ""
return build_tool_preview(name, args, max_len=80) or ""
except Exception:
return ""
-70
View File
@@ -382,7 +382,6 @@ def register(ctx):
| [`post_tool_call`](#post_tool_call) | After any tool returns | ignored |
| [`pre_llm_call`](#pre_llm_call) | Once per turn, before the tool-calling loop | `{"context": str}` to prepend context to the user message |
| [`post_llm_call`](#post_llm_call) | Once per turn, after the tool-calling loop | ignored |
| [`pre_verify`](#pre_verify) | Once per turn when the agent edited code, before it verifies/finishes | `{"action": "continue", "message": str}` to keep going |
| [`on_session_start`](#on_session_start) | New session created (first turn only) | ignored |
| [`on_session_end`](#on_session_end) | Session ends | ignored |
| [`on_session_finalize`](#on_session_finalize) | CLI/gateway tears down an active session (flush, save, stats) | ignored |
@@ -653,71 +652,6 @@ def register(ctx):
---
### `pre_verify`
Fires **once per turn when the agent edited code**, just before it finishes (after the built-in verify-on-stop guard). This is a user/plugin policy gate: a callback can keep the agent going — run a check, defer it, tidy the diff — instead of letting it stop.
Hermes' shipped verification guidance is not a default `pre_verify` hook. It is appended to the evidence-based verify-on-stop nudge when edited code lacks fresh verification evidence, so it does not create a second default continuation path. Set `agent.verify_guidance: false` to keep that built-in evidence nudge terse.
**Callback signature:**
```python
def my_callback(session_id: str, platform: str, model: str, coding: bool,
attempt: int, final_response: str, changed_paths: list, **kwargs):
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `session_id` | `str` | Unique identifier for the current session |
| `platform` | `str` | Where the session is running (`"cli"`, `"telegram"`, …) |
| `model` | `str` | The model identifier |
| `coding` | `bool` | Whether the turn is in the coding posture (in a code workspace) — scope your hook on this |
| `attempt` | `int` | How many times this turn has already been nudged (0 on the first) — self-throttle on this |
| `final_response` | `str` | The answer the agent is about to deliver |
| `changed_paths` | `list` | Files the agent edited this turn (sorted, always non-empty here) |
Scope a hook to the coding context by checking `coding` and make it one-shot with `attempt` (shell hooks read both from `.extra`), the same way a `pre_tool_call` hook scopes on `tool_name` — so you can register several `pre_verify` hooks, each firing only where it should.
**Fires:** In `agent/conversation_loop.py`, at the point the agent would accept a final answer, immediately after the verify-on-stop check — but only when the agent edited code this turn and at least one `pre_verify` hook is registered.
**Return value — keep the agent going:**
```python
return {"action": "continue", "message": "Run the formatter on your changes, then finish."}
```
The `message` is appended as a synthetic user turn and the loop runs again. The Claude-Code Stop shape (`{"decision": "block", "reason": "..."}`, where blocking the stop means *keep going*) is accepted too. A directive with no message — or any other return — lets the turn finish.
**Bounded:** consecutive continue directives in one turn are capped by `agent.max_verify_nudges` (default 3), so a hook that always says continue can never trap the loop. The attempted answer is kept in history but not surfaced to the user while the agent is being nudged.
**Make it idempotent:** the hook re-fires after each nudge, so gate on `attempt` (`if attempt: return None`) — otherwise it just nudges until the bound is hit.
**Use cases:** defer tests/lints during creative iteration, require green checks for certain paths, block "done" until a changelog entry exists, run a project-specific verification checklist.
**Example — defer checks on creative UI work, scoped + one-shot:**
```python
UI = (".tsx", ".jsx", ".css", ".scss")
def defer_ui_checks(coding, attempt, changed_paths, **kwargs):
if attempt or not coding:
return None # one-shot, coding only
if not all(p.endswith(UI) for p in changed_paths):
return None # only pure-UI edits
return {
"action": "continue",
"message": "This is UI work — don't run tests/lints yet; ask the user to "
"eyeball it first, and clean the diff before any commit.",
}
def register(ctx):
ctx.register_hook("pre_verify", defer_ui_checks)
```
For standing guidance that should shape the built-in missing-evidence nudge, use `agent.verify_guidance`. For broader coding posture rules that don't need to *gate* verification, prefer `agent.coding_instructions` in `config.yaml` — it rides the coding brief and costs no extra turn.
---
### `on_session_start`
Fires **once** when a brand-new session is created. Does **not** fire on session continuation (when the user sends a second message in an existing session).
@@ -1350,10 +1284,6 @@ Each time the event fires, Hermes spawns a subprocess for every matching hook (m
// Inject context for pre_llm_call:
{"context": "Today is Friday, 2026-04-17"}
// Keep the agent going at the verify gate (pre_verify); both shapes accepted:
{"action": "continue", "message": "Run the formatter, then finish."}
{"decision": "block", "reason": "Run the formatter, then finish."}
// Silent no-op — any empty / non-matching output is fine:
```

Some files were not shown because too many files have changed in this diff Show More