mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-04 01:05:21 +08:00
Compare commits
285 Commits
feat/deskt
...
bb/ableton
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9335a24f49 | ||
|
|
42bea9e298 | ||
|
|
d40b5735a4 | ||
|
|
9d225fbf4e | ||
|
|
92b5987ca2 | ||
|
|
0d777453fa | ||
|
|
e4d026aa3b | ||
|
|
b82c83d320 | ||
|
|
751adfa6b9 | ||
|
|
ff8920299c | ||
|
|
ca714f6189 | ||
|
|
0654319644 | ||
|
|
d9bd7ce827 | ||
|
|
2107b86024 | ||
|
|
510bf40705 | ||
|
|
2a1e615565 | ||
|
|
d7021af30f | ||
|
|
4efec63a34 | ||
|
|
2c02583c2b | ||
|
|
525ee58b43 | ||
|
|
150afea942 | ||
|
|
73c8d5a1e7 | ||
|
|
1a38a8ff7d | ||
|
|
edf35918be | ||
|
|
e8561d61e6 | ||
|
|
da73223f4a | ||
|
|
1ca1f9f2c7 | ||
|
|
3bf00e459a | ||
|
|
c4c590e4a1 | ||
|
|
5de8a8fbe8 | ||
|
|
6208d6b3be | ||
|
|
0be10607d9 | ||
|
|
d682f320b3 | ||
|
|
c210e23a02 | ||
|
|
6305ac0e4b | ||
|
|
0aea0c3654 | ||
|
|
a53fc78c02 | ||
|
|
15ee2d6f04 | ||
|
|
d93abd75d1 | ||
|
|
8278d82e17 | ||
|
|
931a5e92cc | ||
|
|
70319626a9 | ||
|
|
2d286a6d00 | ||
|
|
88e01d92e6 | ||
|
|
1d9ed7f48a | ||
|
|
a6a28ce3e2 | ||
|
|
d6269da7fd | ||
|
|
e62afaca62 | ||
|
|
60a2feeebf | ||
|
|
6f2b2a1f34 | ||
|
|
736e981abf | ||
|
|
d6cf383d74 | ||
|
|
d0df264213 | ||
|
|
f3372d3407 | ||
|
|
d0f9c4bcc6 | ||
|
|
818f03cdd8 | ||
|
|
6b3ea2cea6 | ||
|
|
5196575d40 | ||
|
|
4362c1a3af | ||
|
|
f3d6d9bbd3 | ||
|
|
3af22c0ed5 | ||
|
|
a5849917a8 | ||
|
|
25c31cab62 | ||
|
|
7078d9d1e2 | ||
|
|
a8e6a4f00b | ||
|
|
41f302fa73 | ||
|
|
7a65800fed | ||
|
|
72ae163250 | ||
|
|
0c442fa1d3 | ||
|
|
e92b5c6af8 | ||
|
|
380d660cab | ||
|
|
d473e5d07a | ||
|
|
1512bad0bc | ||
|
|
da0320bf40 | ||
|
|
a5a2edd451 | ||
|
|
2f1a47b90e | ||
|
|
7ef0f360d0 | ||
|
|
f0beb6f617 | ||
|
|
fcbdf3c356 | ||
|
|
b177d4ee48 | ||
|
|
b693bee100 | ||
|
|
98f3c19282 | ||
|
|
c06ceb3232 | ||
|
|
1b181724fa | ||
|
|
532b7ed408 | ||
|
|
281b333cc5 | ||
|
|
f2c45e2c81 | ||
|
|
cbe5c5689f | ||
|
|
0c3f197cff | ||
|
|
c15945655f | ||
|
|
411faf08bd | ||
|
|
a4fa1481e2 | ||
|
|
d1cac0e5ef | ||
|
|
96af4bec30 | ||
|
|
4aeaba6922 | ||
|
|
7e2db0a140 | ||
|
|
17beb55e3c | ||
|
|
284be6cc24 | ||
|
|
7157b213f5 | ||
|
|
153ad79524 | ||
|
|
a05a9b0e07 | ||
|
|
2ea94c6c45 | ||
|
|
d635a6d507 | ||
|
|
42e14d1089 | ||
|
|
b649cdee4a | ||
|
|
538c419d2e | ||
|
|
f1617a7ebb | ||
|
|
592c462e3c | ||
|
|
9a4600c5fb | ||
|
|
65b13e9dbc | ||
|
|
463bf2be25 | ||
|
|
cb6edbf448 | ||
|
|
a6485bddb8 | ||
|
|
1fe013ee16 | ||
|
|
d335164833 | ||
|
|
a378b1e980 | ||
|
|
4127332f15 | ||
|
|
70650e82a3 | ||
|
|
9a94865552 | ||
|
|
93192059c9 | ||
|
|
2a75c4a8cb | ||
|
|
8d1706ae5c | ||
|
|
41b9b7e719 | ||
|
|
cbd6ba1bdd | ||
|
|
a268dfff0a | ||
|
|
404b06ac4f | ||
|
|
cedbb4cfa2 | ||
|
|
085096fd59 | ||
|
|
7d2c1f3f84 | ||
|
|
710cd48fb1 | ||
|
|
dbf0797335 | ||
|
|
8d1f6debfd | ||
|
|
77d2b50751 | ||
|
|
4d589b1e13 | ||
|
|
489b85ee1e | ||
|
|
e25b56fc64 | ||
|
|
1e4df599ec | ||
|
|
7a79a4447c | ||
|
|
8f0a12ce09 | ||
|
|
9c994377ed | ||
|
|
aacc6bb0a8 | ||
|
|
ed1fdb5b61 | ||
|
|
e0272cfef2 | ||
|
|
59acaa972f | ||
|
|
6800fd6608 | ||
|
|
cae1ee44a7 | ||
|
|
25e2312230 | ||
|
|
b13e2fd694 | ||
|
|
b674f7ba28 | ||
|
|
9214aa7dde | ||
|
|
0225480369 | ||
|
|
743985bf1e | ||
|
|
aab49f6927 | ||
|
|
3faf768cde | ||
|
|
32f837add1 | ||
|
|
de281bcebc | ||
|
|
5b065e32ed | ||
|
|
a130b62678 | ||
|
|
2de7549fe0 | ||
|
|
b41d9b845d | ||
|
|
35e9c63d89 | ||
|
|
6638199c53 | ||
|
|
7e55b934ea | ||
|
|
d8fe1c0b41 | ||
|
|
6da615c77c | ||
|
|
9259d1e5da | ||
|
|
c42d44cb2f | ||
|
|
7fb2027d85 | ||
|
|
f477f892b3 | ||
|
|
fce2af780f | ||
|
|
1a435a6d5d | ||
|
|
b85c460540 | ||
|
|
2187fd884c | ||
|
|
1a174dfb50 | ||
|
|
ae20c3fb90 | ||
|
|
6879d77d74 | ||
|
|
d68a133458 | ||
|
|
7634488074 | ||
|
|
4f521a5382 | ||
|
|
ab9134bf16 | ||
|
|
721cf54fb1 | ||
|
|
f0c5d812b0 | ||
|
|
ac822e4d36 | ||
|
|
a4a74ca9e9 | ||
|
|
d398076c21 | ||
|
|
7243111c57 | ||
|
|
66a0907c95 | ||
|
|
89540d592b | ||
|
|
33926eb315 | ||
|
|
8446c15706 | ||
|
|
c93b9f9057 | ||
|
|
3c75e11571 | ||
|
|
a911bcda18 | ||
|
|
98224ce8b6 | ||
|
|
abc3662bf6 | ||
|
|
73a20a6ad6 | ||
|
|
47fccc0735 | ||
|
|
ba50787180 | ||
|
|
2ee6449fe5 | ||
|
|
be78fbd70e | ||
|
|
4aa793345e | ||
|
|
0ef86febe2 | ||
|
|
7ff48a6291 | ||
|
|
0957d77187 | ||
|
|
81d2dc5d0f | ||
|
|
53f8386587 | ||
|
|
284d06cabf | ||
|
|
3dfbc0ad1d | ||
|
|
d4be583d98 | ||
|
|
dbe14ce35d | ||
|
|
281a439ad4 | ||
|
|
f504aecffe | ||
|
|
050bd01b7b | ||
|
|
901165b5a4 | ||
|
|
0d4cecb352 | ||
|
|
31bced1607 | ||
|
|
fa2f0bf3da | ||
|
|
366c2a3766 | ||
|
|
776f68e1ee | ||
|
|
d93d0aee83 | ||
|
|
78e122ae1a | ||
|
|
c39b2b50ee | ||
|
|
3d56807fbd | ||
|
|
044996e403 | ||
|
|
d539cd9004 | ||
|
|
8e7e104521 | ||
|
|
a39283bf09 | ||
|
|
60d3b8cbce | ||
|
|
7f1c278db8 | ||
|
|
b60260c61a | ||
|
|
0952acbf4d | ||
|
|
06cbc3bae9 | ||
|
|
34bd6a0db5 | ||
|
|
23683c3353 | ||
|
|
935f2bc48d | ||
|
|
4ea3096a85 | ||
|
|
667a9f5139 | ||
|
|
3e508363f7 | ||
|
|
6e88f7b6f7 | ||
|
|
6ef679420e | ||
|
|
6afeea2bea | ||
|
|
e495b33bf1 | ||
|
|
40fddc9e4c | ||
|
|
433db17c0a | ||
|
|
0ba1dfed78 | ||
|
|
807bdc17f6 | ||
|
|
89538d47b8 | ||
|
|
b56aafc2ef | ||
|
|
5511fcf944 | ||
|
|
0c79992db5 | ||
|
|
292a456c06 | ||
|
|
74265c8e84 | ||
|
|
9e924f79a8 | ||
|
|
e32ebc6aa2 | ||
|
|
190b01c553 | ||
|
|
4b7f3826c2 | ||
|
|
aaa2e2cb88 | ||
|
|
e155ca20ea | ||
|
|
02050859f3 | ||
|
|
23c47371d2 | ||
|
|
64131bf975 | ||
|
|
221cd60242 | ||
|
|
72bfc48e63 | ||
|
|
da80ac0042 | ||
|
|
70d28b62fb | ||
|
|
6cc07b6cd0 | ||
|
|
f32be4439c | ||
|
|
97888fed48 | ||
|
|
0089bd820f | ||
|
|
9fd2b2cb9f | ||
|
|
a0471e2464 | ||
|
|
c820eb6a5a | ||
|
|
05c896cf52 | ||
|
|
56b4ef74a6 | ||
|
|
2977e74543 | ||
|
|
45540cfb5e | ||
|
|
351afd353d | ||
|
|
a4e61ddf04 | ||
|
|
791c992b55 | ||
|
|
5342eccf12 | ||
|
|
6fd839ac84 | ||
|
|
86b990fe0f | ||
|
|
75b36a138f | ||
|
|
83aa84ae3b | ||
|
|
e7dbfdaad7 |
62
.github/actions/detect-changes/action.yml
vendored
Normal file
62
.github/actions/detect-changes/action.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: Detect affected areas
|
||||
description: >-
|
||||
Classify a PR's changed files into CI work lanes (python, frontend, site,
|
||||
scan, deps, mcp_catalog) so the orchestrator can conditionally call only
|
||||
the sub-workflows a PR can affect. Outputs are always "true" on push/dispatch
|
||||
events and fail open (everything "true") when the diff cannot be computed.
|
||||
|
||||
outputs:
|
||||
python:
|
||||
description: Run Python tests / ruff / ty / windows-footguns.
|
||||
value: ${{ steps.classify.outputs.python }}
|
||||
frontend:
|
||||
description: Run the TypeScript typecheck matrix + desktop build.
|
||||
value: ${{ steps.classify.outputs.frontend }}
|
||||
docker_meta:
|
||||
description: Docker setup and meta files have changed.
|
||||
value: ${{ steps.classify.outputs.docker_meta }}
|
||||
site:
|
||||
description: Build the Docusaurus docs site.
|
||||
value: ${{ steps.classify.outputs.site }}
|
||||
scan:
|
||||
description: Run the supply-chain critical-pattern scanner.
|
||||
value: ${{ steps.classify.outputs.scan }}
|
||||
deps:
|
||||
description: Check pyproject.toml dependency upper bounds.
|
||||
value: ${{ steps.classify.outputs.deps }}
|
||||
mcp_catalog:
|
||||
description: Require MCP catalog security review label.
|
||||
value: ${{ steps.classify.outputs.mcp_catalog }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Classify changed files
|
||||
id: classify
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Only pull_request events are gated. Other events (push, release,
|
||||
# dispatch) leave CHANGED empty, so the classifier fails open and every
|
||||
# lane runs. Post-merge / on-demand validation is never weakened.
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
# Use the compare endpoint with the pinned base/head SHAs from the
|
||||
# event payload instead of the "current PR files" endpoint. The SHAs
|
||||
# are frozen at trigger time, so the file list is deterministic even
|
||||
# if the PR receives a new push between trigger and detect.
|
||||
CHANGED="$(gh api \
|
||||
--paginate \
|
||||
"repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
|
||||
--jq '.files[].filename' || true)"
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
printf '%s\n' "${CHANGED:-(none)}"
|
||||
printf '%s\n' "${CHANGED:-}" | python3 scripts/ci/classify_changes.py
|
||||
50
.github/actions/retry/action.yml
vendored
Normal file
50
.github/actions/retry/action.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Retry a flaky command
|
||||
description: >-
|
||||
Run a shell command, retrying on non-zero exit. For dependency installs
|
||||
(npm ci, uv sync) whose only failures are transient network/toolchain
|
||||
flakes — a node-gyp header fetch, a registry blip — so CI self-heals
|
||||
instead of needing a manual re-run.
|
||||
|
||||
inputs:
|
||||
command:
|
||||
description: Shell command to run (and retry).
|
||||
required: true
|
||||
attempts:
|
||||
description: Max attempts before giving up.
|
||||
default: "3"
|
||||
delay:
|
||||
description: Seconds to wait between attempts.
|
||||
default: "10"
|
||||
working-directory:
|
||||
description: Directory to run in.
|
||||
default: "."
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
# command goes through env, never interpolated into the script body, so
|
||||
# a command with quotes/specials can't break or inject into the runner.
|
||||
env:
|
||||
_CMD: ${{ inputs.command }}
|
||||
_ATTEMPTS: ${{ inputs.attempts }}
|
||||
_DELAY: ${{ inputs.delay }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
n=0
|
||||
while :; do
|
||||
n=$((n + 1))
|
||||
echo "::group::attempt $n/$_ATTEMPTS: $_CMD"
|
||||
if bash -c "$_CMD"; then
|
||||
echo "::endgroup::"
|
||||
exit 0
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
if [ "$n" -ge "$_ATTEMPTS" ]; then
|
||||
echo "::error::failed after $n attempts: $_CMD"
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::attempt $n failed; retrying in ${_DELAY}s: $_CMD"
|
||||
sleep "$_DELAY"
|
||||
done
|
||||
100
.github/workflows/build-windows-installer.yml
vendored
100
.github/workflows/build-windows-installer.yml
vendored
@@ -1,100 +0,0 @@
|
||||
name: Build Windows Installer
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Gate: workflow_dispatch is already restricted to users with write access,
|
||||
# but we want ADMIN-only. Explicitly check the triggering actor's repo
|
||||
# permission via the API and fail fast for anyone below admin.
|
||||
authorize:
|
||||
name: Authorize (admins only)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check actor is a repo admin
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ACTOR: ${{ github.actor }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
perm=$(gh api \
|
||||
"repos/${{ github.repository }}/collaborators/${ACTOR}/permission" \
|
||||
--jq '.permission')
|
||||
echo "Actor '${ACTOR}' has permission: ${perm}"
|
||||
if [ "${perm}" != "admin" ]; then
|
||||
echo "::error::'${ACTOR}' is not a repo admin (permission=${perm}). Refusing to build/sign."
|
||||
exit 1
|
||||
fi
|
||||
echo "Authorized: '${ACTOR}' is an admin."
|
||||
|
||||
build:
|
||||
name: Hermes-Setup.exe
|
||||
needs: authorize
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
# Required for OIDC auth to Azure (azure/login federated credentials).
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
|
||||
- name: Cache Rust targets
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
workspaces: apps/bootstrap-installer/src-tauri
|
||||
|
||||
- name: Build installer
|
||||
run: npm run tauri:build
|
||||
working-directory: apps/bootstrap-installer
|
||||
|
||||
- name: Azure login (OIDC)
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Sign Hermes-Setup.exe with Azure Artifact Signing
|
||||
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2
|
||||
with:
|
||||
endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }}
|
||||
signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile-name: ${{ vars.AZURE_SIGNING_CERTIFICATE_PROFILE }}
|
||||
# Sign both the raw exe and the bundled NSIS installer.
|
||||
files-folder: ${{ github.workspace }}\apps\bootstrap-installer\src-tauri\target\release
|
||||
files-folder-filter: exe
|
||||
files-folder-recurse: true
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Upload NSIS installer
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Hermes-Setup-installer
|
||||
path: apps/bootstrap-installer/src-tauri/target/release/bundle/nsis/*.exe
|
||||
|
||||
- name: Upload raw exe
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Hermes-Setup-exe
|
||||
path: apps/bootstrap-installer/src-tauri/target/release/Hermes-Setup.exe
|
||||
145
.github/workflows/ci.yml
vendored
Normal file
145
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,145 @@
|
||||
name: CI
|
||||
|
||||
# Orchestrator workflow. Runs ``detect-changes`` once, then conditionally
|
||||
# calls the sub-workflows that a PR can actually affect. A final
|
||||
# ``all-checks-pass`` gate job aggregates results so branch protection only
|
||||
# needs to require a single check.
|
||||
#
|
||||
# Sub-workflows are triggered via ``workflow_call`` and keep their own job
|
||||
# definitions, matrices, and concurrency settings. They no longer have
|
||||
# ``push:`` / ``pull_request:`` triggers of their own — everything flows
|
||||
# through this file.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment)
|
||||
actions: read # needed by osv-scanner (SARIF upload)
|
||||
security-events: write # needed by osv-scanner (SARIF upload)
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# detect: run the classifier once. Every downstream job reads its outputs
|
||||
# to decide whether to run. On push/dispatch the classifier fails open
|
||||
# (all lanes true) so post-merge validation is never weakened.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
detect:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
python: ${{ steps.classify.outputs.python }}
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
site: ${{ steps.classify.outputs.site }}
|
||||
scan: ${{ steps.classify.outputs.scan }}
|
||||
deps: ${{ steps.classify.outputs.deps }}
|
||||
docker_meta: ${{ steps.classify.outputs.docker_meta }}
|
||||
mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }}
|
||||
event_name: ${{ github.event_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Detect affected areas
|
||||
id: classify
|
||||
uses: ./.github/actions/detect-changes
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
|
||||
# Skipped workflows (if condition is false) don't spin up runners.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
tests:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true'
|
||||
uses: ./.github/workflows/tests.yml
|
||||
|
||||
lint:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true'
|
||||
uses: ./.github/workflows/lint.yml
|
||||
with:
|
||||
event_name: ${{ needs.detect.outputs.event_name }}
|
||||
|
||||
typecheck:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.frontend == 'true'
|
||||
uses: ./.github/workflows/typecheck.yml
|
||||
|
||||
docs-site:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.site == 'true'
|
||||
uses: ./.github/workflows/docs-site-checks.yml
|
||||
|
||||
history-check:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.event_name == 'pull_request'
|
||||
uses: ./.github/workflows/history-check.yml
|
||||
|
||||
contributor-check:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true'
|
||||
uses: ./.github/workflows/contributor-check.yml
|
||||
|
||||
uv-lockfile:
|
||||
needs: detect
|
||||
uses: ./.github/workflows/uv-lockfile-check.yml
|
||||
|
||||
docker-lint:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.docker_meta == 'true'
|
||||
uses: ./.github/workflows/docker-lint.yml
|
||||
|
||||
supply-chain:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true' || needs.detect.outputs.mcp_catalog == 'true')
|
||||
uses: ./.github/workflows/supply-chain-audit.yml
|
||||
with:
|
||||
event_name: ${{ needs.detect.outputs.event_name }}
|
||||
scan: ${{ needs.detect.outputs.scan == 'true' }}
|
||||
deps: ${{ needs.detect.outputs.deps == 'true' }}
|
||||
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
|
||||
|
||||
osv-scanner:
|
||||
needs: detect
|
||||
uses: ./.github/workflows/osv-scanner.yml
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Gate: runs after everything. ``if: always()`` ensures it reports a
|
||||
# status even when some deps were skipped. Only actual ``failure``
|
||||
# results cause it to fail; ``skipped`` is treated as success.
|
||||
#
|
||||
# Branch protection should require ONLY this check.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
all-checks-pass:
|
||||
name: All required checks pass
|
||||
needs:
|
||||
- tests
|
||||
- lint
|
||||
- typecheck
|
||||
- docs-site
|
||||
- history-check
|
||||
- contributor-check
|
||||
- uv-lockfile
|
||||
- docker-lint
|
||||
- supply-chain
|
||||
- osv-scanner
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Evaluate job results
|
||||
env:
|
||||
RESULTS: ${{ toJSON(needs.*.result) }}
|
||||
run: |
|
||||
echo "$RESULTS" | python3 -c "
|
||||
import json, sys
|
||||
results = json.load(sys.stdin)
|
||||
failed = [r for r in results if r == 'failure']
|
||||
if failed:
|
||||
print(f'::error::{len(failed)} job(s) failed')
|
||||
sys.exit(1)
|
||||
print('All checks passed (or were skipped)')
|
||||
"
|
||||
21
.github/workflows/contributor-check.yml
vendored
21
.github/workflows/contributor-check.yml
vendored
@@ -1,11 +1,8 @@
|
||||
name: Contributor Attribution Check
|
||||
|
||||
on:
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -17,21 +14,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git log
|
||||
|
||||
- name: Check if relevant files changed
|
||||
id: filter
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
CHANGED=$(git diff --name-only "$BASE"..."$HEAD" -- '*.py' '**/*.py' '.github/workflows/contributor-check.yml' || true)
|
||||
if [ -n "$CHANGED" ]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No Python files changed, skipping attribution check."
|
||||
fi
|
||||
|
||||
- name: Check for unmapped contributor emails
|
||||
if: steps.filter.outputs.run == 'true'
|
||||
run: |
|
||||
# Get the merge base between this PR and main
|
||||
MERGE_BASE=$(git merge-base origin/main HEAD)
|
||||
|
||||
14
.github/workflows/docker-lint.yml
vendored
14
.github/workflows/docker-lint.yml
vendored
@@ -11,19 +11,7 @@ name: Docker / shell lint
|
||||
# activate script doesn't exist at lint time.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- Dockerfile
|
||||
- docker/**
|
||||
- .hadolint.yaml
|
||||
- .github/workflows/docker-lint.yml
|
||||
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
45
.github/workflows/docker-publish.yml
vendored
45
.github/workflows/docker-publish.yml
vendored
@@ -16,7 +16,6 @@ on:
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
release:
|
||||
types: [published]
|
||||
@@ -56,13 +55,21 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# The image build + smoke test + integration tests run ONLY on
|
||||
# push-to-main and release — never on PRs. They are the heaviest jobs
|
||||
# in CI (~15-45 min) and a broken build surfaces on the main push (and
|
||||
# is gated pre-merge by docker-lint + uv-lockfile-check). Every step
|
||||
# below is skipped on PRs, so the job still reports green and the
|
||||
# required check never hangs.
|
||||
- name: Set up Docker Buildx
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
# Build once, load into the local daemon for smoke testing. Cached
|
||||
# to gha with a per-arch scope; the push step below reuses every
|
||||
# layer from this build.
|
||||
- name: Build image (amd64, smoke test)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
@@ -76,6 +83,7 @@ jobs:
|
||||
cache-to: type=gha,mode=max,scope=docker-amd64
|
||||
|
||||
- name: Smoke test image
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: ./.github/actions/hermes-smoke-test
|
||||
with:
|
||||
image: ${{ env.IMAGE_NAME }}:test
|
||||
@@ -102,12 +110,15 @@ jobs:
|
||||
# cheapest path to coverage on every PR that touches docker code.
|
||||
# ---------------------------------------------------------------------
|
||||
- name: Install uv (for docker tests)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
- name: Set up Python 3.11 (for docker tests)
|
||||
if: github.event_name != 'pull_request'
|
||||
run: uv python install 3.11
|
||||
|
||||
- name: Install Python dependencies (for docker tests)
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
uv venv .venv --python 3.11
|
||||
source .venv/bin/activate
|
||||
@@ -118,6 +129,7 @@ jobs:
|
||||
uv pip install -e ".[dev]"
|
||||
|
||||
- name: Run docker integration tests
|
||||
if: github.event_name != 'pull_request'
|
||||
env:
|
||||
# Skip rebuild; use the image already loaded by the build step.
|
||||
HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
|
||||
@@ -190,7 +202,9 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# arm64 build runs only on push-to-main and release (see build-amd64).
|
||||
- name: Set up Docker Buildx
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
# Log in to ghcr.io so the registry-backed build cache below can be
|
||||
@@ -201,41 +215,21 @@ jobs:
|
||||
# crashed the build before the smoke test (the reason the gha cache
|
||||
# was removed from arm64 PRs in the first place).
|
||||
- name: Log in to ghcr.io (build cache)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Build once, load into the local daemon for smoke testing.
|
||||
#
|
||||
# PR builds use the registry-backed cache READ-ONLY (cache-from only):
|
||||
# they pull warm layers pushed by the most recent main build but never
|
||||
# write, so rapid PR pushes don't race on cache writes or pollute the
|
||||
# cache ref. This restores warm-cache speed to arm64 PR builds (which
|
||||
# were running fully uncached and were ~45% slower than amd64, making
|
||||
# them the job most often cancelled on supersede).
|
||||
# Build once, load into the local daemon for smoke 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, smoke test, cache read-only PR)
|
||||
if: github.event_name == 'pull_request'
|
||||
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
|
||||
|
||||
# Main/release builds read AND write the registry cache so the digest
|
||||
# push below reuses layers from this smoke-test build, and so the next
|
||||
# PR/main build starts warm.
|
||||
- name: Build image (arm64, smoke test, cached publish)
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
@@ -251,6 +245,7 @@ jobs:
|
||||
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
|
||||
|
||||
- name: Smoke test image
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: ./.github/actions/hermes-smoke-test
|
||||
with:
|
||||
image: ${{ env.IMAGE_NAME }}:test
|
||||
|
||||
18
.github/workflows/docs-site-checks.yml
vendored
18
.github/workflows/docs-site-checks.yml
vendored
@@ -1,13 +1,7 @@
|
||||
name: Docs Site Checks
|
||||
|
||||
on:
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -25,15 +19,19 @@ jobs:
|
||||
cache-dependency-path: website/package-lock.json
|
||||
|
||||
- name: Install website dependencies
|
||||
run: npm ci
|
||||
working-directory: website
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci
|
||||
working-directory: website
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install ascii-guard
|
||||
run: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3
|
||||
|
||||
- name: Extract skill metadata for dashboard
|
||||
run: python3 website/scripts/extract-skills.py
|
||||
|
||||
6
.github/workflows/history-check.yml
vendored
6
.github/workflows/history-check.yml
vendored
@@ -14,11 +14,7 @@ name: History Check
|
||||
# the PR head and main to be non-empty.
|
||||
|
||||
on:
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
35
.github/workflows/lint.yml
vendored
35
.github/workflows/lint.yml
vendored
@@ -9,18 +9,12 @@ name: Lint (ruff + ty)
|
||||
# enforcement fails.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- "website/**"
|
||||
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
inputs:
|
||||
event_name:
|
||||
description: The event name from the calling orchestrator (pull_request or push).
|
||||
type: string
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -33,6 +27,7 @@ concurrency:
|
||||
jobs:
|
||||
lint-diff:
|
||||
name: ruff + ty diff
|
||||
if: inputs.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
@@ -45,16 +40,16 @@ jobs:
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
- name: Install ruff + ty
|
||||
run: |
|
||||
uv tool install ruff
|
||||
uv tool install ty
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv tool install ruff && uv tool install ty
|
||||
|
||||
- name: Determine base ref
|
||||
id: base
|
||||
run: |
|
||||
# For PRs, diff against the merge base with the target branch.
|
||||
# For pushes to main, diff against the previous commit on main.
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
if [ "${{ inputs.event_name }}" = "pull_request" ]; then
|
||||
BASE_SHA=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
|
||||
BASE_REF="origin/${{ github.base_ref }}"
|
||||
else
|
||||
@@ -110,7 +105,7 @@ jobs:
|
||||
--base-ty .lint-reports/base/ty.json \
|
||||
--head-ty .lint-reports/head/ty.json \
|
||||
--base-ref "${{ steps.base.outputs.ref }}" \
|
||||
--head-ref "${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \
|
||||
--head-ref "${{ inputs.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \
|
||||
--output .lint-reports/summary.md
|
||||
cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -122,7 +117,7 @@ jobs:
|
||||
retention-days: 14
|
||||
|
||||
- name: Post / update PR comment
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
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:
|
||||
@@ -172,7 +167,9 @@ jobs:
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
- name: Install ruff
|
||||
run: uv tool install ruff
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv tool install ruff
|
||||
|
||||
- name: ruff check .
|
||||
# No --exit-zero, no || true. Exit code propagates to the job,
|
||||
|
||||
24
.github/workflows/osv-scanner.yml
vendored
24
.github/workflows/osv-scanner.yml
vendored
@@ -1,8 +1,8 @@
|
||||
name: OSV-Scanner
|
||||
|
||||
# Scans lockfiles (uv.lock, package-lock.json) against the OSV vulnerability
|
||||
# database. Runs on every PR that touches a lockfile and on a weekly schedule
|
||||
# against main.
|
||||
# database. Runs on every PR/push (via the ci.yml orchestrator's workflow_call)
|
||||
# and on a weekly schedule against main.
|
||||
#
|
||||
# This is detection-only — OSV-Scanner does NOT open PRs or modify pins.
|
||||
# It reports known CVEs in currently-pinned dependency versions so we can
|
||||
@@ -10,9 +10,9 @@ name: OSV-Scanner
|
||||
# (full SHA / exact version) is preserved; only the notification signal
|
||||
# is added.
|
||||
#
|
||||
# Complements the existing supply-chain-audit.yml workflow (which scans
|
||||
# for malicious code patterns in PR diffs) by covering the orthogonal
|
||||
# "currently-pinned dep became known-vulnerable" case.
|
||||
# Complements the supply-chain-audit.yml workflow (which scans for malicious
|
||||
# code patterns in PR diffs) by covering the orthogonal "currently-pinned
|
||||
# dep became known-vulnerable" case.
|
||||
#
|
||||
# Uses Google's officially-recommended reusable workflow, pinned by SHA.
|
||||
# Findings land in the repo's Security tab (Code Scanning > OSV-Scanner).
|
||||
@@ -20,19 +20,7 @@ name: OSV-Scanner
|
||||
# vulnerabilities in pinned deps that we may need to patch deliberately.
|
||||
|
||||
on:
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "uv.lock"
|
||||
- "pyproject.toml"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "website/package-lock.json"
|
||||
workflow_call:
|
||||
schedule:
|
||||
# Weekly scan against main — catches CVEs published after merge for
|
||||
# deps that haven't changed since.
|
||||
|
||||
133
.github/workflows/supply-chain-audit.yml
vendored
133
.github/workflows/supply-chain-audit.yml
vendored
@@ -1,16 +1,5 @@
|
||||
name: Supply Chain Audit
|
||||
|
||||
on:
|
||||
# No paths filter — the jobs must always run so required checks
|
||||
# report a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
# Narrow, high-signal scanner. Only fires on critical indicators of supply
|
||||
# chain attacks (e.g. the litellm-style payloads). Low-signal heuristics
|
||||
# (plain base64, plain exec/eval, dependency/Dockerfile/workflow edits,
|
||||
@@ -19,56 +8,40 @@ permissions:
|
||||
# the scanner. Keep this file's checks ruthlessly narrow: if you find
|
||||
# yourself adding WARNING-tier patterns here again, make a separate
|
||||
# advisory-only workflow instead.
|
||||
#
|
||||
# Path-gating is handled centrally by the ``ci.yml`` orchestrator's
|
||||
# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` /
|
||||
# ``mcp_catalog`` booleans as inputs; this workflow's jobs gate on those
|
||||
# inputs instead of re-computing the diff.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
event_name:
|
||||
description: The event name from the calling orchestrator.
|
||||
type: string
|
||||
required: true
|
||||
scan:
|
||||
description: Whether supply-chain-relevant files changed.
|
||||
type: boolean
|
||||
required: true
|
||||
deps:
|
||||
description: Whether pyproject.toml changed.
|
||||
type: boolean
|
||||
required: true
|
||||
mcp_catalog:
|
||||
description: Whether the MCP catalog / installer changed.
|
||||
type: boolean
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ── Path filter (shared by both scan and dep-bounds) ───────────────
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
# True when any file the scanner cares about changed in this PR
|
||||
scan: ${{ steps.filter.outputs.scan }}
|
||||
# True when pyproject.toml changed in this PR
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
# True when the curated MCP catalog / bundled MCP manifests changed.
|
||||
mcp_catalog: ${{ steps.filter.outputs.mcp_catalog }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check for relevant file changes
|
||||
id: filter
|
||||
run: |
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
SCAN_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
|
||||
'*.py' '**/*.py' '*.pth' '**/*.pth' \
|
||||
'setup.py' 'setup.cfg' \
|
||||
'sitecustomize.py' 'usercustomize.py' '__init__.pth' \
|
||||
'pyproject.toml' || true)
|
||||
if [ -n "$SCAN_FILES" ]; then
|
||||
echo "scan=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "scan=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
DEPS_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- 'pyproject.toml' || true)
|
||||
if [ -n "$DEPS_FILES" ]; then
|
||||
echo "deps=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "deps=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
MCP_CATALOG_FILES=$(git diff --name-only "$BASE"..."$HEAD" -- \
|
||||
'optional-mcps/**' \
|
||||
'hermes_cli/mcp_catalog.py' || true)
|
||||
if [ -n "$MCP_CATALOG_FILES" ]; then
|
||||
echo "mcp_catalog=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "mcp_catalog=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
scan:
|
||||
name: Scan PR for critical supply chain risks
|
||||
needs: changes
|
||||
if: needs.changes.outputs.scan == 'true'
|
||||
if: inputs.scan
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -111,7 +84,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# --- base64 decode + exec/eval on the same line (the litellm attack pattern) ---
|
||||
B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true)
|
||||
B64_EXEC_HITS=$(echo "$DIFF" | grep -n '^+' | grep -iE 'base64\.(b64decode|decodebytes|urlsafe_b64decode)' | grep -iE 'exec\(|eval\(' | head -10 || true)
|
||||
if [ -n "$B64_EXEC_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### 🚨 CRITICAL: base64 decode + exec/eval combo
|
||||
@@ -125,7 +98,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# --- subprocess with encoded/obfuscated command argument ---
|
||||
PROC_HITS=$(echo "$DIFF" | grep -n '^\+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|\\x[0-9a-f]{2}|chr\(' | head -10 || true)
|
||||
PROC_HITS=$(echo "$DIFF" | grep -n '^+' | grep -E 'subprocess\.(Popen|call|run)\s*\(' | grep -iE 'base64|\\x[0-9a-f]{2}|chr\(' | head -10 || true)
|
||||
if [ -n "$PROC_HITS" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### 🚨 CRITICAL: subprocess with encoded/obfuscated command
|
||||
@@ -187,23 +160,9 @@ jobs:
|
||||
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
|
||||
exit 1
|
||||
|
||||
# Gate: reports success when scan was skipped (no relevant files changed).
|
||||
# This ensures the required check always gets a status.
|
||||
scan-gate:
|
||||
name: Scan PR for critical supply chain risks
|
||||
needs: changes
|
||||
# always() so the gate still reports SUCCESS even if `changes` fails/is
|
||||
# skipped — without it, a failed dependency would leave the required
|
||||
# check unreported (i.e. "pending"), the exact failure mode this fixes.
|
||||
if: always() && needs.changes.outputs.scan != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No supply-chain-relevant files changed, skipping scan."
|
||||
|
||||
dep-bounds:
|
||||
name: Check PyPI dependency upper bounds
|
||||
needs: changes
|
||||
if: needs.changes.outputs.deps == 'true'
|
||||
if: inputs.deps
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -253,7 +212,7 @@ jobs:
|
||||
$(cat /tmp/unbounded.txt)
|
||||
\`\`\`
|
||||
|
||||
**Fix:** Add an upper bound, e.g. \`\"package>=1.2.0,<2\"\`
|
||||
**Fix:** Add an upper bound, e.g. \`"package>=1.2.0,<2"\`
|
||||
|
||||
---
|
||||
*See PR #2810 and CONTRIBUTING.md for the full policy rationale.*"
|
||||
@@ -266,23 +225,9 @@ jobs:
|
||||
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
|
||||
exit 1
|
||||
|
||||
# Gate: reports success when dep-bounds was skipped (no pyproject.toml changed).
|
||||
# This ensures the required check always gets a status.
|
||||
dep-bounds-gate:
|
||||
name: Check PyPI dependency upper bounds
|
||||
needs: changes
|
||||
# always() so the gate still reports SUCCESS even if `changes` fails/is
|
||||
# skipped — without it, a failed dependency would leave the required
|
||||
# check unreported (i.e. "pending"), the exact failure mode this fixes.
|
||||
if: always() && needs.changes.outputs.deps != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No pyproject.toml changes, skipping dependency bounds check."
|
||||
|
||||
mcp-catalog-review:
|
||||
name: MCP catalog security review
|
||||
needs: changes
|
||||
if: needs.changes.outputs.mcp_catalog == 'true'
|
||||
if: inputs.mcp_catalog
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -317,11 +262,3 @@ jobs:
|
||||
gh pr comment "$PR" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
|
||||
echo "::error::MCP catalog changes require the mcp-catalog-reviewed label."
|
||||
exit 1
|
||||
|
||||
mcp-catalog-review-gate:
|
||||
name: MCP catalog security review
|
||||
needs: changes
|
||||
if: always() && needs.changes.outputs.mcp_catalog != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "No MCP catalog changes, skipping MCP catalog security review."
|
||||
|
||||
25
.github/workflows/tests.yml
vendored
25
.github/workflows/tests.yml
vendored
@@ -1,21 +1,12 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Cancel in-progress runs for the same PR/branch
|
||||
# Cancel in-progress runs for the same ref
|
||||
concurrency:
|
||||
group: tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -49,7 +40,7 @@ jobs:
|
||||
RG_VERSION=15.1.0
|
||||
RG_SHA256=1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599
|
||||
RG_TARBALL=ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz
|
||||
curl -sSfL -o "$RG_TARBALL" \
|
||||
curl -sSfL --retry 3 --retry-delay 5 -o "$RG_TARBALL" \
|
||||
"https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${RG_TARBALL}"
|
||||
echo "${RG_SHA256} ${RG_TARBALL}" | sha256sum -c -
|
||||
tar -xzf "$RG_TARBALL"
|
||||
@@ -78,7 +69,9 @@ jobs:
|
||||
# fails if the lock is out of sync with pyproject.toml), giving a
|
||||
# reproducible env. It also creates .venv itself, so no separate
|
||||
# `uv venv` step is needed.
|
||||
run: uv sync --locked --python 3.11 --extra all --extra dev
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv sync --locked --python 3.11 --extra all --extra dev
|
||||
|
||||
- name: Minimize uv cache
|
||||
# Optimized for CI: prunes pre-built wheels that are cheap to
|
||||
@@ -171,7 +164,7 @@ jobs:
|
||||
RG_VERSION=15.1.0
|
||||
RG_SHA256=1c9297be4a084eea7ecaedf93eb03d058d6faae29bbc57ecdaf5063921491599
|
||||
RG_TARBALL=ripgrep-${RG_VERSION}-x86_64-unknown-linux-musl.tar.gz
|
||||
curl -sSfL -o "$RG_TARBALL" \
|
||||
curl -sSfL --retry 3 --retry-delay 5 -o "$RG_TARBALL" \
|
||||
"https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${RG_TARBALL}"
|
||||
echo "${RG_SHA256} ${RG_TARBALL}" | sha256sum -c -
|
||||
tar -xzf "$RG_TARBALL"
|
||||
@@ -200,7 +193,9 @@ jobs:
|
||||
# fails if the lock is out of sync with pyproject.toml), giving a
|
||||
# reproducible env. It also creates .venv itself, so no separate
|
||||
# `uv venv` step is needed.
|
||||
run: uv sync --locked --python 3.11 --extra all --extra dev
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv sync --locked --python 3.11 --extra all --extra dev
|
||||
|
||||
- name: Minimize uv cache
|
||||
# Optimized for CI: prunes pre-built wheels that are cheap to
|
||||
|
||||
24
.github/workflows/typecheck.yml
vendored
24
.github/workflows/typecheck.yml
vendored
@@ -2,13 +2,7 @@
|
||||
name: Typecheck
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
@@ -24,7 +18,14 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# --ignore-scripts: typecheck only needs the TS sources + type defs, not
|
||||
# native builds. Skipping install scripts drops node-pty's node-gyp
|
||||
# header fetch — the transient flake that killed this job pre-`tsc` — and
|
||||
# is faster. retry covers the remaining registry blips.
|
||||
-
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci --ignore-scripts
|
||||
- run: npm run --prefix ${{ matrix.package }} typecheck
|
||||
|
||||
# Production build of the desktop renderer. `typecheck` runs `tsc` only,
|
||||
@@ -41,5 +42,10 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Keep install scripts here: the production build may need node-pty's
|
||||
# native binary. retry handles the transient install-time fetch flakes.
|
||||
-
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci
|
||||
- run: npm run --prefix apps/desktop build
|
||||
|
||||
15
.github/workflows/uv-lockfile-check.yml
vendored
15
.github/workflows/uv-lockfile-check.yml
vendored
@@ -44,25 +44,14 @@ name: uv.lock check
|
||||
# the same way. Better to catch it here than after merge.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- ".github/workflows/uv-lockfile-check.yml"
|
||||
|
||||
# No paths filter — the job must always run so the required check
|
||||
# reports a status (path-gated workflows leave checks "pending" forever
|
||||
# when no matching files change, which blocks merge).
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: uv-lockfile-check-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -290,6 +290,19 @@ ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
|
||||
ENV HERMES_HOME=/opt/data
|
||||
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
|
||||
ENV HERMES_DISABLE_LAZY_INSTALLS=1
|
||||
# The published image seals /opt/hermes (root-owned, read-only) so a runtime
|
||||
# lazy install can't mutate the agent's own venv and brick it. But opt-in
|
||||
# backends (Firecrawl web search, Exa, Feishu, …) keep their SDKs in
|
||||
# tools/lazy_deps.py — deliberately NOT baked into [all] (see pyproject.toml
|
||||
# policy 2026-05-12: one quarantined release must not break every install).
|
||||
# Redirect those lazy installs to a writable dir on the durable data volume.
|
||||
# lazy_deps appends this dir to the END of sys.path, so a package installed
|
||||
# here can only ADD modules — it can never shadow or downgrade a core module,
|
||||
# so the sealed-venv guarantee holds even with installs re-enabled. The dir
|
||||
# is seeded + chowned to the hermes user by docker/stage2-hook.sh and lives
|
||||
# on the /opt/data volume, so it persists across container recreates / image
|
||||
# updates (an ABI stamp invalidates it if a rebuild bumps the interpreter).
|
||||
ENV HERMES_LAZY_INSTALL_TARGET=/opt/data/lazy-packages
|
||||
|
||||
# `docker exec` privilege-drop shim. When operators run
|
||||
# `docker exec <c> hermes ...` they default to root, and any file the
|
||||
|
||||
@@ -23,6 +23,11 @@ except ModuleNotFoundError:
|
||||
# new code but ``uv pip install -e .`` didn't finish. Missing bootstrap
|
||||
# means UTF-8 stdio setup is skipped on Windows; POSIX is unaffected.
|
||||
pass
|
||||
else:
|
||||
# Stop a ``utils/``/``proxy/``/``ui/`` package in the launch directory from
|
||||
# shadowing Hermes's own modules — ``hermes acp`` can be started from any
|
||||
# cwd, including a project that has same-named packages on its path.
|
||||
hermes_bootstrap.harden_import_path()
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
@@ -106,7 +106,12 @@ def _custom_provider_extra_body_for_agent(
|
||||
base_url: str,
|
||||
custom_providers: List[Dict[str, Any]],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if (provider or "").strip().lower() != "custom":
|
||||
provider_norm = (provider or "").strip().lower()
|
||||
if provider_norm == "custom":
|
||||
provider_key_filter = ""
|
||||
elif provider_norm.startswith("custom:"):
|
||||
provider_key_filter = provider_norm.split(":", 1)[1].strip()
|
||||
else:
|
||||
return None
|
||||
|
||||
target_url = _normalized_custom_base_url(base_url)
|
||||
@@ -117,6 +122,13 @@ def _custom_provider_extra_body_for_agent(
|
||||
for entry in custom_providers or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if provider_key_filter:
|
||||
entry_keys = {
|
||||
str(entry.get("provider_key", "") or "").strip().lower(),
|
||||
str(entry.get("name", "") or "").strip().lower(),
|
||||
}
|
||||
if provider_key_filter not in entry_keys:
|
||||
continue
|
||||
if _normalized_custom_base_url(entry.get("base_url")) != target_url:
|
||||
continue
|
||||
extra_body = entry.get("extra_body")
|
||||
@@ -1506,6 +1518,7 @@ def init_agent(
|
||||
# 3. Check general plugin system (user-installed plugins)
|
||||
# 4. Fall back to built-in ContextCompressor
|
||||
_selected_engine = None
|
||||
_copy_failed = False
|
||||
_engine_name = "compressor" # default
|
||||
try:
|
||||
_ctx_cfg = _agent_cfg.get("context", {}) if isinstance(_agent_cfg, dict) else {}
|
||||
@@ -1523,15 +1536,35 @@ def init_agent(
|
||||
|
||||
# Try general plugin system as fallback
|
||||
if _selected_engine is None:
|
||||
_candidate = None
|
||||
try:
|
||||
from hermes_cli.plugins import get_plugin_context_engine
|
||||
_candidate = get_plugin_context_engine()
|
||||
if _candidate and _candidate.name == _engine_name:
|
||||
_selected_engine = _candidate
|
||||
except Exception:
|
||||
pass
|
||||
_candidate = None
|
||||
if _candidate is not None and _candidate.name == _engine_name:
|
||||
# Deep-copy the shared plugin singleton so a child agent's
|
||||
# update_model() can't mutate the parent's compressor (#42449).
|
||||
# Copy can fail for engines holding uncopyable state (locks, DB
|
||||
# connections, clients); in that case fall back to the built-in
|
||||
# compressor with an ACCURATE message rather than silently
|
||||
# mislabelling it "not found".
|
||||
import copy
|
||||
try:
|
||||
_selected_engine = copy.deepcopy(_candidate)
|
||||
except Exception as _copy_err:
|
||||
_copy_failed = True
|
||||
_ra().logger.warning(
|
||||
"Context engine '%s' could not be safely copied for this "
|
||||
"agent (%s) — falling back to built-in compressor. Plugin "
|
||||
"engines that hold uncopyable state (locks, DB connections) "
|
||||
"should implement __deepcopy__ to copy only mutable budget "
|
||||
"state.",
|
||||
_engine_name, _copy_err,
|
||||
)
|
||||
_selected_engine = None
|
||||
|
||||
if _selected_engine is None:
|
||||
if _selected_engine is None and not _copy_failed:
|
||||
_ra().logger.warning(
|
||||
"Context engine '%s' not found — falling back to built-in compressor",
|
||||
_engine_name,
|
||||
@@ -1621,16 +1654,27 @@ def init_agent(
|
||||
for t in agent.tools
|
||||
if isinstance(t, dict)
|
||||
}
|
||||
for _schema in agent.context_compressor.get_tool_schemas():
|
||||
_tname = _schema.get("name", "")
|
||||
if _tname and _tname in _existing_tool_names:
|
||||
from agent.memory_manager import normalize_tool_schema as _normalize_tool_schema
|
||||
for _raw_schema in agent.context_compressor.get_tool_schemas():
|
||||
_schema = _normalize_tool_schema(_raw_schema)
|
||||
if _schema is None:
|
||||
# A schema with no resolvable name (e.g. an already-wrapped
|
||||
# entry) would append a nameless tool that strict providers
|
||||
# 400 on, disabling the whole toolset (#47707). Skip it.
|
||||
_ra().logger.warning(
|
||||
"Context engine returned a tool schema with no resolvable "
|
||||
"name; skipping to avoid poisoning the request (%r)",
|
||||
_raw_schema,
|
||||
)
|
||||
continue
|
||||
_tname = _schema["name"]
|
||||
if _tname in _existing_tool_names:
|
||||
continue # already registered via plugin/cache path
|
||||
_wrapped = {"type": "function", "function": _schema}
|
||||
agent.tools.append(_wrapped)
|
||||
if _tname:
|
||||
agent.valid_tool_names.add(_tname)
|
||||
agent._context_engine_tool_names.add(_tname)
|
||||
_existing_tool_names.add(_tname)
|
||||
agent.valid_tool_names.add(_tname)
|
||||
agent._context_engine_tool_names.add(_tname)
|
||||
_existing_tool_names.add(_tname)
|
||||
|
||||
# Notify context engine of session start
|
||||
if hasattr(agent, "context_compressor") and agent.context_compressor:
|
||||
|
||||
@@ -1297,7 +1297,15 @@ def run_oauth_setup_token() -> Optional[str]:
|
||||
# Stores credentials in ~/.hermes/.anthropic_oauth.json (our own file).
|
||||
|
||||
_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
_OAUTH_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
||||
# Anthropic migrated the OAuth token endpoint to platform.claude.com;
|
||||
# console.anthropic.com now 404s. Callers should iterate _OAUTH_TOKEN_URLS
|
||||
# (new host first, console fallback). _OAUTH_TOKEN_URL is kept as the primary
|
||||
# for backward compatibility with existing imports and now points at the live host.
|
||||
_OAUTH_TOKEN_URLS = [
|
||||
"https://platform.claude.com/v1/oauth/token",
|
||||
"https://console.anthropic.com/v1/oauth/token",
|
||||
]
|
||||
_OAUTH_TOKEN_URL = _OAUTH_TOKEN_URLS[0]
|
||||
_OAUTH_REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback"
|
||||
_OAUTH_SCOPES = "org:create_api_key user:profile user:inference"
|
||||
_HERMES_OAUTH_FILE = get_hermes_home() / ".anthropic_oauth.json"
|
||||
@@ -1395,18 +1403,34 @@ def run_hermes_oauth_login_pure() -> Optional[Dict[str, Any]]:
|
||||
"code_verifier": verifier,
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
_OAUTH_TOKEN_URL,
|
||||
data=exchange_data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
# Anthropic migrated the OAuth token endpoint to platform.claude.com;
|
||||
# console.anthropic.com now 404s. Try the new host first, then fall
|
||||
# back to console for older deployments (mirrors the refresh path).
|
||||
result = None
|
||||
last_error = None
|
||||
for endpoint in _OAUTH_TOKEN_URLS:
|
||||
req = urllib.request.Request(
|
||||
endpoint,
|
||||
data=exchange_data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"claude-cli/{_get_claude_code_version()} (external, cli)",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
break
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
logger.debug("Anthropic token exchange failed at %s: %s", endpoint, exc)
|
||||
continue
|
||||
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
if result is None:
|
||||
raise last_error if last_error is not None else ValueError(
|
||||
"Anthropic token exchange failed"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Token exchange failed: {e}")
|
||||
return None
|
||||
|
||||
@@ -101,6 +101,7 @@ class _OpenAIProxy:
|
||||
OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
|
||||
|
||||
from agent.credential_pool import load_pool
|
||||
from agent.model_metadata import MINIMUM_CONTEXT_LENGTH, get_model_context_length
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from hermes_constants import OPENROUTER_BASE_URL
|
||||
from utils import base_url_host_matches, base_url_hostname, env_float, model_forces_max_completion_tokens, normalize_proxy_env_vars
|
||||
@@ -2470,7 +2471,7 @@ def _is_payment_error(exc: Exception) -> bool:
|
||||
# but sometimes wrap them in 429 or other codes.
|
||||
# Daily quota exhaustion from Bedrock, Vertex AI, and similar providers
|
||||
# uses different language but is semantically identical to credit exhaustion.
|
||||
if status in {402, 404, 429, None}:
|
||||
if status in {402, 403, 404, 429, None}:
|
||||
if any(kw in err_lower for kw in (
|
||||
"credits", "insufficient funds",
|
||||
"can only afford", "billing",
|
||||
@@ -2479,6 +2480,8 @@ def _is_payment_error(exc: Exception) -> bool:
|
||||
"balance_depleted", "no usable credits",
|
||||
"model_not_supported_on_free_tier",
|
||||
"not available on the free tier",
|
||||
"requires a subscription", "upgrade for access",
|
||||
"upgrade for higher limits", "reached your session usage limit",
|
||||
# Daily / monthly / weekly quota exhaustion keywords
|
||||
"quota exceeded", "quota_exceeded",
|
||||
"too many tokens per day", "daily limit",
|
||||
@@ -2697,6 +2700,60 @@ def _is_model_not_found_error(exc: Exception) -> bool:
|
||||
))
|
||||
|
||||
|
||||
def _is_model_incompatible_error(exc: Exception) -> bool:
|
||||
"""Detect "this route cannot serve this model" 400s (capability mismatch).
|
||||
|
||||
Distinct from :func:`_is_model_not_found_error` (the model does not exist
|
||||
anywhere): here the model name is valid but the *current provider/account*
|
||||
is structurally unable to run it. The canonical case is a configured
|
||||
fallback that cannot run the main model — e.g. an ``openai-codex`` /
|
||||
ChatGPT-account fallback asked to compress a ``glm-5.2`` conversation::
|
||||
|
||||
Error code: 400 - {'detail': "The 'glm-5.2' model is not supported
|
||||
when using Codex with a ChatGPT account."}
|
||||
|
||||
The candidate authenticates fine and builds a client, so the auth and
|
||||
payment predicates don't fire and the call would otherwise raise and
|
||||
abort the whole auxiliary task (commonly compression — which then drops
|
||||
middle turns and churns the session, destroying the prompt cache).
|
||||
Treating it as a fallback-worthy capability error lets the chain skip the
|
||||
incapable route and continue to the next candidate, mirroring the
|
||||
context-window feasibility screen (#52392).
|
||||
|
||||
Billing/quota 400s belong to :func:`_is_payment_error`; "model does not
|
||||
exist" 400s belong to :func:`_is_model_not_found_error`. This predicate
|
||||
explicitly excludes both so the three don't overlap.
|
||||
"""
|
||||
status = getattr(exc, "status_code", None)
|
||||
if status not in {400, None}:
|
||||
return False
|
||||
err_lower = str(exc).lower()
|
||||
# Not-found 400s ("invalid model ID", "model does not exist") are owned by
|
||||
# _is_model_not_found_error. Billing/free-tier 400s are owned by the
|
||||
# payment path — key on the billing keywords directly here rather than
|
||||
# calling _is_payment_error(), because that predicate is status-gated
|
||||
# ({402,403,404,429,None}) and would not recognise a 400-coded billing
|
||||
# body, letting it leak into this capability bucket.
|
||||
if _is_model_not_found_error(exc):
|
||||
return False
|
||||
if any(kw in err_lower for kw in (
|
||||
"credits", "insufficient funds", "billing", "out of funds",
|
||||
"balance_depleted", "no usable credits", "payment required",
|
||||
"free tier", "free-tier", "not available on the free tier",
|
||||
"model_not_supported_on_free_tier", "quota",
|
||||
)):
|
||||
return False
|
||||
return any(kw in err_lower for kw in (
|
||||
"is not supported when using", # codex/ChatGPT-account model gating
|
||||
"model is not supported",
|
||||
"not supported with this",
|
||||
"not supported for this account",
|
||||
"model_not_supported",
|
||||
"does not support this model",
|
||||
"unsupported model",
|
||||
))
|
||||
|
||||
|
||||
def _evict_cached_clients(provider: str) -> None:
|
||||
"""Drop cached auxiliary clients for a provider so fresh creds are used."""
|
||||
normalized = _normalize_aux_provider(provider)
|
||||
@@ -3147,6 +3204,88 @@ def _try_main_agent_model_fallback(
|
||||
return client, resolved_model or main_model, label
|
||||
|
||||
|
||||
# ── Context-window screening for runtime fallback chains (issue #52392) ──
|
||||
#
|
||||
# When the runtime auxiliary fallback chain selects a candidate that is
|
||||
# reachable but has a context window smaller than the compression task
|
||||
# requires, the call errors out instead of continuing to the next, viable
|
||||
# candidate. The startup feasibility check in
|
||||
# ``agent.conversation_compression.check_compression_model_feasibility``
|
||||
# already filters too-small auxiliary models at startup, but the runtime
|
||||
# fallback chain (``_try_configured_fallback_chain`` and
|
||||
# ``_try_main_fallback_chain``) does not apply the same filter, so
|
||||
# compression can stop at the first alive door even if the room behind it
|
||||
# is too small.
|
||||
#
|
||||
# The helpers below screen each candidate by its effective context window
|
||||
# before it is returned. ``None`` results from ``get_model_context_length``
|
||||
# are passed through (we cannot prove a model is too small, so we do not
|
||||
# block it). This preserves the existing fallback surface for
|
||||
# unrecognised/custom models while closing the gap on the well-known ones.
|
||||
|
||||
def _task_minimum_context_length(task: Optional[str]) -> Optional[int]:
|
||||
"""Return the minimum context length required for an auxiliary task.
|
||||
|
||||
Only ``compression`` carries an explicit minimum today (the same
|
||||
``MINIMUM_CONTEXT_LENGTH`` (64K) floor that
|
||||
``check_compression_model_feasibility`` already enforces at startup).
|
||||
Other tasks (``vision``, ``title_generation``, ``web_extract``,
|
||||
``skills_hub``, ``mcp``, ``session_search``) return ``None`` — they
|
||||
have no per-task context floor and the runtime chain must remain
|
||||
permissive for them.
|
||||
|
||||
Returns ``None`` for an empty/``None`` task name so the helper is a
|
||||
safe no-op when called from generic sites.
|
||||
"""
|
||||
if not task:
|
||||
return None
|
||||
if task == "compression":
|
||||
return MINIMUM_CONTEXT_LENGTH
|
||||
return None
|
||||
|
||||
|
||||
def _candidate_context_window(
|
||||
provider: str,
|
||||
model: str,
|
||||
base_url: str = "",
|
||||
api_key: str = "",
|
||||
) -> Optional[int]:
|
||||
"""Resolve the effective context window for a fallback candidate.
|
||||
|
||||
Thin wrapper around :func:`agent.model_metadata.get_model_context_length`
|
||||
that swallows probe failures (returns ``None``). Callers treat
|
||||
``None`` as "unknown — pass through" so the existing fallback
|
||||
surface is preserved when the context-length resolver chain cannot
|
||||
determine a value (custom endpoints, models not in the registry,
|
||||
offline endpoints).
|
||||
|
||||
Best-effort, never raises — the runtime fallback chain must keep
|
||||
moving even if the resolver hits a probe error.
|
||||
"""
|
||||
if not model:
|
||||
return None
|
||||
try:
|
||||
ctx = get_model_context_length(
|
||||
model,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
provider=provider,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Auxiliary fallback: could not resolve context window for %s/%s: %s",
|
||||
provider, model, exc,
|
||||
)
|
||||
return None
|
||||
# ``get_model_context_length`` returns an int (with a 256K default
|
||||
# fallback when nothing else matches). We still propagate ``None`` if
|
||||
# a future change returns ``Optional[int]`` — being explicit is
|
||||
# cheap and the test suite covers both shapes.
|
||||
if isinstance(ctx, int) and ctx > 0:
|
||||
return ctx
|
||||
return None
|
||||
|
||||
|
||||
def _try_configured_fallback_chain(
|
||||
task: str,
|
||||
failed_provider: str,
|
||||
@@ -3171,6 +3310,7 @@ def _try_configured_fallback_chain(
|
||||
|
||||
skip = failed_provider.lower().strip()
|
||||
tried = []
|
||||
min_ctx = _task_minimum_context_length(task)
|
||||
|
||||
for i, entry in enumerate(chain):
|
||||
if not isinstance(entry, dict):
|
||||
@@ -3188,6 +3328,20 @@ def _try_configured_fallback_chain(
|
||||
fb_client, resolved_model = None, None
|
||||
|
||||
if fb_client is not None:
|
||||
if min_ctx is not None and resolved_model:
|
||||
fb_ctx = _candidate_context_window(
|
||||
fb_provider,
|
||||
resolved_model,
|
||||
base_url=str(entry.get("base_url") or ""),
|
||||
api_key=_fallback_entry_api_key(entry) or "",
|
||||
)
|
||||
if fb_ctx is not None and fb_ctx < min_ctx:
|
||||
logger.info(
|
||||
"Auxiliary %s: skipping %s (%s context=%d < min=%d), continuing chain",
|
||||
task, label, resolved_model, fb_ctx, min_ctx,
|
||||
)
|
||||
tried.append(f"{label} (context too small: {fb_ctx}<{min_ctx})")
|
||||
continue
|
||||
logger.info(
|
||||
"Auxiliary %s: %s on %s — configured fallback to %s (%s)",
|
||||
task, reason, failed_provider, label, resolved_model or fb_model or "default",
|
||||
@@ -3203,6 +3357,28 @@ def _try_configured_fallback_chain(
|
||||
return None, None, ""
|
||||
|
||||
|
||||
def _try_configured_fallback_for_unavailable_client(
|
||||
task: Optional[str],
|
||||
failed_provider: str,
|
||||
) -> Tuple[Optional[Any], Optional[str], str]:
|
||||
"""Try task fallback_chain when an explicit aux provider cannot build.
|
||||
|
||||
This covers the "no client" case before any request is sent: missing
|
||||
raw env key, unavailable OAuth/pool credentials, or provider resolver
|
||||
returning ``(None, None)``. It deliberately stops at the configured
|
||||
per-task fallback chain; the main-agent model remains the last-resort
|
||||
runtime fallback for request-time capacity errors.
|
||||
"""
|
||||
explicit = (failed_provider or "").strip().lower()
|
||||
if not task or not explicit or explicit in {"auto"}:
|
||||
return None, None, ""
|
||||
return _try_configured_fallback_chain(
|
||||
task,
|
||||
explicit,
|
||||
reason="provider unavailable",
|
||||
)
|
||||
|
||||
|
||||
def _fallback_entry_api_key(entry: Dict[str, Any]) -> Optional[str]:
|
||||
"""Resolve inline or env-backed API key from a fallback-chain entry."""
|
||||
explicit = str(entry.get("api_key") or "").strip()
|
||||
@@ -3261,6 +3437,7 @@ def _try_main_fallback_chain(
|
||||
main_norm = (_read_main_provider() or "").strip().lower()
|
||||
skip = {p for p in (failed_norm, main_norm, "auto") if p}
|
||||
tried: List[str] = []
|
||||
min_ctx = _task_minimum_context_length(task)
|
||||
|
||||
for i, entry in enumerate(chain):
|
||||
if not isinstance(entry, dict):
|
||||
@@ -3284,6 +3461,20 @@ def _try_main_fallback_chain(
|
||||
logger.debug("Auxiliary %s: main fallback %s failed to resolve: %s", task or "call", label, exc)
|
||||
fb_client, resolved_model = None, None
|
||||
if fb_client is not None:
|
||||
if min_ctx is not None:
|
||||
fb_ctx = _candidate_context_window(
|
||||
fb_provider,
|
||||
resolved_model or fb_model,
|
||||
base_url=str(entry.get("base_url") or ""),
|
||||
api_key=_fallback_entry_api_key(entry) or "",
|
||||
)
|
||||
if fb_ctx is not None and fb_ctx < min_ctx:
|
||||
logger.info(
|
||||
"Auxiliary %s: skipping %s (context=%d < min=%d), continuing chain",
|
||||
task or "call", label, fb_ctx, min_ctx,
|
||||
)
|
||||
tried.append(f"{label} (context too small: {fb_ctx}<{min_ctx})")
|
||||
continue
|
||||
logger.info(
|
||||
"Auxiliary %s: %s on %s — main fallback chain to %s (%s)",
|
||||
task or "call", reason, failed_provider or "auto", label,
|
||||
@@ -5344,21 +5535,30 @@ def call_llm(
|
||||
)
|
||||
if client is None:
|
||||
# When the user explicitly chose a non-OpenRouter provider but no
|
||||
# credentials were found, fail fast instead of silently routing
|
||||
# through OpenRouter (which causes confusing 404s).
|
||||
# credentials were found, honor the task fallback_chain before
|
||||
# raising. Missing raw env keys are recoverable for auxiliary
|
||||
# tasks because fallback entries may use OAuth / credential-pool
|
||||
# auth (for example openai-codex).
|
||||
_explicit = (resolved_provider or "").strip().lower()
|
||||
if _explicit and _explicit not in {"auto", "openrouter", "custom"}:
|
||||
raise RuntimeError(
|
||||
f"Provider '{_explicit}' is set in config.yaml but no API key "
|
||||
f"was found. Set the {_explicit.upper()}_API_KEY environment "
|
||||
f"variable, or switch to a different provider with `hermes model`."
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client(
|
||||
task, _explicit,
|
||||
)
|
||||
if fb_client is not None:
|
||||
client, final_model = fb_client, fb_model
|
||||
resolved_provider = fb_label or resolved_provider
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Provider '{_explicit}' is set in config.yaml but no API key "
|
||||
f"was found. Set the {_explicit.upper()}_API_KEY environment "
|
||||
f"variable, or switch to a different provider with `hermes model`."
|
||||
)
|
||||
# For auto/custom with no credentials, try the full auto chain
|
||||
# rather than hardcoding OpenRouter (which may be depleted).
|
||||
# Pass model=None so each provider uses its own default —
|
||||
# resolved_model may be an OpenRouter-format slug that doesn't
|
||||
# work on other providers.
|
||||
if not resolved_base_url:
|
||||
if client is None and not resolved_base_url:
|
||||
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
|
||||
task or "call", resolved_provider)
|
||||
client, final_model = _get_cached_client("auto", main_runtime=main_runtime, task=task)
|
||||
@@ -5657,6 +5857,7 @@ def call_llm(
|
||||
_is_payment_error(first_err)
|
||||
or _is_connection_error(first_err)
|
||||
or _is_rate_limit_error(first_err)
|
||||
or _is_model_incompatible_error(first_err)
|
||||
)
|
||||
# Respect explicit provider choice for transient errors (auth, request
|
||||
# validation, etc.) but allow fallback when the provider clearly cannot
|
||||
@@ -5667,7 +5868,19 @@ def call_llm(
|
||||
is_auto = resolved_provider in {"auto", "", None}
|
||||
# Capacity errors bypass the explicit-provider gate: the provider
|
||||
# literally cannot serve this request regardless of user intent.
|
||||
is_capacity_error = _is_payment_error(first_err) or _is_connection_error(first_err)
|
||||
# Rate limits are included: after retries are exhausted, a 429 means
|
||||
# the provider cannot serve this request — fall back. See #52228.
|
||||
# Model-incompatibility 400s are also a hard capability mismatch (the
|
||||
# route cannot run this model at all — e.g. a codex/ChatGPT-account
|
||||
# fallback asked to compress a glm-5.2 conversation), so they bypass
|
||||
# the explicit-provider gate and continue to the next candidate
|
||||
# instead of aborting the auxiliary task and churning the session.
|
||||
is_capacity_error = (
|
||||
_is_payment_error(first_err)
|
||||
or _is_connection_error(first_err)
|
||||
or _is_rate_limit_error(first_err)
|
||||
or _is_model_incompatible_error(first_err)
|
||||
)
|
||||
if should_fallback and (is_auto or is_capacity_error):
|
||||
if _is_payment_error(first_err):
|
||||
reason = "payment error"
|
||||
@@ -5680,6 +5893,8 @@ def call_llm(
|
||||
)
|
||||
elif _is_rate_limit_error(first_err):
|
||||
reason = "rate limit"
|
||||
elif _is_model_incompatible_error(first_err):
|
||||
reason = "model incompatible with route"
|
||||
else:
|
||||
reason = "connection error"
|
||||
logger.info("Auxiliary %s: %s on %s (%s), trying fallback",
|
||||
@@ -5854,12 +6069,21 @@ async def async_call_llm(
|
||||
if client is None:
|
||||
_explicit = (resolved_provider or "").strip().lower()
|
||||
if _explicit and _explicit not in {"auto", "openrouter", "custom"}:
|
||||
raise RuntimeError(
|
||||
f"Provider '{_explicit}' is set in config.yaml but no API key "
|
||||
f"was found. Set the {_explicit.upper()}_API_KEY environment "
|
||||
f"variable, or switch to a different provider with `hermes model`."
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client(
|
||||
task, _explicit,
|
||||
)
|
||||
if not resolved_base_url:
|
||||
if fb_client is not None:
|
||||
client, final_model = _to_async_client(
|
||||
fb_client, fb_model or "", is_vision=(task == "vision")
|
||||
)
|
||||
resolved_provider = fb_label or resolved_provider
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Provider '{_explicit}' is set in config.yaml but no API key "
|
||||
f"was found. Set the {_explicit.upper()}_API_KEY environment "
|
||||
f"variable, or switch to a different provider with `hermes model`."
|
||||
)
|
||||
if client is None and not resolved_base_url:
|
||||
logger.info("Auxiliary %s: provider %s unavailable, trying auto-detection chain",
|
||||
task or "call", resolved_provider)
|
||||
client, final_model = _get_cached_client("auto", async_mode=True, main_runtime=main_runtime, task=task)
|
||||
@@ -6109,12 +6333,22 @@ async def async_call_llm(
|
||||
_is_payment_error(first_err)
|
||||
or _is_connection_error(first_err)
|
||||
or _is_rate_limit_error(first_err)
|
||||
or _is_model_incompatible_error(first_err)
|
||||
)
|
||||
# Capacity errors (payment/quota/connection) bypass the explicit-provider
|
||||
# gate — the provider cannot serve the request regardless of user intent.
|
||||
# Capacity errors (payment/quota/connection/rate-limit) bypass the
|
||||
# explicit-provider gate — the provider cannot serve the request
|
||||
# regardless of user intent. Rate limits are included: after retries
|
||||
# are exhausted, a 429 means the provider is at capacity. See #52228.
|
||||
# See #26803: daily token quota must fall back like a 402 credit error.
|
||||
# Model-incompatibility 400s (route cannot run this model at all)
|
||||
# bypass the gate too — see the sync call_llm() path for rationale.
|
||||
is_auto = resolved_provider in {"auto", "", None}
|
||||
is_capacity_error = _is_payment_error(first_err) or _is_connection_error(first_err)
|
||||
is_capacity_error = (
|
||||
_is_payment_error(first_err)
|
||||
or _is_connection_error(first_err)
|
||||
or _is_rate_limit_error(first_err)
|
||||
or _is_model_incompatible_error(first_err)
|
||||
)
|
||||
if should_fallback and (is_auto or is_capacity_error):
|
||||
if _is_payment_error(first_err):
|
||||
reason = "payment error"
|
||||
@@ -6123,6 +6357,8 @@ async def async_call_llm(
|
||||
)
|
||||
elif _is_rate_limit_error(first_err):
|
||||
reason = "rate limit"
|
||||
elif _is_model_incompatible_error(first_err):
|
||||
reason = "model incompatible with route"
|
||||
else:
|
||||
reason = "connection error"
|
||||
logger.info("Auxiliary %s (async): %s on %s (%s), trying fallback",
|
||||
|
||||
@@ -890,7 +890,15 @@ class ContextCompressor(ContextEngine):
|
||||
# This is independent of the abort_on_summary_failure config flag:
|
||||
# rotating on a broken credential is never the right behavior.
|
||||
self._last_summary_auth_failure: bool = False
|
||||
# When a user-configured summary model fails and we recover by
|
||||
# Set when summary generation ultimately fails due to a transient
|
||||
# network/connection error (httpx/httpcore connection drop, premature
|
||||
# stream close, etc.) — distinct from auth failures but treated the
|
||||
# same way by compress(): ABORT and preserve the session unchanged
|
||||
# rather than destroy the middle window for a deterministic
|
||||
# "summary unavailable" marker. Retrying once the network recovers is
|
||||
# strictly better than discarding context for a transient blip
|
||||
# (#29559, #25585). Independent of abort_on_summary_failure.
|
||||
self._last_summary_network_failure: bool = False
|
||||
# retrying on the main model, record the failure so gateway /
|
||||
# CLI callers can still warn the user even though compression
|
||||
# succeeded. Silent recovery would hide the broken config.
|
||||
@@ -1687,6 +1695,7 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
self._summary_model_fallen_back = False
|
||||
self._last_summary_error = None
|
||||
self._last_summary_auth_failure = False
|
||||
self._last_summary_network_failure = False
|
||||
return self._with_summary_prefix(summary)
|
||||
except Exception as e:
|
||||
# ``call_llm`` raises ``RuntimeError`` for two very different cases:
|
||||
@@ -1819,6 +1828,15 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
if len(err_text) > 220:
|
||||
err_text = err_text[:217].rstrip() + "..."
|
||||
self._last_summary_error = err_text
|
||||
# A terminal connection/network failure (we reach this branch only
|
||||
# after any main-model fallback has already been tried or is
|
||||
# unavailable). Flag it so compress() ABORTS and preserves the
|
||||
# session unchanged instead of destroying the middle window for a
|
||||
# placeholder marker — retrying once the network recovers is
|
||||
# strictly better than dropping context (#29559, #25585). Mirrors
|
||||
# the auth-failure carve-out; independent of abort_on_summary_failure.
|
||||
if _is_streaming_closed:
|
||||
self._last_summary_network_failure = True
|
||||
logger.warning(
|
||||
"Failed to generate context summary: %s. "
|
||||
"Further summary attempts paused for %d seconds.",
|
||||
@@ -2382,6 +2400,7 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
self._last_aux_model_failure_model = None
|
||||
self._last_compress_aborted = False
|
||||
self._last_summary_auth_failure = False
|
||||
self._last_summary_network_failure = False
|
||||
|
||||
# Manual /compress (force=True) bypasses the failure cooldown so the
|
||||
# user can retry immediately after an auto-compress abort. Without
|
||||
@@ -2498,15 +2517,21 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
# surface a warning.
|
||||
# Default is False (historical behavior).
|
||||
#
|
||||
# EXCEPTION — auth failures always abort. A 401/403 from the summary
|
||||
# call means the credential or endpoint is broken (invalid/blocked
|
||||
# key, or a token pointed at the wrong inference host). Rotating into
|
||||
# EXCEPTION — auth AND transient network failures always abort. A
|
||||
# 401/403 from the summary call means the credential or endpoint is
|
||||
# broken (invalid/blocked key, or a token pointed at the wrong
|
||||
# inference host). A connection/stream-close error means the network
|
||||
# blipped at the compaction moment (#29559). In BOTH cases rotating into
|
||||
# a child session with a placeholder summary on a broken credential
|
||||
# strands the user on a degraded session for zero benefit — every
|
||||
# subsequent call fails the same way. So when the failure was an auth
|
||||
# error we abort regardless of abort_on_summary_failure, preserving
|
||||
# the conversation unchanged until the credential is fixed.
|
||||
if not summary and (self.abort_on_summary_failure or self._last_summary_auth_failure):
|
||||
if not summary and (
|
||||
self.abort_on_summary_failure
|
||||
or self._last_summary_auth_failure
|
||||
or self._last_summary_network_failure
|
||||
):
|
||||
n_skipped = compress_end - compress_start
|
||||
self._last_summary_dropped_count = 0 # nothing actually dropped
|
||||
self._last_summary_fallback_used = False
|
||||
@@ -2521,6 +2546,15 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
"with /compress or start fresh with /new.",
|
||||
n_skipped,
|
||||
)
|
||||
elif self._last_summary_network_failure:
|
||||
logger.warning(
|
||||
"Summary generation failed with a network/connection "
|
||||
"error — aborting compression. %d message(s) preserved "
|
||||
"unchanged; the session was NOT rotated. This is "
|
||||
"transient: retry with /compress once connectivity "
|
||||
"recovers, or continue the conversation as-is.",
|
||||
n_skipped,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Summary generation failed — aborting compression "
|
||||
|
||||
@@ -90,6 +90,7 @@ def check_compression_model_feasibility(agent: Any) -> None:
|
||||
try:
|
||||
from agent.auxiliary_client import (
|
||||
_resolve_task_provider_model,
|
||||
_try_configured_fallback_for_unavailable_client,
|
||||
get_text_auxiliary_client,
|
||||
)
|
||||
from agent.model_metadata import (
|
||||
@@ -97,10 +98,6 @@ def check_compression_model_feasibility(agent: Any) -> None:
|
||||
get_model_context_length,
|
||||
)
|
||||
|
||||
client, aux_model = get_text_auxiliary_client(
|
||||
"compression",
|
||||
main_runtime=agent._current_main_runtime(),
|
||||
)
|
||||
# Best-effort aux provider label for the warning message. The
|
||||
# configured provider may be "auto", in which case we fall back
|
||||
# to the client's base_url hostname so the user can still tell
|
||||
@@ -109,6 +106,19 @@ def check_compression_model_feasibility(agent: Any) -> None:
|
||||
_aux_cfg_provider, _, _, _, _ = _resolve_task_provider_model("compression")
|
||||
except Exception:
|
||||
_aux_cfg_provider = ""
|
||||
client, aux_model = get_text_auxiliary_client(
|
||||
"compression",
|
||||
main_runtime=agent._current_main_runtime(),
|
||||
)
|
||||
if client is None or not aux_model:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_for_unavailable_client(
|
||||
"compression",
|
||||
_aux_cfg_provider,
|
||||
)
|
||||
if fb_client is not None and fb_model:
|
||||
client, aux_model = fb_client, fb_model
|
||||
if "(" in fb_label and fb_label.endswith(")"):
|
||||
_aux_cfg_provider = fb_label.rsplit("(", 1)[1][:-1]
|
||||
if client is None or not aux_model:
|
||||
if _aux_cfg_provider and _aux_cfg_provider != "auto":
|
||||
msg = (
|
||||
|
||||
@@ -35,6 +35,7 @@ from agent.turn_context import build_turn_context
|
||||
from agent.turn_retry_state import TurnRetryState
|
||||
from agent.memory_manager import build_memory_context_block
|
||||
from agent.message_sanitization import (
|
||||
close_interrupted_tool_sequence,
|
||||
_repair_tool_call_arguments,
|
||||
_sanitize_messages_non_ascii,
|
||||
_sanitize_messages_surrogates,
|
||||
@@ -55,7 +56,7 @@ from agent.model_metadata import (
|
||||
)
|
||||
from agent.process_bootstrap import _install_safe_stdio
|
||||
from agent.prompt_caching import apply_anthropic_cache_control
|
||||
from agent.retry_utils import jittered_backoff
|
||||
from agent.retry_utils import adaptive_rate_limit_backoff, jittered_backoff
|
||||
from agent.trajectory import has_incomplete_scratchpad
|
||||
from agent.usage_pricing import estimate_usage_cost, normalize_usage
|
||||
from hermes_constants import PARTIAL_STREAM_STUB_ID
|
||||
@@ -1396,10 +1397,12 @@ def run_conversation(
|
||||
while time.time() < sleep_end:
|
||||
if agent._interrupt_requested:
|
||||
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True)
|
||||
_interrupt_text = f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries})."
|
||||
close_interrupted_tool_sequence(messages, _interrupt_text)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
agent.clear_interrupt()
|
||||
return {
|
||||
"final_response": f"Operation interrupted during retry ({_failure_hint}, attempt {retry_count}/{max_retries}).",
|
||||
"final_response": _interrupt_text,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
@@ -2663,10 +2666,12 @@ def run_conversation(
|
||||
# Check for interrupt before deciding to retry
|
||||
if agent._interrupt_requested:
|
||||
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during error handling, aborting retries.", force=True)
|
||||
_interrupt_text = f"Operation interrupted: handling API error ({error_type}: {agent._clean_error_message(str(api_error))})."
|
||||
close_interrupted_tool_sequence(messages, _interrupt_text)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
agent.clear_interrupt()
|
||||
return {
|
||||
"final_response": f"Operation interrupted: handling API error ({error_type}: {agent._clean_error_message(str(api_error))}).",
|
||||
"final_response": _interrupt_text,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
@@ -3537,16 +3542,38 @@ def run_conversation(
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0)
|
||||
_backoff_policy = None
|
||||
if is_rate_limited and not _retry_after:
|
||||
wait_time, _backoff_policy = adaptive_rate_limit_backoff(
|
||||
retry_count,
|
||||
base_url=str(_base),
|
||||
model=_model,
|
||||
error=api_error,
|
||||
default_wait=wait_time,
|
||||
)
|
||||
if is_rate_limited:
|
||||
agent._buffer_status(f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries})...")
|
||||
_policy_note = ""
|
||||
if _backoff_policy == "zai_coding_overload_long":
|
||||
_policy_note = " (Z.AI Coding overload adaptive long backoff)"
|
||||
elif _backoff_policy == "zai_coding_overload_short":
|
||||
_policy_note = " (Z.AI Coding overload short retry)"
|
||||
_rate_limit_status = f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries}){_policy_note}..."
|
||||
# Normal retries are buffered to avoid noisy transient chatter. Long
|
||||
# Z.AI Coding waits are different: they can last minutes, so surface
|
||||
# progress immediately instead of making the TUI look frozen.
|
||||
if _backoff_policy == "zai_coding_overload_long":
|
||||
agent._emit_status(_rate_limit_status)
|
||||
else:
|
||||
agent._buffer_status(_rate_limit_status)
|
||||
else:
|
||||
agent._buffer_status(f"⏳ Retrying in {wait_time:.1f}s (attempt {retry_count}/{max_retries})...")
|
||||
logger.warning(
|
||||
"Retrying API call in %ss (attempt %s/%s) %s error=%s",
|
||||
"Retrying API call in %ss (attempt %s/%s) %s policy=%s error=%s",
|
||||
wait_time,
|
||||
retry_count,
|
||||
max_retries,
|
||||
agent._client_log_context(),
|
||||
_backoff_policy or "default",
|
||||
api_error,
|
||||
)
|
||||
# Sleep in small increments so we can respond to interrupts quickly
|
||||
@@ -3556,10 +3583,12 @@ def run_conversation(
|
||||
while time.time() < sleep_end:
|
||||
if agent._interrupt_requested:
|
||||
agent._vprint(f"{agent.log_prefix}⚡ Interrupt detected during retry wait, aborting.", force=True)
|
||||
_interrupt_text = f"Operation interrupted: retrying API call after error (retry {retry_count}/{max_retries})."
|
||||
close_interrupted_tool_sequence(messages, _interrupt_text)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
agent.clear_interrupt()
|
||||
return {
|
||||
"final_response": f"Operation interrupted: retrying API call after error (retry {retry_count}/{max_retries}).",
|
||||
"final_response": _interrupt_text,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": False,
|
||||
@@ -4050,6 +4079,19 @@ def run_conversation(
|
||||
|
||||
messages.append(assistant_msg)
|
||||
agent._emit_interim_assistant_message(assistant_msg)
|
||||
try:
|
||||
# Persist the assistant tool-call turn before any tool
|
||||
# side effects run. If a destructive tool restarts or
|
||||
# terminates Hermes mid-turn, resume logic still sees the
|
||||
# exact tool-call block that already executed.
|
||||
agent._flush_messages_to_session_db(messages, conversation_history)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Incremental tool-call persistence failed before execution "
|
||||
"(session=%s): %s",
|
||||
agent.session_id or "none",
|
||||
exc,
|
||||
)
|
||||
|
||||
# Close any open streaming display (response box, reasoning
|
||||
# box) before tool execution begins. Intermediate turns may
|
||||
@@ -4479,9 +4521,10 @@ def run_conversation(
|
||||
final_msg = agent._build_assistant_message(assistant_message, finish_reason)
|
||||
|
||||
# Pop thinking-only prefill and empty-response retry
|
||||
# scaffolding before appending the final response. These
|
||||
# internal turns are only for the next API retry and should
|
||||
# not become durable transcript context.
|
||||
# scaffolding before appending either a final response or a
|
||||
# verification-stop follow-up. These internal turns are only
|
||||
# for the next API retry and should not become durable
|
||||
# transcript context.
|
||||
while (
|
||||
messages
|
||||
and isinstance(messages[-1], dict)
|
||||
@@ -4493,6 +4536,44 @@ def run_conversation(
|
||||
):
|
||||
messages.pop()
|
||||
|
||||
try:
|
||||
from agent.verification_stop import (
|
||||
build_verify_on_stop_nudge,
|
||||
verify_on_stop_enabled,
|
||||
)
|
||||
|
||||
if verify_on_stop_enabled():
|
||||
_verify_nudge = build_verify_on_stop_nudge(
|
||||
session_id=getattr(agent, "session_id", None),
|
||||
changed_paths=getattr(agent, "_turn_file_mutation_paths", set()),
|
||||
attempts=getattr(agent, "_verification_stop_nudges", 0),
|
||||
)
|
||||
else:
|
||||
_verify_nudge = None
|
||||
except Exception:
|
||||
logger.debug("verification stop-loop check failed", exc_info=True)
|
||||
_verify_nudge = None
|
||||
|
||||
if _verify_nudge:
|
||||
agent._verification_stop_nudges = (
|
||||
getattr(agent, "_verification_stop_nudges", 0) + 1
|
||||
)
|
||||
final_msg["finish_reason"] = "verification_required"
|
||||
messages.append(final_msg)
|
||||
# Keep the attempted final answer in model history so the
|
||||
# synthetic user nudge preserves role alternation, but do
|
||||
# not surface it to the user as an interim answer. The
|
||||
# whole point of this guard is to prevent premature
|
||||
# "done" claims before checks run.
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": _verify_nudge,
|
||||
"_verification_stop_synthetic": True,
|
||||
})
|
||||
agent._session_messages = messages
|
||||
agent._emit_status("↻ Verification required before finishing")
|
||||
continue
|
||||
|
||||
messages.append(final_msg)
|
||||
|
||||
_turn_exit_reason = f"text_response(finish_reason={finish_reason})"
|
||||
|
||||
183
agent/display.py
183
agent/display.py
@@ -6,6 +6,7 @@ Used by AIAgent._execute_tool_calls for CLI feedback.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -177,6 +178,167 @@ def _truncate_preview(text: str, max_len: int | None) -> str:
|
||||
return text
|
||||
|
||||
|
||||
_SHELL_SILENT_HEADS = {"cd", "pushd", "popd", "export", "set", "unset", "source", ".", "true", "false", ":"}
|
||||
_SHELL_PIPE_TAIL_HEADS = {"head", "tail", "wc", "sort", "uniq"}
|
||||
|
||||
|
||||
def _shell_basename(head: str) -> str:
|
||||
return head.rsplit("/", 1)[-1] if head else ""
|
||||
|
||||
|
||||
def _split_shell_words(segment: str) -> list[str]:
|
||||
words: list[str] = []
|
||||
buf: list[str] = []
|
||||
quote: str | None = None
|
||||
|
||||
for i, ch in enumerate(segment):
|
||||
if quote:
|
||||
buf.append(ch)
|
||||
if ch == quote and (i == 0 or segment[i - 1] != "\\"):
|
||||
quote = None
|
||||
continue
|
||||
|
||||
if ch in {"'", '"'}:
|
||||
quote = ch
|
||||
buf.append(ch)
|
||||
continue
|
||||
|
||||
if ch.isspace():
|
||||
if buf:
|
||||
words.append("".join(buf))
|
||||
buf = []
|
||||
continue
|
||||
|
||||
buf.append(ch)
|
||||
|
||||
if buf:
|
||||
words.append("".join(buf))
|
||||
|
||||
return words
|
||||
|
||||
|
||||
def _strip_shell_pipe_tail(segment: str) -> str:
|
||||
words = _split_shell_words(segment)
|
||||
out: list[str] = []
|
||||
|
||||
for i, word in enumerate(words):
|
||||
if word == "|" and _shell_basename(words[i + 1] if i + 1 < len(words) else "") in _SHELL_PIPE_TAIL_HEADS:
|
||||
break
|
||||
out.append(word)
|
||||
|
||||
return " ".join(out).strip()
|
||||
|
||||
|
||||
def _split_shell_compound(command: str) -> list[str]:
|
||||
segments: list[str] = []
|
||||
buf: list[str] = []
|
||||
quote: str | None = None
|
||||
i = 0
|
||||
|
||||
while i < len(command):
|
||||
ch = command[i]
|
||||
|
||||
if quote:
|
||||
buf.append(ch)
|
||||
if ch == quote and (i == 0 or command[i - 1] != "\\"):
|
||||
quote = None
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if ch in {"'", '"'}:
|
||||
quote = ch
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
op_len = 2 if command.startswith("&&", i) or command.startswith("||", i) else 1 if ch in {";", "\n"} else 0
|
||||
if op_len:
|
||||
segment = _strip_shell_pipe_tail("".join(buf).strip())
|
||||
if segment:
|
||||
segments.append(segment)
|
||||
buf = []
|
||||
i += op_len
|
||||
continue
|
||||
|
||||
buf.append(ch)
|
||||
i += 1
|
||||
|
||||
segment = _strip_shell_pipe_tail("".join(buf).strip())
|
||||
if segment:
|
||||
segments.append(segment)
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
def _shell_head_word(segment: str) -> str:
|
||||
words = _split_shell_words(segment)
|
||||
index = 0
|
||||
while index < len(words) and re.match(r"^[A-Za-z_]\w*=", words[index]):
|
||||
index += 1
|
||||
return _shell_basename(words[index] if index < len(words) else "")
|
||||
|
||||
|
||||
def _clean_shell_segment(segment: str) -> str:
|
||||
words = _split_shell_words(segment)
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(words):
|
||||
word = words[i]
|
||||
if re.match(r"^\d*(?:>>?|<)$", word):
|
||||
i += 2
|
||||
continue
|
||||
if re.match(r"^\d*(?:>&|<&)\d+$", word) or re.match(r"^\d*>&\d+$", word):
|
||||
i += 1
|
||||
continue
|
||||
out.append(word)
|
||||
i += 1
|
||||
return " ".join(out).strip()
|
||||
|
||||
|
||||
def _is_shell_boundary_echo(segment: str) -> bool:
|
||||
words = _split_shell_words(segment)
|
||||
if _shell_basename(words[0] if words else "") != "echo":
|
||||
return False
|
||||
rest = " ".join(words[1:])
|
||||
return bool(re.search(r"-{2,}|_exit=|(?:^|\s|=)\$[?{]|PIPESTATUS", rest))
|
||||
|
||||
|
||||
def summarize_shell_command(command: str) -> str:
|
||||
"""Compact shell wrapper/plumbing for display while preserving raw command elsewhere."""
|
||||
original = _oneline(command)
|
||||
if not original:
|
||||
return ""
|
||||
|
||||
segments = _split_shell_compound(original)
|
||||
if len(segments) <= 1:
|
||||
return _clean_shell_segment(segments[0] if segments else original) or original
|
||||
|
||||
core: list[str] = []
|
||||
for segment in segments:
|
||||
cleaned = _clean_shell_segment(segment)
|
||||
head = _shell_head_word(cleaned)
|
||||
if cleaned and head not in _SHELL_SILENT_HEADS and not _is_shell_boundary_echo(cleaned):
|
||||
core.append(cleaned)
|
||||
|
||||
if not core:
|
||||
return original
|
||||
if len(core) == 1:
|
||||
return core[0]
|
||||
|
||||
count = len(core) - 1
|
||||
return f"{core[0]} + {count} {'command' if count == 1 else 'commands'}"
|
||||
|
||||
|
||||
def _read_file_line_label(args: dict) -> str:
|
||||
offset = args.get("offset")
|
||||
limit = args.get("limit")
|
||||
if not isinstance(offset, int) or offset <= 0:
|
||||
return ""
|
||||
if not isinstance(limit, int) or limit <= 1:
|
||||
return f"L{offset}"
|
||||
return f"L{offset}-{offset + limit - 1}"
|
||||
|
||||
|
||||
def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]:
|
||||
if not isinstance(tasks, list):
|
||||
return 0, []
|
||||
@@ -253,6 +415,23 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
||||
else:
|
||||
return f"planning {len(todos_arg)} task(s)"
|
||||
|
||||
if tool_name in {"terminal", "execute_code"}:
|
||||
key = "code" if tool_name == "execute_code" else "command"
|
||||
command = args.get(key)
|
||||
if command is None:
|
||||
return None
|
||||
preview = summarize_shell_command(str(command))
|
||||
return _truncate_preview(preview, max_len) if preview else None
|
||||
|
||||
if tool_name == "read_file":
|
||||
path = args.get("path") or args.get("file") or args.get("filepath")
|
||||
if path is None:
|
||||
return None
|
||||
label = Path(str(path).replace("\\", "/")).name or str(path)
|
||||
line_label = _read_file_line_label(args)
|
||||
preview = f"{label} {line_label}".strip()
|
||||
return _truncate_preview(preview, max_len) if preview else None
|
||||
|
||||
if tool_name == "session_search":
|
||||
query = _oneline(args.get("query", ""))
|
||||
return f"recall: \"{query[:25]}{'...' if len(query) > 25 else ''}\""
|
||||
@@ -943,7 +1122,7 @@ def get_cute_tool_message(
|
||||
return _wrap(f"┊ 📄 fetch {_trunc(domain, 35)}{extra} {dur}")
|
||||
return _wrap(f"┊ 📄 fetch pages {dur}")
|
||||
if tool_name == "terminal":
|
||||
return _wrap(f"┊ 💻 $ {_trunc(args.get('command', ''), 42)} {dur}")
|
||||
return _wrap(f"┊ 💻 $ {_trunc(build_tool_preview(tool_name, args) or args.get('command', ''), 42)} {dur}")
|
||||
if tool_name == "process":
|
||||
action = args.get("action", "?")
|
||||
sid = args.get("session_id", "")[:12]
|
||||
@@ -951,7 +1130,7 @@ def get_cute_tool_message(
|
||||
"wait": f"wait {sid}", "kill": f"kill {sid}", "write": f"write {sid}", "submit": f"submit {sid}"}
|
||||
return _wrap(f"┊ ⚙️ proc {labels.get(action, f'{action} {sid}')} {dur}")
|
||||
if tool_name == "read_file":
|
||||
return _wrap(f"┊ 📖 read {_path(args.get('path', ''))} {dur}")
|
||||
return _wrap(f"┊ 📖 read {_trunc(build_tool_preview(tool_name, args) or args.get('path', ''), 42)} {dur}")
|
||||
if tool_name == "write_file":
|
||||
return _wrap(f"┊ ✍️ write {_path(args.get('path', ''))} {dur}")
|
||||
if tool_name == "patch":
|
||||
|
||||
136
agent/learn_prompt.py
Normal file
136
agent/learn_prompt.py
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""``/learn`` — build the standards-guided prompt that turns whatever the user
|
||||
described into a reusable skill.
|
||||
|
||||
``/learn`` is open-ended. The user can point it at anything they can describe:
|
||||
a directory of code, an API doc URL, a workflow they just walked the agent
|
||||
through in this conversation, or pasted notes. This module builds ONE prompt
|
||||
that instructs the live agent to:
|
||||
|
||||
1. Gather the sources the user named, using the tools it already has
|
||||
(``read_file`` / ``search_files`` for dirs, ``web_extract`` for URLs, the
|
||||
current conversation for "what I just did", the user's text for pasted
|
||||
material).
|
||||
2. Author a single ``SKILL.md`` via ``skill_manage`` that follows the Hermes
|
||||
skill-authoring standards (description <=60 chars, the modern section
|
||||
order, Hermes-tool framing, no invented commands).
|
||||
|
||||
There is no separate distillation engine and no model-tool footprint: the
|
||||
agent does the work with its existing toolset, so this works identically on
|
||||
local, Docker, and remote terminal backends. Every surface (CLI ``/learn``,
|
||||
gateway ``/learn``, the dashboard "Learn a skill" panel) calls
|
||||
:func:`build_learn_prompt` and feeds the result to the agent as a normal turn.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# The house-style rules, distilled from AGENTS.md "Skill authoring standards
|
||||
# (HARDLINE)" and the hermes-agent-dev new-skill salvage reference. Embedded in
|
||||
# the prompt so the agent authors skills the way a maintainer would by hand.
|
||||
_AUTHORING_STANDARDS = """\
|
||||
Follow the Hermes skill-authoring standards exactly. These are the same
|
||||
HARDLINE rules a maintainer enforces in review:
|
||||
|
||||
Frontmatter:
|
||||
- name: lowercase-hyphenated, <=64 chars, no spaces.
|
||||
- description: ONE sentence, **<=60 characters**, ends with a period. State the
|
||||
capability, not the implementation. No marketing words (powerful,
|
||||
comprehensive, seamless, advanced, robust). Do NOT repeat the skill name. If
|
||||
the description contains a colon, wrap the whole value in double quotes.
|
||||
This is the most-violated rule and it is NOT cosmetic: the system-prompt
|
||||
skill index truncates the description to 60 chars and loads it every
|
||||
session, so anything past char 60 is silently cut and never routes. After
|
||||
you write the description, COUNT the characters; if it is over 60, cut it
|
||||
down before saving — do not ship a sentence and hope.
|
||||
Good (<=60): `Search arXiv papers by keyword, author, or ID.`
|
||||
Bad (123): `A comprehensive skill that lets the agent search arXiv for
|
||||
academic papers using keywords, authors, and categories.`
|
||||
- version: 0.1.0
|
||||
- author: always the literal value `Hermes`. NEVER fill it from the host
|
||||
environment — the OS/login username (e.g. the `user=` line in your
|
||||
environment hints), git config, or any identity you can probe must not be
|
||||
written. Skills get shared and published, so an environment-derived name is
|
||||
a privacy leak the user never opted into; the skill names itself as Hermes.
|
||||
- platforms: declare `[macos]`, `[linux]`, and/or `[windows]` IF the skill
|
||||
uses OS-bound primitives (osascript/apt/systemctl => the matching OS; /proc,
|
||||
os.setsid, signal.SIGKILL => linux; fcntl/termios => POSIX). Prefer fixing it
|
||||
cross-platform first (tempfile.gettempdir(), pathlib.Path, psutil); gate only
|
||||
when the dependency is genuinely platform-bound. Omit the field for portable
|
||||
skills.
|
||||
- metadata.hermes.tags: a few Capitalized, Relevant, Tags.
|
||||
|
||||
Body section order (omit a section only if it genuinely has no content):
|
||||
1. "# <Human Title>" then a 2-3 sentence intro: what it does, what it does NOT
|
||||
do, and the key dependency stance (e.g. "stdlib only").
|
||||
2. "## When to Use" — bullet list of concrete trigger phrases.
|
||||
3. "## Prerequisites" — exact env vars, install steps, credentials.
|
||||
4. "## How to Run" — the canonical invocation, framed through Hermes tools.
|
||||
5. "## Quick Reference" — a flat command/endpoint list, no narration.
|
||||
6. "## Procedure" — numbered steps with copy-paste-exact commands.
|
||||
7. "## Pitfalls" — known limits, rate limits, things that look broken but aren't.
|
||||
8. "## Verification" — a single command/check that proves the skill worked.
|
||||
|
||||
Hermes-tool framing (this is what makes it a skill, not shell docs):
|
||||
- Frame running scripts as "invoke through the `terminal` tool".
|
||||
- Reference Hermes tools by name in backticks: `terminal`, `read_file`,
|
||||
`write_file`, `search_files`, `patch`, `web_extract`, `web_search`,
|
||||
`vision_analyze`, `browser_navigate`, `delegate_task`, `image_generate`,
|
||||
`text_to_speech`, `cronjob`, `memory`, `skill_view`, `execute_code`.
|
||||
- Do NOT name shell utilities the agent already has wrapped: say `read_file`
|
||||
not cat/head/tail, `search_files` not grep/rg/find/ls, `patch` not sed/awk,
|
||||
`web_extract` not curl-to-scrape, `write_file` not echo>file or heredocs.
|
||||
- Third-party CLIs (ffmpeg, gh, an SDK) are fine inside a script file, but the
|
||||
prose still frames them as "invoke through the `terminal` tool". If the
|
||||
skill needs an MCP server, name it and document its setup in Prerequisites.
|
||||
|
||||
Quality bar:
|
||||
- Prefer exact commands, endpoint URLs, function signatures, and config keys
|
||||
that appear VERBATIM in the source. NEVER invent flags, paths, or APIs — if
|
||||
you didn't see it in the source, don't write it.
|
||||
- Keep it tight and scannable: ~100 lines for a simple skill, ~200 for a
|
||||
complex one. Don't re-paste the source docs.
|
||||
- Don't write a router/index/hub skill that only points at other skills.
|
||||
- Larger scripts/parsers belong in a `scripts/` file (add via
|
||||
`skill_manage` write_file), referenced from SKILL.md by relative path — not
|
||||
inlined for the agent to re-type every run. References go in `references/`,
|
||||
templates in `templates/`."""
|
||||
|
||||
|
||||
def build_learn_prompt(user_request: str) -> str:
|
||||
"""Build the agent prompt for an open-ended ``/learn`` request.
|
||||
|
||||
Args:
|
||||
user_request: the free-text the user gave after ``/learn`` — a
|
||||
description of the workflow, paths, URLs, or "what I just did".
|
||||
|
||||
Returns:
|
||||
A complete instruction the agent runs as a normal turn. The agent
|
||||
gathers the described sources with its existing tools and authors the
|
||||
skill via ``skill_manage``.
|
||||
"""
|
||||
req = (user_request or "").strip()
|
||||
if not req:
|
||||
req = (
|
||||
"the workflow we just went through in this conversation — review "
|
||||
"the steps taken and distill them into a reusable skill"
|
||||
)
|
||||
|
||||
return (
|
||||
"[/learn] The user wants you to learn a reusable skill from the "
|
||||
"source(s) they described below, and save it.\n\n"
|
||||
f"WHAT TO LEARN FROM:\n{req}\n\n"
|
||||
"Do this:\n"
|
||||
"1. Gather the material. Resolve whatever the user named using the "
|
||||
"tools you already have — `read_file`/`search_files` for local files "
|
||||
"or directories, `web_extract` for URLs, the current conversation "
|
||||
"history if they referred to something you just did, and the text "
|
||||
"they pasted as-is. If the request is ambiguous about scope, make a "
|
||||
"reasonable choice and note it; do not stall.\n"
|
||||
"2. Author ONE SKILL.md and save it with the `skill_manage` tool "
|
||||
"(action=\"create\"). Pick a sensible category. If the procedure needs "
|
||||
"a non-trivial script, add it under the skill's `scripts/` with "
|
||||
"`skill_manage` write_file and reference it by relative path.\n\n"
|
||||
f"{_AUTHORING_STANDARDS}\n\n"
|
||||
"When done, tell the user the skill name, its category, and a "
|
||||
"one-line summary of what it captured."
|
||||
)
|
||||
@@ -46,6 +46,39 @@ logger = logging.getLogger(__name__)
|
||||
_SYNC_DRAIN_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
def normalize_tool_schema(schema: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Return a function-tool dict with a resolvable top-level ``name``.
|
||||
|
||||
Context engines and memory providers expose tool schemas via
|
||||
``get_tool_schemas()``. The expected shape is a bare function schema
|
||||
(``{"name": ..., "description": ..., "parameters": ...}``) which callers
|
||||
wrap as ``{"type": "function", "function": schema}``.
|
||||
|
||||
Some providers instead return an entry that is *already* in OpenAI tool
|
||||
form (``{"type": "function", "function": {"name": ...}}``). Wrapping that
|
||||
a second time produces ``{"type": "function", "function": {"type":
|
||||
"function", "function": {...}}}`` whose ``function`` has no top-level
|
||||
``name``. Strict providers (e.g. DeepSeek) reject the *entire* request
|
||||
with ``tools[N].function: missing field name`` (HTTP 400), so one bad
|
||||
schema disables the whole toolset and breaks every turn (#47707).
|
||||
|
||||
This helper normalizes both shapes to the bare function schema and
|
||||
returns ``None`` for anything without a resolvable name, so callers can
|
||||
skip-with-warning rather than appending a nameless tool.
|
||||
"""
|
||||
if not isinstance(schema, dict):
|
||||
return None
|
||||
# Unwrap an already-wrapped OpenAI tool entry.
|
||||
if schema.get("type") == "function" and isinstance(schema.get("function"), dict):
|
||||
schema = schema["function"]
|
||||
if not isinstance(schema, dict):
|
||||
return None
|
||||
name = schema.get("name", "")
|
||||
if not name or not isinstance(name, str):
|
||||
return None
|
||||
return schema
|
||||
|
||||
|
||||
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool:
|
||||
"""Return whether external memory-provider tools should be exposed."""
|
||||
if enabled_toolsets is None:
|
||||
@@ -92,11 +125,17 @@ def inject_memory_provider_tools(agent: Any) -> int:
|
||||
agent.valid_tool_names = valid_tool_names
|
||||
|
||||
added = 0
|
||||
for schema in get_schemas():
|
||||
if not isinstance(schema, dict):
|
||||
for raw_schema in get_schemas():
|
||||
schema = normalize_tool_schema(raw_schema)
|
||||
if schema is None:
|
||||
logger.warning(
|
||||
"Memory provider returned a tool schema with no resolvable "
|
||||
"name; skipping to avoid poisoning the request (%r)",
|
||||
raw_schema,
|
||||
)
|
||||
continue
|
||||
tool_name = schema.get("name", "")
|
||||
if not tool_name or tool_name in existing_tool_names:
|
||||
tool_name = schema["name"]
|
||||
if tool_name in existing_tool_names:
|
||||
continue
|
||||
tools.append({"type": "function", "function": schema})
|
||||
valid_tool_names.add(tool_name)
|
||||
@@ -370,8 +409,11 @@ class MemoryManager:
|
||||
_core_tool_names = set(_HERMES_CORE_TOOLS)
|
||||
|
||||
# Index tool names → provider for routing
|
||||
for schema in provider.get_tool_schemas():
|
||||
tool_name = schema.get("name", "")
|
||||
for raw_schema in provider.get_tool_schemas():
|
||||
schema = normalize_tool_schema(raw_schema)
|
||||
if schema is None:
|
||||
continue
|
||||
tool_name = schema["name"]
|
||||
if tool_name in _core_tool_names:
|
||||
logger.warning(
|
||||
"Memory provider '%s' tool '%s' shadows a reserved core "
|
||||
@@ -658,11 +700,19 @@ class MemoryManager:
|
||||
seen = set()
|
||||
for provider in self._providers:
|
||||
try:
|
||||
for schema in provider.get_tool_schemas():
|
||||
name = schema.get("name", "")
|
||||
for raw_schema in provider.get_tool_schemas():
|
||||
schema = normalize_tool_schema(raw_schema)
|
||||
if schema is None:
|
||||
logger.warning(
|
||||
"Memory provider '%s' returned a tool schema with "
|
||||
"no resolvable name; skipping (%r)",
|
||||
provider.name, raw_schema,
|
||||
)
|
||||
continue
|
||||
name = schema["name"]
|
||||
if name in _core_tool_names:
|
||||
continue
|
||||
if name and name not in seen:
|
||||
if name not in seen:
|
||||
schemas.append(schema)
|
||||
seen.add(name)
|
||||
except Exception as e:
|
||||
|
||||
@@ -279,6 +279,38 @@ def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
|
||||
return "{}"
|
||||
|
||||
|
||||
def close_interrupted_tool_sequence(messages: list, final_response: Any = None) -> bool:
|
||||
"""Append a synthetic assistant turn when an interrupted tail is a tool result.
|
||||
|
||||
A turn cut short by ``/stop`` can leave the transcript ending on a raw
|
||||
``tool`` message (a tool finished, or its execution was cancelled, but the
|
||||
model never streamed a closing assistant turn). Persisting that tail means
|
||||
the next user message lands as ``… tool → user`` — a role-alternation
|
||||
violation that strict providers (Gemini, Claude) react to by hallucinating
|
||||
a continuation of the user's message and ignoring prior context, which
|
||||
reads to the user as "lost context" (#48879).
|
||||
|
||||
``finalize_turn`` closes this on the happy interrupt path, but the
|
||||
retry/backoff/error interrupt aborts in ``conversation_loop`` ``return``
|
||||
early and never reach it — this shared helper closes the sequence on all of
|
||||
them. ``final_response`` is usually empty on an interrupt, so an explicit
|
||||
placeholder is used rather than an empty-content assistant turn.
|
||||
|
||||
Mutates ``messages`` in place. Returns True if a closing turn was appended.
|
||||
"""
|
||||
if not messages:
|
||||
return False
|
||||
last = messages[-1]
|
||||
if not isinstance(last, dict) or last.get("role") != "tool":
|
||||
return False
|
||||
text = final_response if isinstance(final_response, str) else ""
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": text.strip() or "Operation interrupted.",
|
||||
})
|
||||
return True
|
||||
|
||||
|
||||
def _strip_non_ascii(text: str) -> str:
|
||||
"""Remove non-ASCII characters, replacing with closest ASCII equivalent or removing.
|
||||
|
||||
@@ -431,6 +463,7 @@ def _sanitize_structure_non_ascii(payload: Any) -> bool:
|
||||
|
||||
__all__ = [
|
||||
"_SURROGATE_RE",
|
||||
"close_interrupted_tool_sequence",
|
||||
"_sanitize_surrogates",
|
||||
"_sanitize_structure_surrogates",
|
||||
"_sanitize_messages_surrogates",
|
||||
|
||||
51
agent/pet/__init__.py
Normal file
51
agent/pet/__init__.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Petdex pet engine — shared core for the CLI, TUI, and desktop surfaces.
|
||||
|
||||
Petdex (https://github.com/crafter-station/petdex) is a public gallery of
|
||||
animated sprite "pets" for coding agents. Each pet is a ``pet.json`` plus a
|
||||
``spritesheet.{webp,png}`` of 192×208 px cells. Current Codex/petdex sheets use
|
||||
an 8-column × 9-row atlas; older Hermes/petdex sheets used an 8-row atlas.
|
||||
Hermes infers the row taxonomy from the sheet and maps agent activity onto
|
||||
idle/run/review/failed/wave/jump.
|
||||
|
||||
This package is the **single source of truth** for the feature so the base
|
||||
CLI (Python) and TUI (Ink, via ``tui_gateway``) never duplicate the hard
|
||||
parts:
|
||||
|
||||
- :mod:`agent.pet.constants` — frame geometry + the :class:`PetState` enum.
|
||||
- :mod:`agent.pet.state` — map agent activity → a :class:`PetState`.
|
||||
- :mod:`agent.pet.manifest` — fetch the public petdex manifest.
|
||||
- :mod:`agent.pet.store` — install / list / resolve pets on disk
|
||||
(profile-aware via ``get_hermes_home()``).
|
||||
- :mod:`agent.pet.render` — decode a spritesheet and encode frames for a
|
||||
terminal (kitty / iTerm2 / sixel graphics
|
||||
protocols, with a Unicode half-block
|
||||
fallback).
|
||||
|
||||
Rendering in the Electron desktop is necessarily TypeScript (canvas), but it
|
||||
reuses the same on-disk store and the same state semantics.
|
||||
|
||||
The whole feature is a *display* concern: it adds no model tool, mutates no
|
||||
system prompt or toolset, and therefore has zero effect on prompt caching.
|
||||
"""
|
||||
|
||||
from agent.pet.constants import (
|
||||
DEFAULT_SCALE,
|
||||
FRAME_H,
|
||||
FRAME_W,
|
||||
FRAMES_PER_STATE,
|
||||
LOOP_MS,
|
||||
STATE_ROWS,
|
||||
PetState,
|
||||
)
|
||||
from agent.pet.state import derive_pet_state
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_SCALE",
|
||||
"FRAME_H",
|
||||
"FRAME_W",
|
||||
"FRAMES_PER_STATE",
|
||||
"LOOP_MS",
|
||||
"STATE_ROWS",
|
||||
"PetState",
|
||||
"derive_pet_state",
|
||||
]
|
||||
167
agent/pet/constants.py
Normal file
167
agent/pet/constants.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Pet sprite geometry + animation-state taxonomy.
|
||||
|
||||
These values are the common petdex/Codex pet geometry. The real ``pet.json``
|
||||
usually only carries ``id``/``displayName``/``description``/``spritesheetPath``;
|
||||
row taxonomy is inferred from the atlas shape so Hermes can render both legacy
|
||||
8-row sheets and current 9-row Codex sheets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
# Frame geometry (pixels). Current Codex/petdex spritesheets are 8 columns x 9
|
||||
# rows (1536x1872), while older Hermes/petdex sheets used 9 columns x 8 rows
|
||||
# (1728x1664). Renderers derive both row taxonomy and real column count from the
|
||||
# concrete sheet, so either shape works.
|
||||
FRAME_W = 192
|
||||
FRAME_H = 208
|
||||
|
||||
# Frames consumed per animation state (the petdex web app uses CSS
|
||||
# ``steps(6)``). A sheet may physically contain more columns; we only step
|
||||
# through the first ``FRAMES_PER_STATE``.
|
||||
FRAMES_PER_STATE = 6
|
||||
|
||||
# Full-loop duration for one state, milliseconds (petdex default).
|
||||
LOOP_MS = 1100
|
||||
|
||||
# Default on-screen scale relative to native frame size. ``display.pet.scale``
|
||||
# is the single master scalar: the desktop canvas multiplies its native pixels
|
||||
# by it and every terminal surface derives its half-block/kitty column width
|
||||
# from it (see :func:`cols_for_scale`), so one number shrinks all three
|
||||
# interfaces together. (petdex's own clients render at 0.7; we default smaller
|
||||
# so the kitty/GUI mascot stays a glanceable corner sprite. The half-block
|
||||
# fallback can't shrink as far — see ``UNICODE_MIN_COLS`` — and clamps to its
|
||||
# legibility floor instead.)
|
||||
DEFAULT_SCALE = 0.33
|
||||
|
||||
# User-settable scale bounds (``/pet scale``, desktop slider). Floor keeps the
|
||||
# pet clickable/visible; ceiling stops a fat-fingered value from filling the
|
||||
# screen. The unicode fallback additionally clamps to ``UNICODE_MIN_COLS``.
|
||||
MIN_SCALE = 0.1
|
||||
MAX_SCALE = 3.0
|
||||
|
||||
|
||||
def clamp_scale(scale: float) -> float:
|
||||
"""Clamp *scale* to ``[MIN_SCALE, MAX_SCALE]`` (the single validation point)."""
|
||||
return max(MIN_SCALE, min(MAX_SCALE, scale))
|
||||
|
||||
# Terminal cells one native frame spans at ``scale == 1.0``. A cell is ~8px
|
||||
# wide, a frame is ``FRAME_W`` (192) px → 24 cells. This mirrors the kitty
|
||||
# graphics placement (``scaled_px // 8``) so at full scale every renderer agrees.
|
||||
BASE_UNICODE_COLS = FRAME_W // 8
|
||||
|
||||
# Legibility floor for the half-block fallback. A half-block cell samples the
|
||||
# sprite at only 1 horizontal + 2 vertical taps, so below this width a 192×208
|
||||
# pet collapses into an unreadable blob *regardless* of scale. kitty/GUI draw
|
||||
# true pixels and have no such floor — that's why the same ``scale: 0.33`` is
|
||||
# crisp there but mush in half-blocks. ``scale`` shrinks the unicode pet down
|
||||
# TO this floor (and grows it above), instead of past it into noise.
|
||||
UNICODE_MIN_COLS = 16
|
||||
|
||||
|
||||
def cols_for_scale(scale: float) -> int:
|
||||
"""Half-block width implied by *scale*, clamped to the legibility floor.
|
||||
|
||||
Above the floor it tracks the kitty cell box (``scaled_px // 8``) so the two
|
||||
renderers converge at larger sizes; below it the floor keeps the sprite
|
||||
readable rather than letting it devolve into a blob.
|
||||
"""
|
||||
return max(UNICODE_MIN_COLS, round(BASE_UNICODE_COLS * (scale or DEFAULT_SCALE)))
|
||||
|
||||
|
||||
def resolve_cols(scale: float, unicode_cols: int = 0) -> int:
|
||||
"""Resolve terminal width: explicit *unicode_cols* override, else from *scale*."""
|
||||
return int(unicode_cols) if unicode_cols and int(unicode_cols) > 0 else cols_for_scale(scale)
|
||||
|
||||
|
||||
class PetState(str, Enum):
|
||||
"""Animation state a pet can be shown in.
|
||||
|
||||
These are Hermes' activity state names. They are not always identical to the
|
||||
source atlas row names: Codex-format pets use rows like ``jumping`` /
|
||||
``running`` while the UI keeps the shorter ``jump`` / ``run`` names.
|
||||
"""
|
||||
|
||||
IDLE = "idle"
|
||||
WAVE = "wave"
|
||||
RUN = "run"
|
||||
FAILED = "failed"
|
||||
REVIEW = "review"
|
||||
JUMP = "jump"
|
||||
WAITING = "waiting"
|
||||
|
||||
|
||||
# Legacy Hermes/petdex row order (top -> bottom) used by the older 8-row,
|
||||
# 9-column atlas shape.
|
||||
LEGACY_STATE_ROWS: list[str] = [
|
||||
PetState.IDLE.value,
|
||||
PetState.WAVE.value,
|
||||
PetState.RUN.value,
|
||||
PetState.FAILED.value,
|
||||
PetState.REVIEW.value,
|
||||
PetState.JUMP.value,
|
||||
"extra1",
|
||||
"extra2",
|
||||
]
|
||||
|
||||
# Current Petdex row order (top -> bottom) used by 1536x1872 atlases:
|
||||
# 8 columns x 9 rows of 192x208 cells.
|
||||
CODEX_STATE_ROWS: list[str] = [
|
||||
PetState.IDLE.value,
|
||||
"running-right",
|
||||
"running-left",
|
||||
"waving",
|
||||
"jumping",
|
||||
PetState.FAILED.value,
|
||||
PetState.WAITING.value,
|
||||
"running",
|
||||
PetState.REVIEW.value,
|
||||
]
|
||||
|
||||
# Default/fallback for callers without a sheet. Prefer the current 9-row Codex
|
||||
# format because generated pets and the public Codex pet contract use it.
|
||||
STATE_ROWS: list[str] = CODEX_STATE_ROWS
|
||||
|
||||
# Canonical Hermes activity names -> accepted row-name aliases in descending
|
||||
# preference. This keeps our internal state names stable (`wave`/`jump`/`run`)
|
||||
# while matching Petdex's current `waving`/`jumping`/`running` taxonomy.
|
||||
STATE_ALIASES: dict[str, tuple[str, ...]] = {
|
||||
PetState.IDLE.value: (PetState.IDLE.value,),
|
||||
PetState.WAVE.value: (PetState.WAVE.value, "waving"),
|
||||
PetState.JUMP.value: (PetState.JUMP.value, "jumping"),
|
||||
PetState.RUN.value: (PetState.RUN.value, "running"),
|
||||
PetState.FAILED.value: (PetState.FAILED.value,),
|
||||
PetState.REVIEW.value: (PetState.REVIEW.value,),
|
||||
PetState.WAITING.value: (PetState.WAITING.value,),
|
||||
}
|
||||
|
||||
|
||||
def state_aliases_for(state: "PetState | str") -> tuple[str, ...]:
|
||||
"""Return accepted row-name aliases for *state* (always non-empty)."""
|
||||
value = state.value if isinstance(state, PetState) else str(state)
|
||||
aliases = STATE_ALIASES.get(value)
|
||||
return aliases if aliases else (value,)
|
||||
|
||||
|
||||
def state_rows_for_grid(row_count: int | None) -> list[str]:
|
||||
"""Return the row taxonomy for a spritesheet with *row_count* rows."""
|
||||
try:
|
||||
rows = int(row_count or 0)
|
||||
except (TypeError, ValueError):
|
||||
rows = 0
|
||||
|
||||
if rows >= len(CODEX_STATE_ROWS):
|
||||
return CODEX_STATE_ROWS
|
||||
return LEGACY_STATE_ROWS
|
||||
|
||||
|
||||
def state_row_index(state: "PetState | str", row_count: int | None = None) -> int:
|
||||
"""Return the spritesheet row index for *state* (clamped, never raises)."""
|
||||
rows = state_rows_for_grid(row_count)
|
||||
for name in state_aliases_for(state):
|
||||
try:
|
||||
return rows.index(name)
|
||||
except ValueError:
|
||||
continue
|
||||
return 0 # fall back to the idle row
|
||||
29
agent/pet/generate/__init__.py
Normal file
29
agent/pet/generate/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Pet generation — base-draft → hatch pipeline.
|
||||
|
||||
Public surface used by the gateway RPCs, the CLI ``hermes pets generate``
|
||||
command, and tests:
|
||||
|
||||
- :func:`generate_base_drafts` / :func:`hatch_pet` — the two-step flow.
|
||||
- :class:`HatchResult`, :class:`GenerationError`.
|
||||
- :mod:`atlas` — deterministic frame extraction + atlas composition/validation.
|
||||
|
||||
Image generation is delegated to the active reference-capable
|
||||
:class:`~agent.image_gen_provider.ImageGenProvider` (OpenAI gpt-image-2 or Krea);
|
||||
atlas assembly is fully deterministic so it's testable without any API calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agent.pet.generate.imagegen import GenerationError
|
||||
from agent.pet.generate.orchestrate import (
|
||||
HatchResult,
|
||||
generate_base_drafts,
|
||||
hatch_pet,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"GenerationError",
|
||||
"HatchResult",
|
||||
"generate_base_drafts",
|
||||
"hatch_pet",
|
||||
]
|
||||
1183
agent/pet/generate/atlas.py
Normal file
1183
agent/pet/generate/atlas.py
Normal file
File diff suppressed because it is too large
Load Diff
251
agent/pet/generate/imagegen.py
Normal file
251
agent/pet/generate/imagegen.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""Thin image-generation layer for pet sprites.
|
||||
|
||||
Wraps the active :class:`~agent.image_gen_provider.ImageGenProvider` with the
|
||||
two things sprite generation needs that the agent-facing ``image_generate`` tool
|
||||
doesn't expose: **N variants** (loop) and **reference-image grounding** (so each
|
||||
animation row stays the same character as the chosen base).
|
||||
|
||||
Reference grounding only works on providers that support it — currently OpenAI
|
||||
``gpt-image-2`` (image edits) and Krea (style references). We resolve to one of
|
||||
those and surface a clear, actionable error otherwise rather than silently
|
||||
producing an ungrounded, drifting pet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Providers that can ground generation on a reference image, in preference order
|
||||
# (Nous Portal → OpenAI → OpenRouter → …). OpenRouter/Nous run a quality-first
|
||||
# model chain and may fall back depending on account access and endpoint behavior,
|
||||
# so fidelity can vary by configured backend + model availability.
|
||||
_REF_CAPABLE = ("nous", "openai", "openai-codex", "openrouter", "krea")
|
||||
|
||||
# Friendly display label per reference-capable provider, surfaced in the desktop
|
||||
# pet-gen picker.
|
||||
_PROVIDER_LABELS: dict[str, str] = {
|
||||
"nous": "Nous Portal",
|
||||
"openrouter": "OpenRouter",
|
||||
"openai": "OpenAI",
|
||||
"openai-codex": "OpenAI (Codex)",
|
||||
"krea": "Krea",
|
||||
}
|
||||
|
||||
|
||||
def _forced_provider_from_env() -> str | None:
|
||||
"""Optional QA override to force a pet-gen backend.
|
||||
|
||||
`HERMES_PET_IMAGE_PROVIDER=<name>` (e.g. `openrouter`) bypasses the normal
|
||||
active/default provider resolution for pet generation only. Unknown values are
|
||||
ignored so existing users are unaffected.
|
||||
"""
|
||||
forced = os.environ.get("HERMES_PET_IMAGE_PROVIDER", "").strip().lower()
|
||||
return forced if forced in _REF_CAPABLE else None
|
||||
|
||||
|
||||
class GenerationError(RuntimeError):
|
||||
"""Raised on any image-generation failure (no provider, API error, IO)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpriteProvider:
|
||||
"""Resolved provider plus whether it can take reference images."""
|
||||
|
||||
name: str
|
||||
provider: object
|
||||
supports_references: bool
|
||||
|
||||
|
||||
def _discover() -> None:
|
||||
try:
|
||||
from hermes_cli.plugins import _ensure_plugins_discovered
|
||||
|
||||
_ensure_plugins_discovered()
|
||||
except Exception as exc: # noqa: BLE001 - discovery is best-effort
|
||||
logger.debug("image-gen plugin discovery failed: %s", exc)
|
||||
|
||||
|
||||
def resolve_provider(*, require_references: bool = True, prefer: str | None = None) -> SpriteProvider:
|
||||
"""Pick the image provider to use for sprite work.
|
||||
|
||||
Preference: an explicit *prefer* choice (the desktop pet-gen picker) when it's
|
||||
reference-capable and configured, then the configured/active provider when
|
||||
it's reference-capable, else the first available reference-capable provider.
|
||||
With *require_references* off we fall back to any available provider (used for
|
||||
prompt-only base drafts).
|
||||
"""
|
||||
_discover()
|
||||
from agent.image_gen_registry import get_active_provider, get_provider
|
||||
|
||||
# QA override: force one provider for pet-gen iteration regardless of the
|
||||
# globally active image_gen backend.
|
||||
forced = _forced_provider_from_env()
|
||||
if forced:
|
||||
chosen = get_provider(forced)
|
||||
if chosen is not None and chosen.is_available():
|
||||
return SpriteProvider(name=forced, provider=chosen, supports_references=True)
|
||||
|
||||
# An explicit user pick wins when it's reference-capable and has credentials;
|
||||
# otherwise we ignore it and fall through to the normal resolution.
|
||||
if prefer:
|
||||
chosen = get_provider(prefer)
|
||||
if prefer in _REF_CAPABLE and chosen is not None and chosen.is_available():
|
||||
return SpriteProvider(name=prefer, provider=chosen, supports_references=True)
|
||||
|
||||
# Configured / active provider first.
|
||||
active = None
|
||||
try:
|
||||
active = get_active_provider()
|
||||
except Exception: # noqa: BLE001
|
||||
active = None
|
||||
if active is not None:
|
||||
name = getattr(active, "name", "")
|
||||
if name in _REF_CAPABLE and active.is_available():
|
||||
return SpriteProvider(name=name, provider=active, supports_references=True)
|
||||
|
||||
# Any available reference-capable provider.
|
||||
for name in _REF_CAPABLE:
|
||||
provider = get_provider(name)
|
||||
if provider is not None and provider.is_available():
|
||||
return SpriteProvider(name=name, provider=provider, supports_references=True)
|
||||
|
||||
if not require_references and active is not None and active.is_available():
|
||||
return SpriteProvider(
|
||||
name=getattr(active, "name", "unknown"), provider=active, supports_references=False
|
||||
)
|
||||
|
||||
raise GenerationError(
|
||||
"Pet generation needs an image backend that supports reference images. "
|
||||
"Open `hermes tools` → Image Generation and configure Nous Portal, "
|
||||
"OpenRouter, or OpenAI (gpt-image-2) with an API key."
|
||||
)
|
||||
|
||||
|
||||
def list_sprite_providers() -> list[dict]:
|
||||
"""The reference-capable providers available to pick for pet generation.
|
||||
|
||||
Returns ``[{name, label, default}]`` for every ref-capable provider the user
|
||||
actually has credentials for, in preference order, marking the one
|
||||
:func:`resolve_provider` would choose with no explicit preference. Empty when
|
||||
none is configured (the picker hides itself). Best-effort: discovery hiccups
|
||||
yield an empty list.
|
||||
"""
|
||||
_discover()
|
||||
from agent.image_gen_registry import get_provider
|
||||
|
||||
try:
|
||||
default_name = resolve_provider(require_references=True).name
|
||||
except GenerationError:
|
||||
default_name = ""
|
||||
|
||||
out: list[dict] = []
|
||||
for name in _REF_CAPABLE:
|
||||
provider = get_provider(name)
|
||||
if provider is None or not provider.is_available():
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"name": name,
|
||||
"label": _PROVIDER_LABELS.get(name, name),
|
||||
"default": name == default_name,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _save_local(image_ref: str, *, prefix: str) -> Path:
|
||||
"""Return a local path for *image_ref*, downloading it if it's a URL."""
|
||||
if image_ref.startswith(("http://", "https://")):
|
||||
from agent.image_gen_provider import save_url_image
|
||||
|
||||
return Path(save_url_image(image_ref, prefix=prefix))
|
||||
return Path(image_ref)
|
||||
|
||||
|
||||
def _rejected_background(error: str) -> bool:
|
||||
"""True when a provider error is specifically about the ``background`` param.
|
||||
|
||||
Transparent backgrounds are a per-model capability (e.g. some gpt-image tiers
|
||||
reject ``background=transparent`` outright). We detect that one rejection so
|
||||
we can retry without the flag rather than failing the whole pet — our chroma
|
||||
key pass makes the result transparent regardless.
|
||||
"""
|
||||
lowered = (error or "").lower()
|
||||
return "background" in lowered and ("not supported" in lowered or "transparent" in lowered)
|
||||
|
||||
|
||||
def generate(
|
||||
prompt: str,
|
||||
*,
|
||||
n: int = 1,
|
||||
reference_images: list[Path] | None = None,
|
||||
provider: SpriteProvider | None = None,
|
||||
prefix: str = "pet_gen",
|
||||
aspect_ratio: str = "square",
|
||||
) -> list[Path]:
|
||||
"""Generate *n* sprite images and return their local paths.
|
||||
|
||||
*reference_images* grounds the output on a base image (required for rows).
|
||||
*aspect_ratio* picks the canvas: ``"square"`` for single-character base
|
||||
drafts, ``"landscape"`` for multi-frame row strips (the wider 1536px canvas
|
||||
gives every frame real horizontal room so winged poses don't have to be
|
||||
shrunk to avoid touching their neighbors).
|
||||
We *ask* for a transparent background, but fall back to an opaque generation
|
||||
(cleaned up downstream by the chroma-key pass) on models that reject the
|
||||
flag. Raises :class:`GenerationError` if nothing usable comes back.
|
||||
"""
|
||||
sprite = provider or resolve_provider(require_references=bool(reference_images))
|
||||
if reference_images and not sprite.supports_references:
|
||||
raise GenerationError(
|
||||
f"image backend '{sprite.name}' cannot use reference images; "
|
||||
"configure OpenAI gpt-image-2 or Krea for pet generation"
|
||||
)
|
||||
|
||||
refs = [str(p) for p in (reference_images or [])]
|
||||
|
||||
def _run(extra: dict) -> tuple[Path | None, str]:
|
||||
kwargs: dict = {"aspect_ratio": aspect_ratio, **extra}
|
||||
if refs:
|
||||
# Providers disagree on the ref kwarg name: our OpenRouter/Nous
|
||||
# backends read ``reference_images``, OpenAI's gpt-image-2 reads
|
||||
# ``reference_image_urls``. Send both; each ignores the other.
|
||||
kwargs["reference_images"] = refs
|
||||
kwargs["reference_image_urls"] = refs
|
||||
try:
|
||||
result = sprite.provider.generate(prompt, **kwargs)
|
||||
except Exception as exc: # noqa: BLE001 - normalize provider crashes
|
||||
logger.debug("provider.generate crashed: %s", exc)
|
||||
return None, str(exc)
|
||||
if not isinstance(result, dict) or not result.get("success"):
|
||||
return None, (result or {}).get("error", "unknown error") if isinstance(result, dict) else "no result"
|
||||
image_ref = result.get("image")
|
||||
if not image_ref:
|
||||
return None, "provider returned no image"
|
||||
try:
|
||||
return _save_local(str(image_ref), prefix=prefix), ""
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return None, f"could not save generated image: {exc}"
|
||||
|
||||
out: list[Path] = []
|
||||
last_error = ""
|
||||
allow_transparent = True
|
||||
for _ in range(max(1, n)):
|
||||
path, err = _run({"background": "transparent"} if allow_transparent else {})
|
||||
# Model doesn't support the transparent flag → drop it for this and every
|
||||
# remaining variant (no point re-probing a capability we just disproved).
|
||||
if path is None and allow_transparent and _rejected_background(err):
|
||||
allow_transparent = False
|
||||
path, err = _run({})
|
||||
if path is not None:
|
||||
out.append(path)
|
||||
else:
|
||||
last_error = err
|
||||
|
||||
if not out:
|
||||
raise GenerationError(last_error or "image generation produced no output")
|
||||
return out
|
||||
358
agent/pet/generate/orchestrate.py
Normal file
358
agent/pet/generate/orchestrate.py
Normal file
@@ -0,0 +1,358 @@
|
||||
"""Pet generation orchestration — the base-draft → hatch flow.
|
||||
|
||||
Two steps, mirroring the UX across every surface:
|
||||
|
||||
1. :func:`generate_base_drafts` — a handful of prompt-only "what should this pet
|
||||
look like" variants. Cheap; the user picks one (or retries for a fresh set).
|
||||
2. :func:`hatch_pet` — takes the chosen base and generates one grounded row
|
||||
strip per Hermes state, slices each into frames, composes the atlas, validates
|
||||
it, and writes the pet into the store.
|
||||
|
||||
Splitting it this way bounds cost (4 cheap base calls per round; the ~6 row
|
||||
calls happen once, on the pet you actually keep) and gives each UI a natural
|
||||
preview/loading point.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from agent.pet.generate import atlas, imagegen, prompts
|
||||
from agent.pet.generate.imagegen import GenerationError, SpriteProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (event, detail) — e.g. ("row", "idle"), ("compose", ""), ("save", "<slug>").
|
||||
ProgressFn = Callable[[str, str], None]
|
||||
|
||||
# Image generations are independent network calls, so we fan them out instead of
|
||||
# blocking on each in turn — a hatch is ~8 row calls that would otherwise run
|
||||
# back-to-back and routinely blow past the client's RPC timeout. Capped so we
|
||||
# don't hammer the provider's rate limit (one cold call can still be slow).
|
||||
_MAX_PARALLEL_GENERATIONS = 4
|
||||
# How many times to (re)generate a single row before accepting a best-effort
|
||||
# slice. Early attempts demand clean per-pose gutters; the last is lenient so a
|
||||
# stubborn row still yields frames instead of dropping out entirely.
|
||||
_ROW_GEN_ATTEMPTS = 3
|
||||
_MIN_FILLED_STATES = 6
|
||||
_REQUIRED_STATES = frozenset({"idle", "running-right", "waving"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HatchResult:
|
||||
"""Outcome of a successful :func:`hatch_pet`."""
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
spritesheet: Path
|
||||
states: list[str]
|
||||
validation: dict
|
||||
|
||||
|
||||
def _harden_transparency(path: Path) -> Path:
|
||||
"""Key out any solid backdrop the provider painted; save as an RGBA PNG.
|
||||
|
||||
``background=transparent`` is requested on every call, but image models honor
|
||||
it inconsistently — some still paint a flat (often near-white) backdrop. We
|
||||
run the same chroma-key pass the row extractor uses so every base draft the
|
||||
user picks between (and the reference the rows are grounded on) is a clean
|
||||
cutout. Best-effort: a decode failure leaves the original untouched.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
with Image.open(path) as opened:
|
||||
keyed = atlas.remove_background(opened.convert("RGBA"))
|
||||
# Zero the RGB of any leftover semi-transparent edge pixels so a keyed
|
||||
# draft has no colored halo when composited on the dark UI.
|
||||
keyed = atlas._clear_transparent_rgb(keyed)
|
||||
out = path.with_suffix(".png")
|
||||
keyed.save(out, format="PNG")
|
||||
return out
|
||||
except Exception as exc: # noqa: BLE001 - cosmetic; fall back to the raw image
|
||||
logger.debug("base draft transparency hardening failed for %s: %s", path, exc)
|
||||
return path
|
||||
|
||||
|
||||
def generate_base_drafts(
|
||||
concept: str,
|
||||
*,
|
||||
n: int = 4,
|
||||
style: str = "auto",
|
||||
reference_images: list[Path] | None = None,
|
||||
provider: SpriteProvider | None = None,
|
||||
on_draft: Callable[[int, Path], None] | None = None,
|
||||
is_cancelled: Callable[[], bool] | None = None,
|
||||
) -> list[Path]:
|
||||
"""Generate *n* candidate base looks for *concept*; returns image paths.
|
||||
|
||||
Each draft is hardened to a transparent cutout (see :func:`_harden_transparency`).
|
||||
Drafts are generated concurrently and *on_draft(index, path)* fires as each
|
||||
one finishes (not at the end) so callers can stream previews to the UI
|
||||
instead of leaving it blank until the whole batch is done.
|
||||
|
||||
*is_cancelled*, when supplied, is polled cooperatively: a draft that hasn't
|
||||
started yet is skipped, and once it trips we stop staging/streaming further
|
||||
drafts and cancel any queued work (already-in-flight provider calls can't be
|
||||
hard-killed, but their results are dropped).
|
||||
"""
|
||||
# A user reference image (e.g. their own pet) grounds every draft, so it
|
||||
# needs a reference-capable provider — same requirement as the row passes.
|
||||
refs = reference_images or None
|
||||
sprite = provider or imagegen.resolve_provider(require_references=bool(refs))
|
||||
cancelled = is_cancelled or (lambda: False)
|
||||
|
||||
# Each draft is its own one-shot generation, run concurrently so the user
|
||||
# waits for one image, not N. A single draft failing must not sink the set.
|
||||
# Each gets a distinct variation nudge so the options aren't near-duplicates.
|
||||
logger.info("pet generate: drafting %d base looks for %r (style=%s)", n, concept, style)
|
||||
|
||||
def _one(index: int) -> tuple[int, Path | None, str | None]:
|
||||
if cancelled():
|
||||
return index, None, None
|
||||
t0 = time.monotonic()
|
||||
variation = prompts.BASE_VARIATIONS[index % len(prompts.BASE_VARIATIONS)]
|
||||
prompt = prompts.build_base_prompt(concept, style=style, variation=variation)
|
||||
try:
|
||||
out = imagegen.generate(prompt, n=1, reference_images=refs, provider=sprite, prefix="pet_base")
|
||||
except Exception as exc: # noqa: BLE001 - tolerate a single failed draft
|
||||
logger.warning("pet generate: draft %d failed after %.1fs: %s", index, time.monotonic() - t0, exc)
|
||||
return index, None, str(exc)
|
||||
if not out:
|
||||
logger.warning("pet generate: draft %d produced no image", index)
|
||||
return index, None, "the image provider returned no image"
|
||||
logger.info("pet generate: draft %d ready in %.1fs", index, time.monotonic() - t0)
|
||||
return index, _harden_transparency(out[0]), None
|
||||
|
||||
workers = max(1, min(n, _MAX_PARALLEL_GENERATIONS))
|
||||
results: dict[int, Path] = {}
|
||||
errors: list[str] = []
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = [pool.submit(_one, i) for i in range(n)]
|
||||
# as_completed runs in *this* (the caller's) thread, so on_draft — and any
|
||||
# gateway event it emits — inherits the request's bound transport, unlike
|
||||
# the worker threads above.
|
||||
for fut in as_completed(futures):
|
||||
if cancelled():
|
||||
logger.info("pet generate: cancelled — dropping remaining drafts")
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
break
|
||||
index, path, err = fut.result()
|
||||
if path is None:
|
||||
if err:
|
||||
errors.append(err)
|
||||
continue
|
||||
results[index] = path
|
||||
if on_draft is not None:
|
||||
try:
|
||||
on_draft(index, path)
|
||||
except Exception as exc: # noqa: BLE001 - progress is best-effort
|
||||
logger.debug("on_draft callback failed: %s", exc)
|
||||
|
||||
drafts = [results[i] for i in sorted(results)]
|
||||
if not drafts and not cancelled():
|
||||
# Surface *why* — every draft failed for a reason (a content-policy refusal
|
||||
# on a name like "minion", a provider/auth error, …); the most common one
|
||||
# is the representative cause. Far more useful than "no usable drafts".
|
||||
raise GenerationError(_drafts_failed_reason(errors))
|
||||
return drafts
|
||||
|
||||
|
||||
def _drafts_failed_reason(errors: list[str]) -> str:
|
||||
"""The representative reason a draft round produced nothing, humanized."""
|
||||
if not errors:
|
||||
return "image generation produced no usable drafts"
|
||||
from collections import Counter
|
||||
|
||||
return _humanize_image_error(Counter(errors).most_common(1)[0][0])
|
||||
|
||||
|
||||
def _humanize_image_error(error: str) -> str:
|
||||
"""Turn a raw provider error into a friendly, actionable sentence.
|
||||
|
||||
The big one is moderation: image models refuse trademarked characters and
|
||||
real people (e.g. "minion"), which reads as an opaque 400 otherwise.
|
||||
"""
|
||||
low = error.lower()
|
||||
if any(s in low for s in ("moderation_blocked", "safety system", "content policy", "content_policy")):
|
||||
return (
|
||||
"The image provider blocked this prompt — its safety filter rejects "
|
||||
"trademarked characters and real people. Try an original description."
|
||||
)
|
||||
if any(s in low for s in ("api key", "unauthorized", "401", "auth")):
|
||||
return "The image provider rejected the request — check your API key in Settings → Providers."
|
||||
if "rate limit" in low or "429" in low:
|
||||
return "The image provider is rate-limiting — wait a moment and try again."
|
||||
# Otherwise the first line, trimmed of the noisy provider envelope.
|
||||
return error.splitlines()[0].strip()[:200]
|
||||
|
||||
|
||||
def hatch_pet(
|
||||
*,
|
||||
base_image: str | Path,
|
||||
slug: str,
|
||||
display_name: str = "",
|
||||
description: str = "",
|
||||
concept: str = "",
|
||||
style: str = "auto",
|
||||
on_progress: ProgressFn | None = None,
|
||||
provider: SpriteProvider | None = None,
|
||||
is_cancelled: Callable[[], bool] | None = None,
|
||||
) -> HatchResult:
|
||||
"""Turn an approved base image into a full, installed Hermes pet.
|
||||
|
||||
Generates a grounded row strip per state, extracts frames, composes +
|
||||
validates the atlas, and registers it. The idle row falls back to the base
|
||||
look so the pet always renders. Raises :class:`GenerationError` on failure.
|
||||
|
||||
*is_cancelled*, when supplied, is polled cooperatively: rows that haven't
|
||||
started are skipped, queued rows are cancelled, and once every row is done we
|
||||
abort (raising :class:`GenerationError`) before composing/saving so a stopped
|
||||
hatch never writes a half-built pet.
|
||||
"""
|
||||
base = Path(base_image)
|
||||
if not base.is_file():
|
||||
raise GenerationError(f"base image not found: {base}")
|
||||
|
||||
sprite = provider or imagegen.resolve_provider(require_references=True)
|
||||
progress = on_progress or (lambda *_: None)
|
||||
cancelled = is_cancelled or (lambda: False)
|
||||
label = concept or display_name or slug
|
||||
|
||||
frames_by_state: dict[str, list] = {}
|
||||
total_rows = len(atlas.ROW_SPECS)
|
||||
logger.info("pet hatch %r: generating %d animation rows", slug, total_rows)
|
||||
|
||||
# Generate every state's row strip concurrently — they're independent
|
||||
# grounded calls, so the hatch waits for the slowest row, not their sum. A
|
||||
# single row failing is tolerated (idle is guaranteed below).
|
||||
def _gen_row(spec: tuple[str, int, int]) -> tuple[str, list | None]:
|
||||
state, _row, count = spec
|
||||
if cancelled():
|
||||
return state, None
|
||||
t0 = time.monotonic()
|
||||
last_exc: Exception | None = None
|
||||
# Self-healing: a model occasionally returns a row whose poses are touching
|
||||
# (no clean gutters), which slices badly. We retry such rolls; only the
|
||||
# final attempt falls back to lenient ``auto`` slicing so a stubborn row
|
||||
# still yields *something* rather than dropping the whole row.
|
||||
for attempt in range(_ROW_GEN_ATTEMPTS):
|
||||
if cancelled():
|
||||
return state, None
|
||||
strict = attempt < _ROW_GEN_ATTEMPTS - 1
|
||||
try:
|
||||
strips = imagegen.generate(
|
||||
prompts.build_row_prompt(state, count, label, style=style),
|
||||
n=1,
|
||||
reference_images=[base],
|
||||
provider=sprite,
|
||||
prefix=f"pet_row_{state}",
|
||||
# Wider canvas → each frame gets real horizontal room, so winged
|
||||
# poses keep a full, healthy size and still leave clean gutters.
|
||||
aspect_ratio="landscape",
|
||||
)
|
||||
# ``components`` requires clean per-pose gutters (raises otherwise),
|
||||
# so a touching roll is rejected and regenerated; the last attempt
|
||||
# uses ``auto`` (equal-slot fallback, never raises). Raw (fit=False)
|
||||
# so normalize_cells registers the whole pet at once.
|
||||
method = "components" if strict else "auto"
|
||||
frames = atlas.extract_strip_frames(strips[0], count, method=method, fit=False)
|
||||
logger.info(
|
||||
"pet hatch %r: row %r ready in %.1fs (attempt %d)",
|
||||
slug, state, time.monotonic() - t0, attempt + 1,
|
||||
)
|
||||
return state, frames
|
||||
except Exception as exc: # noqa: BLE001 - retried; one bad row is tolerated
|
||||
last_exc = exc
|
||||
logger.warning(
|
||||
"pet hatch %r: row %r attempt %d/%d failed: %s",
|
||||
slug, state, attempt + 1, _ROW_GEN_ATTEMPTS, exc,
|
||||
)
|
||||
logger.warning(
|
||||
"pet hatch %r: row %r gave up after %.1fs: %s",
|
||||
slug, state, time.monotonic() - t0, last_exc,
|
||||
)
|
||||
return state, None
|
||||
|
||||
# running-left is derived by mirroring running-right (guaranteed-consistent
|
||||
# and one fewer generation), so we don't generate it directly.
|
||||
generated_specs = [spec for spec in atlas.ROW_SPECS if spec[0] != "running-left"]
|
||||
|
||||
workers = max(1, min(len(generated_specs), _MAX_PARALLEL_GENERATIONS))
|
||||
done = 0
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = [pool.submit(_gen_row, spec) for spec in generated_specs]
|
||||
# as_completed runs on the caller (request) thread, so progress events
|
||||
# emitted here inherit the request transport — unlike the worker threads.
|
||||
for fut in as_completed(futures):
|
||||
if cancelled():
|
||||
logger.info("pet hatch %r: cancelled — dropping remaining rows", slug)
|
||||
for pending in futures:
|
||||
pending.cancel()
|
||||
break
|
||||
state, frames = fut.result()
|
||||
done += 1
|
||||
progress("row", f"{state}:{done}:{total_rows}")
|
||||
if frames:
|
||||
frames_by_state[state] = frames
|
||||
|
||||
if cancelled():
|
||||
raise GenerationError("hatch cancelled")
|
||||
|
||||
# Derive running-left from the approved running-right row (per-frame mirror,
|
||||
# preserving order/timing). Missing running-right is rejected below; a pet
|
||||
# without its canonical walk cycle is a failed hatch, not a shippable mascot.
|
||||
right = frames_by_state.get("running-right")
|
||||
if right:
|
||||
done += 1
|
||||
progress("row", f"running-left:{done}:{total_rows}")
|
||||
frames_by_state["running-left"] = atlas.mirror_frames(right)
|
||||
logger.info("pet hatch %r: row 'running-left' mirrored from running-right", slug)
|
||||
else:
|
||||
logger.warning("pet hatch %r: no running-right to mirror; left walk left empty", slug)
|
||||
|
||||
# Idle is the resting state the renderer falls back to — guarantee it.
|
||||
if not frames_by_state.get("idle"):
|
||||
progress("row", "idle-fallback")
|
||||
frames_by_state["idle"] = [atlas.single_frame(base, fit=False)]
|
||||
|
||||
progress("compose", "")
|
||||
logger.info("pet hatch %r: composing atlas from %d states", slug, len(frames_by_state))
|
||||
# One shared scale + baseline across every state so the pet never slides or
|
||||
# pulses size between frames; compose just packs the normalized cells.
|
||||
sheet = atlas.compose_atlas(atlas.normalize_cells(frames_by_state))
|
||||
validation = atlas.validate_atlas(sheet)
|
||||
if not validation["ok"]:
|
||||
raise GenerationError("; ".join(validation["errors"]) or "atlas validation failed")
|
||||
filled_states = set(validation["filled_states"])
|
||||
missing_required = sorted(_REQUIRED_STATES - filled_states)
|
||||
if missing_required:
|
||||
raise GenerationError(f"missing required animation row(s): {', '.join(missing_required)}")
|
||||
if len(filled_states) < _MIN_FILLED_STATES:
|
||||
raise GenerationError(
|
||||
f"only {len(filled_states)}/{len(atlas.ROW_SPECS)} animation rows were usable; regenerate"
|
||||
)
|
||||
|
||||
from agent.pet import store
|
||||
|
||||
progress("save", slug)
|
||||
logger.info("pet hatch %r: saving pet", slug)
|
||||
pet = store.register_local_pet(
|
||||
sheet,
|
||||
slug=slug,
|
||||
display_name=display_name or slug,
|
||||
description=description,
|
||||
)
|
||||
return HatchResult(
|
||||
slug=pet.slug,
|
||||
display_name=pet.display_name,
|
||||
spritesheet=pet.spritesheet,
|
||||
states=validation["filled_states"],
|
||||
validation=validation,
|
||||
)
|
||||
183
agent/pet/generate/prompts.py
Normal file
183
agent/pet/generate/prompts.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""Prompt builders for pet generation.
|
||||
|
||||
Two prompt shapes: a *base* prompt (prompt-only, produces the canonical look the
|
||||
user picks between) and per-*state* *row* prompts (grounded on the chosen base,
|
||||
produce one horizontal strip of N poses). Prompts stay concise and
|
||||
sprite-production oriented; the identity lock and "one transparent row" framing
|
||||
matter more than flowery description.
|
||||
|
||||
We generate the full petdex/Codex nine-state set (see
|
||||
:data:`agent.pet.generate.atlas.ROW_SPECS`) so a hatched pet is a valid
|
||||
``petdex submit`` spritesheet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# What each petdex/Codex state should depict (kept short — these go straight into
|
||||
# the row prompt). Phrased to avoid the common sprite-gen failure modes (detached
|
||||
# effects, motion lines, shadows). Critical distinction: ``running`` is the
|
||||
# *working* state (in place), while ``running-right`` / ``running-left`` are the
|
||||
# actual directional walk/run cycles.
|
||||
STATE_ACTIONS: dict[str, str] = {
|
||||
"idle": "a calm idle loop: subtle breathing, a tiny blink or gentle bob, no big gestures",
|
||||
"running-right": (
|
||||
"a sideways walk/run locomotion cycle moving to the RIGHT: the character "
|
||||
"faces and travels right with clear directional steps, a smooth gait loop"
|
||||
),
|
||||
"running-left": (
|
||||
"a sideways walk/run locomotion cycle moving to the LEFT: the character "
|
||||
"faces and travels left with clear directional steps (the mirror of the "
|
||||
"right-facing run)"
|
||||
),
|
||||
"waving": "a friendly greeting: raising a paw/hand/limb to wave, clear up-and-down gesture",
|
||||
"jumping": "a happy celebration jump: anticipation, lift off the ground, peak, and land",
|
||||
"failed": "a sad or deflated reaction: slumped, dejected, small frown — readable but not noisy",
|
||||
"waiting": (
|
||||
"an expectant 'waiting on you' pose: looking up/out as if asking for input "
|
||||
"or approval — distinct from idle and review"
|
||||
),
|
||||
"running": (
|
||||
"focused active work, staying IN PLACE (NOT walking or foot-running): "
|
||||
"leaning in, concentrating, busy 'thinking / processing / typing' energy"
|
||||
),
|
||||
"review": "careful inspection: a focused lean, head tilt, studying something intently",
|
||||
}
|
||||
|
||||
_STYLE_HINTS: dict[str, str] = {
|
||||
# Default to the popular petdex look: crisp 16-bit PIXEL ART, not the smooth
|
||||
# 2D illustration (let alone 3D render) gpt-image reaches for by default.
|
||||
"auto": (
|
||||
" Style: crisp 16-bit PIXEL-ART game sprite — visible square pixels, a small "
|
||||
"limited palette, clean dark outline, flat cel shading, chunky chibi "
|
||||
"proportions, like a classic SNES/JRPG party member or a petdex.dev mascot. "
|
||||
"Absolutely NOT 3D-rendered, NOT a smooth painted or vector illustration, "
|
||||
"NOT photorealistic — no soft gradients, no realistic lighting, no figurine look."
|
||||
),
|
||||
"pixel": " Render in clean 16-bit pixel-art style with visible square pixels and a limited palette.",
|
||||
"plush": " Render as a soft plush toy.",
|
||||
"clay": " Render as a claymation / soft 3D clay figure.",
|
||||
"sticker": " Render as a glossy die-cut sticker.",
|
||||
"flat-vector": " Render in flat vector mascot style.",
|
||||
"3d-toy": " Render as a glossy 3D toy.",
|
||||
"painterly": " Render in a soft painterly style.",
|
||||
}
|
||||
|
||||
_BACKGROUND = (
|
||||
"Center the character on a SINGLE flat, uniform, high-contrast chroma-key "
|
||||
"background — pure hot magenta #FF00FF (only if magenta appears on the "
|
||||
"character, use pure green #00FF00 instead). The background is ONE continuous "
|
||||
"even color that completely surrounds the character with NO gradient, "
|
||||
"vignette, texture, pattern, scenery, shadow, ground line, frame, border, "
|
||||
"panel, comic cell, gutter line, grid, or divider of any kind, so it keys out "
|
||||
"cleanly. The background color must not appear anywhere on the character. "
|
||||
"No text, no labels, no speech bubbles, no UI."
|
||||
)
|
||||
|
||||
|
||||
def style_hint(style: str | None) -> str:
|
||||
return _STYLE_HINTS.get((style or "auto").strip().lower(), "")
|
||||
|
||||
|
||||
# Row strips are generated on the wider landscape canvas (see imagegen.generate /
|
||||
# orchestrate). The extra width is what lets each pose stay a healthy size AND
|
||||
# leave a real gutter — used here only to cite concrete pixel numbers.
|
||||
_ASSUMED_STRIP_WIDTH = 1536
|
||||
|
||||
|
||||
def _spacing_spec(frame_count: int) -> tuple[int, int]:
|
||||
"""(per-pose width px, gap px) for a row of *frame_count* poses.
|
||||
|
||||
Pixel counts alone don't hold — the model fills each slot edge-to-edge with
|
||||
the full wingspan, so neighbors touch even when bodies are spaced. The lever
|
||||
that works is proportional containment on a wide canvas: give each pose its
|
||||
own equal cell and keep the ENTIRE silhouette (wings/tail/halo included)
|
||||
inside it. On the 1536px landscape strip ~70% occupancy still leaves a
|
||||
generous gutter, so the pet stays a normal, good-looking size — no shrinking.
|
||||
"""
|
||||
slots = max(1, frame_count)
|
||||
slot_w = _ASSUMED_STRIP_WIDTH / slots
|
||||
pose_px = round(slot_w * 0.7)
|
||||
gap_px = max(48, round(slot_w * 0.3))
|
||||
return pose_px, gap_px
|
||||
|
||||
|
||||
# Per-draft nudges so the 4 base options are actually distinct — gpt-image returns
|
||||
# near-duplicates for a single prompt. We vary the *look* (palette, build,
|
||||
# expression, accents), NOT the pose, so the chosen base still grounds clean,
|
||||
# consistent animation rows.
|
||||
BASE_VARIATIONS: tuple[str, ...] = (
|
||||
"",
|
||||
"a distinctly different colour palette and markings",
|
||||
"a heavier, broader silhouette with sturdier proportions",
|
||||
"a different facial structure and expression matching the concept tone, with unique accent/accessory details",
|
||||
"a leaner, taller build and an alternate colour scheme",
|
||||
"bolder, more saturated colours and a stronger expression matching the concept tone",
|
||||
)
|
||||
|
||||
|
||||
def build_base_prompt(concept: str, *, style: str | None = "auto", variation: str = "") -> str:
|
||||
"""The base look: a single, clean, centered full-body mascot.
|
||||
|
||||
*variation* differentiates one draft from the next (see :data:`BASE_VARIATIONS`).
|
||||
"""
|
||||
concept = (concept or "a distinctive mascot creature").strip()
|
||||
nudge = f" Make this design distinct: {variation}." if variation else ""
|
||||
return (
|
||||
f"A stylized mascot pet character: {concept}. "
|
||||
"Honor the requested tone and mood exactly (cute, eerie, scary, menacing, whimsical, etc.) "
|
||||
"while staying non-graphic. "
|
||||
"Compact, whole-body silhouette that reads clearly at small size, "
|
||||
"clear readable facial features, simple consistent palette. "
|
||||
# A neutral, symmetric, at-rest stance makes the cleanest identity anchor
|
||||
"Neutral front-facing standing pose, upright and symmetric, arms/limbs "
|
||||
"relaxed at the sides, feet together on the ground, any cape/accessories "
|
||||
"hanging straight and still."
|
||||
f"{nudge} "
|
||||
f"{_BACKGROUND}{style_hint(style)}"
|
||||
)
|
||||
|
||||
|
||||
def build_row_prompt(state: str, frame_count: int, concept: str, *, style: str | None = "auto") -> str:
|
||||
"""A row strip: *frame_count* poses of the SAME character, left→right.
|
||||
|
||||
The attached base image is the identity source of truth; the prompt locks
|
||||
species, palette, face, and props to it.
|
||||
"""
|
||||
action = STATE_ACTIONS.get(state, "a simple idle pose")
|
||||
concept = (concept or "the mascot").strip()
|
||||
pose_px, gap_px = _spacing_spec(frame_count)
|
||||
return (
|
||||
f"Using the attached reference image as the exact same character "
|
||||
f"(same species, face, colors, markings, proportions, and props), "
|
||||
"preserving the same emotional tone/mood (e.g., scary stays scary, cute stays cute), "
|
||||
f"draw a single WIDE horizontal strip of {frame_count} animation frames showing {action}. "
|
||||
f"LAYOUT: arrange {frame_count} poses in ONE horizontal row at equal spacing, "
|
||||
"each pose centered in its own imaginary equal region. Draw NO panel borders, "
|
||||
"NO comic cells, NO boxes, NO vertical divider/gutter lines, NO grid, NO frame "
|
||||
"outlines between poses — the backdrop is one unbroken flat field behind all of them. "
|
||||
"Fill the WHOLE strip with the SAME single flat chroma-key color as the attached "
|
||||
"reference image's background (identical hue in every frame, no per-pose color shifts). "
|
||||
f"SPACING (critical): draw each pose at a consistent, healthy, clearly "
|
||||
f"visible size (roughly {pose_px}px wide on a {_ASSUMED_STRIP_WIDTH}px "
|
||||
f"strip) — do NOT shrink it tiny — but keep its ENTIRE silhouette "
|
||||
f"(wings, tail, halo, horns, cape, every appendage) fully INSIDE its own "
|
||||
f"cell. Leave at least {gap_px}px of empty chroma-key background between "
|
||||
f"neighboring silhouettes at their closest point (wingtip to wingtip), and "
|
||||
f"the same empty margin before the first pose and after the last. If a wing, "
|
||||
f"cape, or tail would reach into a neighbor, FOLD or angle it inward rather "
|
||||
f"than letting it cross the gap. Silhouettes must NEVER touch, overlap, "
|
||||
f"share a shadow, share a ground line, share motion trails, or merge into "
|
||||
f"one connected shape. "
|
||||
# Registration: a clean sprite sheet keeps the character locked in place
|
||||
# so only the action moves — this is what stops the loop sliding/pulsing.
|
||||
"REGISTRATION (critical): the character is the SAME height and SAME width "
|
||||
"in every frame, drawn at the SAME scale, centered over the SAME point, "
|
||||
"with all feet aligned to the SAME invisible horizontal baseline across the "
|
||||
"whole strip — this baseline is conceptual ONLY: draw NO ground line, floor, "
|
||||
"platform, horizon, or contact shadow beneath the feet. Keep the body's center, size, and stance fixed frame to "
|
||||
"frame — ONLY the limbs/features the action needs may move. Capes, cloaks, "
|
||||
"bags, and scarves stay in the SAME place and shape every frame (no "
|
||||
"swinging, flowing, or drifting) unless the action itself requires it. No "
|
||||
"pose is cropped at the strip edges. "
|
||||
f"{_BACKGROUND}{style_hint(style)}"
|
||||
)
|
||||
165
agent/pet/manifest.py
Normal file
165
agent/pet/manifest.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Fetch the public petdex manifest.
|
||||
|
||||
``https://petdex.dev/api/manifest`` 307-redirects to a JSON document on R2:
|
||||
|
||||
{
|
||||
"generatedAt": "...",
|
||||
"total": 2926,
|
||||
"pets": [
|
||||
{"slug": "boba", "displayName": "Boba", "kind": "creature",
|
||||
"submittedBy": "railly",
|
||||
"spritesheetUrl": "https://assets.petdex.dev/.../spritesheet.webp",
|
||||
"petJsonUrl": "https://assets.petdex.dev/.../pet.json",
|
||||
"zipUrl": "https://assets.petdex.dev/.../boba.zip"},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Read-only and unauthenticated; no credentials involved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MANIFEST_URL = "https://petdex.dev/api/manifest"
|
||||
|
||||
_DEFAULT_TIMEOUT = 10.0
|
||||
|
||||
# In-process cache for the (large, slow, identical-per-call) manifest. The list
|
||||
# is a static CDN object that barely changes, yet a single session can ask for
|
||||
# it many times — every gallery open, plus a full re-fetch per install/select
|
||||
# (``find_entry``). A short TTL collapses those into one network hit without
|
||||
# going stale for long. Cleared by :func:`clear_cache` (tests).
|
||||
_MANIFEST_TTL = 300.0
|
||||
_cache: tuple[float, list[ManifestEntry]] | None = None
|
||||
|
||||
_prefetch_lock = threading.Lock()
|
||||
_prefetching = False
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Drop the cached manifest (forces the next fetch to hit the network)."""
|
||||
global _cache
|
||||
_cache = None
|
||||
|
||||
|
||||
def _cache_is_warm() -> bool:
|
||||
return _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL
|
||||
|
||||
|
||||
def prefetch(*, timeout: float = _DEFAULT_TIMEOUT) -> None:
|
||||
"""Warm the manifest cache in a daemon thread — idempotent, never blocks.
|
||||
|
||||
The desktop picker calls this when it loads the (instant) local-only gallery
|
||||
so the full petdex catalog is usually cached by the time it's requested,
|
||||
without ever holding up the user's own pets on a network round-trip.
|
||||
"""
|
||||
global _prefetching
|
||||
|
||||
if _cache_is_warm():
|
||||
return
|
||||
|
||||
with _prefetch_lock:
|
||||
if _prefetching:
|
||||
return
|
||||
_prefetching = True
|
||||
|
||||
def _run() -> None:
|
||||
global _prefetching
|
||||
try:
|
||||
fetch_manifest(timeout=timeout)
|
||||
except Exception as exc: # noqa: BLE001 - best-effort warm
|
||||
logger.debug("petdex manifest prefetch failed: %s", exc)
|
||||
finally:
|
||||
_prefetching = False
|
||||
|
||||
threading.Thread(target=_run, name="petdex-prefetch", daemon=True).start()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestEntry:
|
||||
"""A single pet's row in the manifest."""
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
kind: str
|
||||
submitted_by: str
|
||||
spritesheet_url: str
|
||||
pet_json_url: str
|
||||
zip_url: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "ManifestEntry":
|
||||
return cls(
|
||||
slug=str(data.get("slug", "")).strip(),
|
||||
display_name=str(data.get("displayName", "") or data.get("slug", "")),
|
||||
kind=str(data.get("kind", "") or "pet"),
|
||||
submitted_by=str(data.get("submittedBy", "") or ""),
|
||||
spritesheet_url=str(data.get("spritesheetUrl", "") or ""),
|
||||
pet_json_url=str(data.get("petJsonUrl", "") or ""),
|
||||
zip_url=str(data.get("zipUrl", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
class ManifestError(RuntimeError):
|
||||
"""Raised when the manifest can't be fetched or parsed."""
|
||||
|
||||
|
||||
def fetch_manifest(*, timeout: float = _DEFAULT_TIMEOUT, force: bool = False) -> list[ManifestEntry]:
|
||||
"""Return every approved pet from the public manifest.
|
||||
|
||||
Cached in-process for ``_MANIFEST_TTL`` seconds (pass ``force=True`` to
|
||||
bypass). Follows the 307 redirect to R2. Raises :class:`ManifestError` on
|
||||
any network/parse failure so callers can surface a clean message.
|
||||
"""
|
||||
global _cache
|
||||
|
||||
if not force and _cache is not None and time.monotonic() - _cache[0] < _MANIFEST_TTL:
|
||||
return _cache[1]
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError as exc: # pragma: no cover - httpx is a core dep
|
||||
raise ManifestError("httpx is required to fetch the petdex manifest") from exc
|
||||
|
||||
try:
|
||||
resp = httpx.get(
|
||||
MANIFEST_URL,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "hermes-agent-petdex"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except Exception as exc: # noqa: BLE001 - normalize to one error type
|
||||
raise ManifestError(f"could not fetch petdex manifest: {exc}") from exc
|
||||
|
||||
pets = payload.get("pets") if isinstance(payload, dict) else None
|
||||
if not isinstance(pets, list):
|
||||
raise ManifestError("petdex manifest had no 'pets' array")
|
||||
|
||||
entries: list[ManifestEntry] = []
|
||||
for raw in pets:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
entry = ManifestEntry.from_dict(raw)
|
||||
if entry.slug and entry.spritesheet_url:
|
||||
entries.append(entry)
|
||||
|
||||
_cache = (time.monotonic(), entries)
|
||||
return entries
|
||||
|
||||
|
||||
def find_entry(slug: str, *, timeout: float = _DEFAULT_TIMEOUT) -> ManifestEntry | None:
|
||||
"""Return the manifest entry for *slug*, or ``None`` if not listed."""
|
||||
slug = slug.strip().lower()
|
||||
for entry in fetch_manifest(timeout=timeout):
|
||||
if entry.slug.lower() == slug:
|
||||
return entry
|
||||
return None
|
||||
618
agent/pet/render.py
Normal file
618
agent/pet/render.py
Normal file
@@ -0,0 +1,618 @@
|
||||
"""Decode a pet spritesheet and encode frames for a terminal.
|
||||
|
||||
Shared by the base CLI (writes the escape bytes to its own stdout) and the
|
||||
TUI (``tui_gateway`` ships the encoded bytes to Ink, which writes them) so the
|
||||
decode + capability-detection + protocol-encoding logic exists exactly once.
|
||||
|
||||
Supported output modes, in fidelity order:
|
||||
|
||||
- ``kitty`` — the kitty graphics protocol (kitty, Ghostty, WezTerm).
|
||||
- ``iterm`` — iTerm2 inline images (iTerm2, WezTerm).
|
||||
- ``sixel`` — DEC sixel (xterm -ti vt340, foot, mlterm, WezTerm, …).
|
||||
- ``unicode`` — 24-bit half-block downscale; works in any truecolor terminal.
|
||||
|
||||
Frame decoding requires Pillow (a core Hermes dependency). If Pillow or the
|
||||
spritesheet is unavailable the renderer degrades to ``unicode`` text or an
|
||||
empty string rather than raising.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from agent.pet.constants import (
|
||||
DEFAULT_SCALE,
|
||||
FRAME_H,
|
||||
FRAME_W,
|
||||
FRAMES_PER_STATE,
|
||||
PetState,
|
||||
state_row_index,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Public render-mode names accepted by ``display.pet.render_mode``.
|
||||
RENDER_MODES = ("auto", "kitty", "iterm", "sixel", "unicode", "off")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Terminal capability detection
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def detect_terminal_graphics() -> str:
|
||||
"""Best-effort detection of the richest graphics protocol available.
|
||||
|
||||
Env-based (non-blocking — we never issue a DA1/terminal query that could
|
||||
hang a pipe). Returns one of ``kitty`` / ``iterm`` / ``sixel`` /
|
||||
``unicode``. Conservative: unknown terminals get ``unicode``, which works
|
||||
anywhere with truecolor.
|
||||
"""
|
||||
term = os.environ.get("TERM", "").lower()
|
||||
term_program = os.environ.get("TERM_PROGRAM", "").lower()
|
||||
|
||||
# The VS Code / Cursor integrated terminal sets TERM_PROGRAM=vscode
|
||||
# authoritatively but does NOT scrub the terminal env vars it inherits when
|
||||
# launched from another emulator (ITERM_SESSION_ID, KITTY_WINDOW_ID, …).
|
||||
# Trusting those leaks emits an image protocol the embedded xterm.js can't
|
||||
# display — you get a blank frame. Inline images there are opt-in
|
||||
# (terminal.integrated.enableImages), so default to half-blocks, which
|
||||
# always render in its truecolor grid. Users who enabled images can pin
|
||||
# display.pet.render_mode explicitly.
|
||||
if term_program == "vscode":
|
||||
return "unicode"
|
||||
|
||||
# kitty graphics protocol
|
||||
if os.environ.get("KITTY_WINDOW_ID") or "kitty" in term or "ghostty" in term:
|
||||
return "kitty"
|
||||
if term_program in {"ghostty"}:
|
||||
return "kitty"
|
||||
|
||||
# WezTerm speaks both kitty and iterm; prefer kitty (richer placement).
|
||||
if term_program == "wezterm" or os.environ.get("WEZTERM_PANE"):
|
||||
return "kitty"
|
||||
|
||||
# iTerm2 inline images
|
||||
if term_program == "iterm.app" or os.environ.get("ITERM_SESSION_ID"):
|
||||
return "iterm"
|
||||
|
||||
# sixel-capable terminals (env heuristics only)
|
||||
if term_program in {"mintty"} or "foot" in term or "mlterm" in term:
|
||||
return "sixel"
|
||||
if "sixel" in term:
|
||||
return "sixel"
|
||||
|
||||
return "unicode"
|
||||
|
||||
|
||||
def resolve_mode(configured: str | None, *, stream=None) -> str:
|
||||
"""Resolve the effective render mode from config + the environment.
|
||||
|
||||
``configured`` is ``display.pet.render_mode`` (``auto`` → detect). Returns
|
||||
``off`` when not attached to a TTY (no point emitting graphics into a pipe
|
||||
or logfile).
|
||||
"""
|
||||
mode = (configured or "auto").strip().lower()
|
||||
if mode not in RENDER_MODES:
|
||||
mode = "auto"
|
||||
if mode == "off":
|
||||
return "off"
|
||||
|
||||
stream = stream or sys.stdout
|
||||
try:
|
||||
if not (hasattr(stream, "isatty") and stream.isatty()):
|
||||
return "off"
|
||||
except (ValueError, OSError):
|
||||
return "off"
|
||||
|
||||
if mode == "auto":
|
||||
return detect_terminal_graphics()
|
||||
return mode
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Frame decoding
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _open_sheet(path: Path):
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(path)
|
||||
return img.convert("RGBA")
|
||||
|
||||
|
||||
# Max alpha at/below which a frame counts as blank padding. petdex sheets are
|
||||
# left-packed: a state with fewer real frames than ``FRAMES_PER_STATE`` fills
|
||||
# the trailing columns with fully transparent cells. Animating into one flashes
|
||||
# the pet blank, so we stop the row at the first such gap.
|
||||
_BLANK_ALPHA = 8
|
||||
|
||||
|
||||
def _frame_is_blank(frame) -> bool:
|
||||
"""True if *frame* has no meaningfully opaque pixel (transparent padding)."""
|
||||
return frame.getchannel("A").getextrema()[1] <= _BLANK_ALPHA
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def _raw_frames(
|
||||
sheet_path: str,
|
||||
state_value: str,
|
||||
frame_w: int,
|
||||
frame_h: int,
|
||||
frames_per_state: int,
|
||||
) -> tuple:
|
||||
"""Cropped, padding-trimmed RGBA frames for one state row (unscaled).
|
||||
|
||||
Steps across the row until the first blank column so pets with ragged
|
||||
per-state frame counts never animate into empty padding. Cached; returns
|
||||
``()`` on any decode failure.
|
||||
"""
|
||||
try:
|
||||
sheet = _open_sheet(Path(sheet_path))
|
||||
cols = max(1, sheet.width // frame_w)
|
||||
rows = max(1, sheet.height // frame_h)
|
||||
row = state_row_index(state_value, rows)
|
||||
top = row * frame_h
|
||||
# Clamp the row to the sheet (some pets ship fewer rows than the 8 the
|
||||
# taxonomy reserves).
|
||||
if top + frame_h > sheet.height:
|
||||
top = max(0, sheet.height - frame_h)
|
||||
|
||||
frames = []
|
||||
for i in range(min(frames_per_state, cols)):
|
||||
left = i * frame_w
|
||||
frame = sheet.crop((left, top, left + frame_w, top + frame_h))
|
||||
if _frame_is_blank(frame):
|
||||
break # trailing transparent padding — real frames end here
|
||||
frames.append(frame)
|
||||
return tuple(frames)
|
||||
except Exception as exc: # noqa: BLE001 - cosmetic feature, never fatal
|
||||
logger.debug("pet frame decode failed (%s, %s): %s", sheet_path, state_value, exc)
|
||||
return ()
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _frames_for(
|
||||
sheet_path: str,
|
||||
state_value: str,
|
||||
frame_w: int,
|
||||
frame_h: int,
|
||||
frames_per_state: int,
|
||||
scale_w: int,
|
||||
scale_h: int,
|
||||
):
|
||||
"""Return padding-trimmed RGBA frames for one state row, scaled.
|
||||
|
||||
Thin scaling layer over :func:`_raw_frames`; both are cached so repeated
|
||||
frame requests during animation are free.
|
||||
"""
|
||||
raw = _raw_frames(sheet_path, state_value, frame_w, frame_h, frames_per_state)
|
||||
if not raw or (scale_w, scale_h) == (frame_w, frame_h):
|
||||
return list(raw)
|
||||
from PIL import Image
|
||||
|
||||
return [f.resize((scale_w, scale_h), Image.LANCZOS) for f in raw]
|
||||
|
||||
|
||||
def state_frame_counts(
|
||||
sheet_path: str | Path,
|
||||
*,
|
||||
frame_w: int = FRAME_W,
|
||||
frame_h: int = FRAME_H,
|
||||
frames_per_state: int = FRAMES_PER_STATE,
|
||||
) -> dict[str, int]:
|
||||
"""Map each driven :class:`PetState` → its real (padding-trimmed) frame count.
|
||||
|
||||
The single source of truth for "how many frames does this state actually
|
||||
have?". The CLI/TUI consume the trimmed frame lists directly; the gateway
|
||||
ships this map to the desktop canvas, which steps its own loop.
|
||||
"""
|
||||
return {
|
||||
state.value: len(
|
||||
_raw_frames(str(sheet_path), state.value, frame_w, frame_h, frames_per_state)
|
||||
)
|
||||
for state in PetState
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Encoders
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _png_bytes(frame) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
frame.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def _kitty_apc(ctrl: str, data: str) -> str:
|
||||
"""Emit a kitty APC escape for *data*, chunked into ≤4096-byte ``m`` pieces."""
|
||||
chunk = 4096
|
||||
if len(data) <= chunk:
|
||||
return f"\x1b_G{ctrl},m=0;{data}\x1b\\"
|
||||
out = [f"\x1b_G{ctrl},m=1;{data[:chunk]}\x1b\\"]
|
||||
rest = data[chunk:]
|
||||
while rest:
|
||||
piece, rest = rest[:chunk], rest[chunk:]
|
||||
out.append(f"\x1b_Gm={1 if rest else 0};{piece}\x1b\\")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _encode_kitty(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str:
|
||||
"""Encode one frame via the kitty graphics protocol (transmit + display).
|
||||
|
||||
``a=T`` transmits & displays at the cursor; ``c``/``r`` request a display
|
||||
box in terminal cells so successive frames overwrite the same area.
|
||||
"""
|
||||
ctrl = "f=100,a=T,q=2"
|
||||
if cell_cols:
|
||||
ctrl += f",c={cell_cols}"
|
||||
if cell_rows:
|
||||
ctrl += f",r={cell_rows}"
|
||||
return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii"))
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# kitty Unicode placeholders
|
||||
#
|
||||
# Ink (the TUI's React-for-terminal layer) owns the screen and measures every
|
||||
# cell's width, so it can't host raw kitty image escapes (no width to count,
|
||||
# clobbered on the next repaint). kitty's *Unicode placeholder* protocol is the
|
||||
# grid-safe path: transmit the image once (q=2, virtual placement U=1), then the
|
||||
# host app prints ordinary-width placeholder cells (U+10EEEE + diacritics) whose
|
||||
# foreground color encodes the image id. Ink counts those as width-1 text, so
|
||||
# layout stays correct and the terminal paints the image underneath.
|
||||
# https://sw.kovidgoyal.net/kitty/graphics-protocol/#unicode-placeholders
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_KITTY_PLACEHOLDER = "\U0010eeee"
|
||||
|
||||
# Row/column diacritics, in order (index → diacritic). Verbatim from kitty's
|
||||
# gen/rowcolumn-diacritics.txt (Unicode 6.0.0, combining class 230). Index i is
|
||||
# the diacritic that encodes the number i; we only ever need the row index.
|
||||
_ROWCOL_DIACRITICS: tuple[int, ...] = (
|
||||
0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A,
|
||||
0x034B, 0x034C, 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365,
|
||||
0x0366, 0x0367, 0x0368, 0x0369, 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F,
|
||||
0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, 0x0593, 0x0594, 0x0595, 0x0597,
|
||||
0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, 0x05A8, 0x05A9,
|
||||
0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615,
|
||||
0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6,
|
||||
0x06D7, 0x06D8, 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2,
|
||||
0x06E4, 0x06E7, 0x06E8, 0x06EB, 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736,
|
||||
0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, 0x0745, 0x0747, 0x0749, 0x074A,
|
||||
0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, 0x0816, 0x0817,
|
||||
0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822,
|
||||
0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951,
|
||||
0x0953, 0x0954, 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD,
|
||||
0x193A, 0x1A17, 0x1A75, 0x1A76, 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C,
|
||||
0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, 0x1B72, 0x1B73, 0x1CD0, 0x1CD1,
|
||||
0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, 0x1DC5, 0x1DC6,
|
||||
0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5,
|
||||
0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF,
|
||||
0x1DE0, 0x1DE1, 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1,
|
||||
0x20D4, 0x20D5, 0x20D6, 0x20D7, 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0,
|
||||
0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6,
|
||||
0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, 0x2DEF, 0x2DF0,
|
||||
0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA,
|
||||
0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1,
|
||||
0xA8E0, 0xA8E1, 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9,
|
||||
0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2,
|
||||
0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, 0xFE20, 0xFE21, 0xFE22, 0xFE23,
|
||||
0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, 0x1D188,
|
||||
0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244,
|
||||
)
|
||||
|
||||
|
||||
def kitty_image_id(slug: str) -> int:
|
||||
"""Stable per-pet image id in ``[1, 0x7FFF]``.
|
||||
|
||||
The id is encoded in the placeholder's 24-bit foreground color, so it must
|
||||
be non-zero and fit comfortably under ``0xFFFFFF``. A small CRC keeps it
|
||||
deterministic per slug (so re-renders reuse the same terminal-side image)
|
||||
while making collisions between two different pets unlikely.
|
||||
"""
|
||||
import zlib
|
||||
|
||||
return (zlib.crc32(slug.encode("utf-8")) % 0x7FFE) + 1
|
||||
|
||||
|
||||
def kitty_color_hex(image_id: int) -> str:
|
||||
"""Hex foreground color (``#rrggbb``) that encodes *image_id* for kitty."""
|
||||
return "#%06x" % (image_id & 0xFFFFFF)
|
||||
|
||||
|
||||
def kitty_placeholder_rows(cols: int, rows: int) -> list[str]:
|
||||
"""Build the placeholder text grid for an *rows*×*cols* image.
|
||||
|
||||
Each line is one row of the grid: the first cell carries the row diacritic
|
||||
(column defaults to 0), and the remaining ``cols-1`` bare placeholders let
|
||||
the terminal auto-increment the column. The foreground color (the image id)
|
||||
is applied by the caller / Ink, not embedded here.
|
||||
"""
|
||||
cols = max(1, cols)
|
||||
out: list[str] = []
|
||||
for r in range(max(1, rows)):
|
||||
idx = min(r, len(_ROWCOL_DIACRITICS) - 1)
|
||||
first = _KITTY_PLACEHOLDER + chr(_ROWCOL_DIACRITICS[idx])
|
||||
out.append(first + _KITTY_PLACEHOLDER * (cols - 1))
|
||||
return out
|
||||
|
||||
|
||||
def _encode_kitty_virtual(frame, *, image_id: int, cols: int, rows: int) -> str:
|
||||
"""Transmit a frame as a kitty *virtual* placement for Unicode placeholders.
|
||||
|
||||
``a=T`` transmits and creates the placement in one shot; ``U=1`` marks it
|
||||
virtual (no on-screen output, cursor untouched); ``q=2`` suppresses the
|
||||
terminal's OK/error replies that would otherwise corrupt the host app's
|
||||
output. Re-sending with the same ``i`` replaces the image, so the static
|
||||
placeholder cells animate underneath.
|
||||
"""
|
||||
ctrl = f"a=T,U=1,i={image_id},c={cols},r={rows},f=100,q=2"
|
||||
return _kitty_apc(ctrl, base64.standard_b64encode(_png_bytes(frame)).decode("ascii"))
|
||||
|
||||
|
||||
def _encode_iterm(frame, *, cell_cols: int | None = None, cell_rows: int | None = None) -> str:
|
||||
"""Encode one frame as an iTerm2 inline image (OSC 1337 File)."""
|
||||
payload = base64.standard_b64encode(_png_bytes(frame)).decode("ascii")
|
||||
size = len(payload)
|
||||
args = [f"inline=1", f"size={size}", "preserveAspectRatio=1"]
|
||||
if cell_cols:
|
||||
args.append(f"width={cell_cols}")
|
||||
if cell_rows:
|
||||
args.append(f"height={cell_rows}")
|
||||
return f"\x1b]1337;File={';'.join(args)}:{payload}\x07"
|
||||
|
||||
|
||||
def _encode_sixel(frame) -> str:
|
||||
"""Encode one frame as DEC sixel.
|
||||
|
||||
Quantizes to an adaptive palette (≤255 colors) and emits the sixel band
|
||||
stream. Pillow has no sixel writer, so this is a compact hand-rolled
|
||||
encoder. Transparent pixels render as background (color register skipped).
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
rgba = frame
|
||||
# Composite onto transparent-as-skip: track alpha to decide background.
|
||||
pal = rgba.convert("RGB").quantize(colors=255, method=Image.MEDIANCUT)
|
||||
palette = pal.getpalette() or []
|
||||
px = pal.load()
|
||||
alpha = rgba.getchannel("A").load()
|
||||
w, h = pal.size
|
||||
|
||||
out = ["\x1bP0;1;0q", '"1;1;%d;%d' % (w, h)]
|
||||
# Color register definitions (sixel uses 0..100 scale).
|
||||
used = sorted({px[x, y] for y in range(h) for x in range(w)})
|
||||
for idx in used:
|
||||
r = palette[idx * 3] if idx * 3 < len(palette) else 0
|
||||
g = palette[idx * 3 + 1] if idx * 3 + 1 < len(palette) else 0
|
||||
b = palette[idx * 3 + 2] if idx * 3 + 2 < len(palette) else 0
|
||||
out.append("#%d;2;%d;%d;%d" % (idx, r * 100 // 255, g * 100 // 255, b * 100 // 255))
|
||||
|
||||
# Emit in 6-row bands.
|
||||
for band in range(0, h, 6):
|
||||
for color_idx in used:
|
||||
line = ["#%d" % color_idx]
|
||||
run_char = None
|
||||
run_len = 0
|
||||
|
||||
def flush():
|
||||
nonlocal run_char, run_len
|
||||
if run_char is None:
|
||||
return
|
||||
if run_len > 3:
|
||||
line.append("!%d%s" % (run_len, run_char))
|
||||
else:
|
||||
line.append(run_char * run_len)
|
||||
run_char, run_len = None, 0
|
||||
|
||||
for x in range(w):
|
||||
bits = 0
|
||||
for bit in range(6):
|
||||
y = band + bit
|
||||
if y < h and alpha[x, y] > 32 and px[x, y] == color_idx:
|
||||
bits |= 1 << bit
|
||||
ch = chr(63 + bits)
|
||||
if ch == run_char:
|
||||
run_len += 1
|
||||
else:
|
||||
flush()
|
||||
run_char, run_len = ch, 1
|
||||
flush()
|
||||
out.append("".join(line) + "$") # carriage return within band
|
||||
out.append("-") # next band
|
||||
out.append("\x1b\\")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
_HALF_BLOCK = "▀"
|
||||
|
||||
# A single half-block cell: top pixel + bottom pixel as (r, g, b, a) tuples.
|
||||
Cell = tuple[tuple[int, int, int, int], tuple[int, int, int, int]]
|
||||
|
||||
|
||||
def _downscale_cells(frame, *, target_cols: int) -> list[list[Cell]]:
|
||||
"""Downscale a frame to a grid of half-block cells.
|
||||
|
||||
Each cell pairs a top and bottom pixel so one terminal row encodes two
|
||||
pixel rows. Returns rows of ``((tr,tg,tb,ta),(br,bg,bb,ba))`` — the
|
||||
framework-neutral representation shared by the ANSI encoder (CLI) and the
|
||||
structured ``cells`` API (Ink).
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
target_cols = max(4, target_cols)
|
||||
aspect = frame.height / max(1, frame.width)
|
||||
target_rows = max(2, int(round(target_cols * aspect * 0.5)) * 2)
|
||||
small = frame.resize((target_cols, target_rows), Image.LANCZOS).convert("RGBA")
|
||||
px = small.load()
|
||||
|
||||
grid: list[list[Cell]] = []
|
||||
for y in range(0, target_rows, 2):
|
||||
row: list[Cell] = []
|
||||
for x in range(target_cols):
|
||||
top = px[x, y]
|
||||
bottom = px[x, y + 1] if y + 1 < target_rows else (0, 0, 0, 0)
|
||||
row.append((top, bottom))
|
||||
grid.append(row)
|
||||
return grid
|
||||
|
||||
|
||||
def _encode_unicode(frame, *, target_cols: int) -> str:
|
||||
"""Downscale to truecolor ANSI half-blocks (one char = 2 vertical pixels)."""
|
||||
lines: list[str] = []
|
||||
for row in _downscale_cells(frame, target_cols=target_cols):
|
||||
cells: list[str] = []
|
||||
for (tr, tg, tb, ta), (br, bg, bb, ba) in row:
|
||||
if ta < 32 and ba < 32:
|
||||
cells.append("\x1b[0m ") # fully transparent → blank
|
||||
continue
|
||||
cells.append(f"\x1b[38;2;{tr};{tg};{tb}m\x1b[48;2;{br};{bg};{bb}m{_HALF_BLOCK}")
|
||||
lines.append("".join(cells) + "\x1b[0m")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Public renderer
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class PetRenderer:
|
||||
"""Holds a pet's spritesheet and yields encoded frames per (state, index).
|
||||
|
||||
Construct once per pet, then call :meth:`frame` on an animation timer.
|
||||
Cheap to call repeatedly — decoded frames are cached.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
spritesheet: str | Path,
|
||||
*,
|
||||
mode: str = "unicode",
|
||||
scale: float = DEFAULT_SCALE,
|
||||
unicode_cols: int = 20,
|
||||
frame_w: int = FRAME_W,
|
||||
frame_h: int = FRAME_H,
|
||||
frames_per_state: int = FRAMES_PER_STATE,
|
||||
) -> None:
|
||||
self.spritesheet = str(spritesheet)
|
||||
self.mode = mode if mode in RENDER_MODES else "unicode"
|
||||
self.scale = scale
|
||||
self.unicode_cols = unicode_cols
|
||||
self.frame_w = frame_w
|
||||
self.frame_h = frame_h
|
||||
self.frames_per_state = frames_per_state
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self.mode != "off" and Path(self.spritesheet).is_file()
|
||||
|
||||
def frame_count(self, state: PetState | str) -> int:
|
||||
return len(self._frames(state))
|
||||
|
||||
def _frames(self, state: PetState | str):
|
||||
value = state.value if isinstance(state, PetState) else str(state)
|
||||
scale_w = max(1, int(self.frame_w * self.scale))
|
||||
scale_h = max(1, int(self.frame_h * self.scale))
|
||||
return _frames_for(
|
||||
self.spritesheet,
|
||||
value,
|
||||
self.frame_w,
|
||||
self.frame_h,
|
||||
self.frames_per_state,
|
||||
scale_w,
|
||||
scale_h,
|
||||
)
|
||||
|
||||
def cells(self, state: PetState | str, index: int, *, cols: int | None = None) -> list[list[Cell]]:
|
||||
"""Return one frame as a half-block cell grid (framework-neutral).
|
||||
|
||||
Used by the TUI, which renders the grid with native Ink color props
|
||||
instead of raw ANSI. Returns ``[]`` when no frame is available.
|
||||
"""
|
||||
frames = self._frames(state)
|
||||
if not frames:
|
||||
return []
|
||||
frame = frames[index % len(frames)]
|
||||
return _downscale_cells(frame, target_cols=cols or self.unicode_cols)
|
||||
|
||||
def _cell_box(self, frame) -> tuple[int, int]:
|
||||
"""Terminal cell box for a scaled frame (~8×16 px per cell).
|
||||
|
||||
Must match :meth:`frame` graphics sizing — kitty stretches the image to
|
||||
fill ``c``×``r`` cells, so these must reflect the scaled pixel
|
||||
dimensions, not a native-aspect column count (that upscales small pets).
|
||||
"""
|
||||
return max(1, frame.width // 8), max(1, frame.height // 16)
|
||||
|
||||
def kitty_payload(self, state: PetState | str, *, image_id: int) -> dict | None:
|
||||
"""Build the kitty Unicode-placeholder payload for one state.
|
||||
|
||||
Returns ``{cols, rows, placeholder, frames}`` where ``frames`` is a
|
||||
list of transmit escapes (one per animation frame, all reusing
|
||||
``image_id``) and ``placeholder`` is the static text grid Ink paints.
|
||||
Placement geometry is derived from the scaled frame pixels (via
|
||||
:meth:`_cell_box`), not ``unicode_cols`` — kitty upscales to fill
|
||||
``c``×``r`` cells. ``None`` when no frame is available.
|
||||
"""
|
||||
frames = self._frames(state)
|
||||
if not frames:
|
||||
return None
|
||||
cols, rows = self._cell_box(frames[0])
|
||||
return {
|
||||
"cols": cols,
|
||||
"rows": rows,
|
||||
"placeholder": kitty_placeholder_rows(cols, rows),
|
||||
"frames": [
|
||||
_encode_kitty_virtual(f, image_id=image_id, cols=cols, rows=rows) for f in frames
|
||||
],
|
||||
}
|
||||
|
||||
def frame(self, state: PetState | str, index: int) -> str:
|
||||
"""Return the encoded escape string for one frame, or ``""``.
|
||||
|
||||
``index`` is taken modulo the available frame count so callers can pass
|
||||
a free-running counter.
|
||||
"""
|
||||
if self.mode == "off":
|
||||
return ""
|
||||
frames = self._frames(state)
|
||||
if not frames:
|
||||
return ""
|
||||
frame = frames[index % len(frames)]
|
||||
cell_cols, cell_rows = self._cell_box(frame)
|
||||
|
||||
try:
|
||||
if self.mode == "kitty":
|
||||
return _encode_kitty(frame, cell_cols=cell_cols, cell_rows=cell_rows)
|
||||
if self.mode == "iterm":
|
||||
return _encode_iterm(frame, cell_cols=cell_cols, cell_rows=cell_rows)
|
||||
if self.mode == "sixel":
|
||||
return _encode_sixel(frame)
|
||||
return _encode_unicode(frame, target_cols=self.unicode_cols)
|
||||
except Exception as exc: # noqa: BLE001 - degrade silently
|
||||
logger.debug("pet frame encode failed (mode=%s): %s", self.mode, exc)
|
||||
return ""
|
||||
|
||||
|
||||
def build_renderer(
|
||||
spritesheet: str | Path,
|
||||
*,
|
||||
configured_mode: str | None = None,
|
||||
scale: float = DEFAULT_SCALE,
|
||||
unicode_cols: int = 20,
|
||||
stream=None,
|
||||
) -> PetRenderer:
|
||||
"""Convenience factory: resolve the mode from config+env, then construct."""
|
||||
mode = resolve_mode(configured_mode, stream=stream)
|
||||
return PetRenderer(
|
||||
spritesheet,
|
||||
mode=mode,
|
||||
scale=scale,
|
||||
unicode_cols=unicode_cols,
|
||||
)
|
||||
81
agent/pet/state.py
Normal file
81
agent/pet/state.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Map agent activity → a :class:`PetState`.
|
||||
|
||||
This is the one place the "what is the agent doing right now?" → "which
|
||||
animation row?" decision lives. Each surface feeds it the signals it already
|
||||
tracks:
|
||||
|
||||
- CLI — ``KawaiiSpinner`` waiting/thinking state + tool outcomes.
|
||||
- TUI — gateway ``tool.start/complete`` + ``message.delta/complete`` events.
|
||||
- Desktop — the ``$busy``/``$awaitingResponse``/tool-event nanostores
|
||||
(re-implemented in TS, but mirroring this priority order).
|
||||
|
||||
Keeping the priority order here (and documenting it) lets the TypeScript
|
||||
mirror stay faithful without a second design.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from agent.pet.constants import PetState
|
||||
|
||||
|
||||
def todos_all_done(todos: Iterable[Any] | None) -> bool:
|
||||
"""True iff there's ≥1 todo and every one is completed/cancelled.
|
||||
|
||||
The "celebrate" beat (``JUMP``) fires when a plan finishes; this mirrors
|
||||
the TUI's ``isTodoDone`` so the trigger is defined once across surfaces.
|
||||
Accepts dicts (``{"status": ...}``) or objects with a ``status`` attr.
|
||||
"""
|
||||
items = list(todos or [])
|
||||
if not items:
|
||||
return False
|
||||
|
||||
def _status(t: Any) -> Any:
|
||||
return t.get("status") if isinstance(t, dict) else getattr(t, "status", None)
|
||||
|
||||
return all(_status(t) in ("completed", "cancelled") for t in items)
|
||||
|
||||
|
||||
def derive_pet_state(
|
||||
*,
|
||||
busy: bool = False,
|
||||
awaiting_input: bool = False,
|
||||
error: bool = False,
|
||||
celebrate: bool = False,
|
||||
just_completed: bool = False,
|
||||
tool_running: bool = False,
|
||||
reasoning: bool = False,
|
||||
) -> PetState:
|
||||
"""Resolve the animation state from coarse activity signals.
|
||||
|
||||
Priority (highest first) — only one row can show at a time, so the most
|
||||
salient signal wins:
|
||||
|
||||
1. ``error`` → ``FAILED`` (a tool/turn just failed)
|
||||
2. ``celebrate`` → ``JUMP`` (explicit success beat, e.g. todos done)
|
||||
3. ``just_completed`` → ``WAVE`` (turn finished cleanly / greeting)
|
||||
4. ``awaiting_input`` → ``WAITING`` (blocked on the user — a clarify/approval
|
||||
prompt is open; this outranks the in-flight signals below because the turn
|
||||
is paused on *you*, even though a tool is technically mid-call)
|
||||
5. ``tool_running`` → ``RUN`` (a tool is executing)
|
||||
6. ``reasoning`` → ``REVIEW`` (model is thinking / reading)
|
||||
7. ``busy`` → ``RUN`` (turn in flight, unspecified work)
|
||||
8. otherwise → ``IDLE``
|
||||
"""
|
||||
if error:
|
||||
return PetState.FAILED
|
||||
if celebrate:
|
||||
return PetState.JUMP
|
||||
if just_completed:
|
||||
return PetState.WAVE
|
||||
if awaiting_input:
|
||||
return PetState.WAITING
|
||||
if tool_running:
|
||||
return PetState.RUN
|
||||
if reasoning:
|
||||
return PetState.REVIEW
|
||||
if busy:
|
||||
return PetState.RUN
|
||||
return PetState.IDLE
|
||||
503
agent/pet/store.py
Normal file
503
agent/pet/store.py
Normal file
@@ -0,0 +1,503 @@
|
||||
"""On-disk pet store — install / list / resolve pets.
|
||||
|
||||
Pets live under ``get_hermes_home()/pets/<slug>/`` so every profile gets its
|
||||
own set (we deliberately do **not** reuse petdex's ``~/.codex/pets`` default —
|
||||
that's owned by the petdex npm CLI and isn't profile-aware). Each installed
|
||||
pet directory holds:
|
||||
|
||||
pets/<slug>/
|
||||
pet.json # {id, displayName, description, spritesheetPath}
|
||||
spritesheet.webp # (or .png)
|
||||
|
||||
The active pet is resolved from the caller-supplied ``display.pet.slug`` config
|
||||
value (falling back to the first installed pet), so this module stays free of
|
||||
the config loader.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DOWNLOAD_TIMEOUT = 60.0
|
||||
|
||||
|
||||
class PetStoreError(RuntimeError):
|
||||
"""Raised on install/IO failures."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstalledPet:
|
||||
"""A pet present on disk."""
|
||||
|
||||
slug: str
|
||||
display_name: str
|
||||
description: str
|
||||
directory: Path
|
||||
spritesheet: Path
|
||||
created_by: str = "" # "generator" for pets hatched locally; "" for petdex installs
|
||||
|
||||
@property
|
||||
def exists(self) -> bool:
|
||||
return self.spritesheet.is_file()
|
||||
|
||||
@property
|
||||
def generated(self) -> bool:
|
||||
return self.created_by == "generator"
|
||||
|
||||
|
||||
def pets_dir() -> Path:
|
||||
"""Return the profile-scoped pets directory (created on demand)."""
|
||||
path = get_hermes_home() / "pets"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _read_pet_json(directory: Path) -> dict:
|
||||
pet_json = directory / "pet.json"
|
||||
if not pet_json.is_file():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(pet_json.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.debug("unreadable pet.json in %s: %s", directory, exc)
|
||||
return {}
|
||||
|
||||
|
||||
def _resolve_spritesheet(directory: Path, meta: dict) -> Path:
|
||||
"""Find the spritesheet for a pet dir.
|
||||
|
||||
Honors ``spritesheetPath`` from pet.json, else probes the conventional
|
||||
filenames (``spritesheet.{webp,png}`` and petdex R2's ``sprite.webp``).
|
||||
"""
|
||||
declared = str(meta.get("spritesheetPath", "") or "").strip()
|
||||
if declared:
|
||||
candidate = directory / declared
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
for name in ("spritesheet.webp", "spritesheet.png", "sprite.webp", "sprite.png"):
|
||||
candidate = directory / name
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
# Default expectation even if missing, so callers get a stable path.
|
||||
return directory / "spritesheet.webp"
|
||||
|
||||
|
||||
def _safe_slug(slug: str) -> str:
|
||||
"""Normalize a slug to a single bare path segment.
|
||||
|
||||
Pet slugs index into ``pets_dir()/<slug>/`` for load/remove, so a value
|
||||
carrying path separators (``../``, absolute paths) could escape the pets
|
||||
directory. Strip every separator and reject ``.``/``..`` so callers can
|
||||
only ever name a direct child of the pets directory.
|
||||
"""
|
||||
segment = Path(str(slug).strip()).name
|
||||
if segment in ("", ".", ".."):
|
||||
return ""
|
||||
return segment
|
||||
|
||||
|
||||
def load_pet(slug: str) -> InstalledPet | None:
|
||||
"""Return the :class:`InstalledPet` for *slug*, or ``None`` if absent."""
|
||||
slug = _safe_slug(slug)
|
||||
if not slug:
|
||||
return None
|
||||
directory = pets_dir() / slug
|
||||
if not directory.is_dir():
|
||||
return None
|
||||
meta = _read_pet_json(directory)
|
||||
return InstalledPet(
|
||||
slug=slug,
|
||||
display_name=str(meta.get("displayName", "") or slug),
|
||||
description=str(meta.get("description", "") or ""),
|
||||
directory=directory,
|
||||
spritesheet=_resolve_spritesheet(directory, meta),
|
||||
created_by=str(meta.get("createdBy", "") or ""),
|
||||
)
|
||||
|
||||
|
||||
def installed_pets() -> list[InstalledPet]:
|
||||
"""Return every installed pet (dirs containing a usable spritesheet)."""
|
||||
out: list[InstalledPet] = []
|
||||
for child in sorted(pets_dir().iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
pet = load_pet(child.name)
|
||||
if pet and pet.exists:
|
||||
out.append(pet)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_active_pet(configured_slug: str | None = None) -> InstalledPet | None:
|
||||
"""Resolve which pet to display.
|
||||
|
||||
Precedence: the configured slug (``display.pet.slug``) if it's installed,
|
||||
otherwise the first installed pet alphabetically, otherwise ``None``.
|
||||
"""
|
||||
if configured_slug:
|
||||
pet = load_pet(configured_slug.strip())
|
||||
if pet and pet.exists:
|
||||
return pet
|
||||
pets = installed_pets()
|
||||
return pets[0] if pets else None
|
||||
|
||||
|
||||
def install_pet(slug: str, *, force: bool = False, timeout: float = _DOWNLOAD_TIMEOUT) -> InstalledPet:
|
||||
"""Download *slug* from the manifest into the pets directory.
|
||||
|
||||
Idempotent: a fully-installed pet is returned as-is unless *force*. Raises
|
||||
:class:`PetStoreError` / :class:`~agent.pet.manifest.ManifestError` on
|
||||
failure.
|
||||
"""
|
||||
from agent.pet.manifest import find_entry
|
||||
|
||||
slug = _safe_slug(slug)
|
||||
if not slug:
|
||||
raise PetStoreError("invalid pet slug")
|
||||
existing = load_pet(slug)
|
||||
if existing and existing.exists and not force:
|
||||
return existing
|
||||
|
||||
entry = find_entry(slug, timeout=timeout)
|
||||
if entry is None:
|
||||
raise PetStoreError(f"pet '{slug}' is not in the petdex manifest")
|
||||
|
||||
# Host-pin every asset URL to petdex. The manifest is trusted (HTTPS from
|
||||
# petdex.dev), but pin the asset hosts too so a compromised/spoofed manifest
|
||||
# can't redirect the download at an arbitrary host. Matches thumbnail_png.
|
||||
if not _is_petdex_host(entry.spritesheet_url):
|
||||
raise PetStoreError(f"refusing non-petdex spritesheet host for '{slug}'")
|
||||
|
||||
directory = pets_dir() / slug
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
sprite_ext = ".png" if entry.spritesheet_url.lower().split("?")[0].endswith(".png") else ".webp"
|
||||
sprite_path = directory / f"spritesheet{sprite_ext}"
|
||||
|
||||
_download(entry.spritesheet_url, sprite_path, timeout=timeout)
|
||||
|
||||
# Fetch the upstream pet.json if present; otherwise synthesize a minimal
|
||||
# one so the local layout is self-describing.
|
||||
meta: dict = {}
|
||||
if entry.pet_json_url and _is_petdex_host(entry.pet_json_url):
|
||||
try:
|
||||
meta = _download_json(entry.pet_json_url, timeout=timeout)
|
||||
except Exception as exc: # noqa: BLE001 - non-fatal, fall back below
|
||||
logger.debug("pet.json fetch failed for %s: %s", slug, exc)
|
||||
if not isinstance(meta, dict) or not meta:
|
||||
meta = {"id": slug, "displayName": entry.display_name, "description": ""}
|
||||
meta["spritesheetPath"] = sprite_path.name
|
||||
meta.setdefault("id", slug)
|
||||
meta.setdefault("displayName", entry.display_name)
|
||||
(directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
|
||||
pet = load_pet(slug)
|
||||
if pet is None or not pet.exists:
|
||||
raise PetStoreError(f"install of '{slug}' did not produce a spritesheet")
|
||||
return pet
|
||||
|
||||
|
||||
def slugify(name: str) -> str:
|
||||
"""Lowercase, hyphenate, and strip a display name into a filesystem slug."""
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", (name or "").strip().lower()).strip("-")
|
||||
return slug or "pet"
|
||||
|
||||
|
||||
def unique_slug(name: str) -> str:
|
||||
"""A :func:`slugify` result that doesn't collide with an existing pet dir."""
|
||||
base = slugify(name)
|
||||
slug = base
|
||||
counter = 2
|
||||
while (pets_dir() / slug).exists():
|
||||
slug = f"{base}-{counter}"
|
||||
counter += 1
|
||||
return slug
|
||||
|
||||
|
||||
def _write_spritesheet(source, dest: Path) -> None:
|
||||
"""Write *source* (PIL image, bytes, or path) as a lossless WebP at *dest*."""
|
||||
if isinstance(source, (bytes, bytearray)):
|
||||
dest.write_bytes(bytes(source))
|
||||
return
|
||||
|
||||
from PIL import Image
|
||||
|
||||
if isinstance(source, (str, Path)):
|
||||
with Image.open(source) as opened:
|
||||
image = opened.convert("RGBA")
|
||||
else:
|
||||
image = source.convert("RGBA")
|
||||
image.save(dest, format="WEBP", lossless=True, quality=100, method=6, exact=True)
|
||||
|
||||
|
||||
def register_local_pet(
|
||||
spritesheet,
|
||||
*,
|
||||
slug: str,
|
||||
display_name: str = "",
|
||||
description: str = "",
|
||||
) -> InstalledPet:
|
||||
"""Write a locally-generated pet into the store and return it.
|
||||
|
||||
*spritesheet* may be a PIL image, raw WebP/PNG bytes, or a path. The pet
|
||||
appears in :func:`installed_pets` immediately, and because :func:`install_pet`
|
||||
returns an already-on-disk pet before consulting the manifest, it can be
|
||||
adopted (``pet.select`` / ``/pet <slug>``) without a manifest entry.
|
||||
"""
|
||||
slug = slugify(slug)
|
||||
directory = pets_dir() / slug
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
sprite_path = directory / "spritesheet.webp"
|
||||
try:
|
||||
_write_spritesheet(spritesheet, sprite_path)
|
||||
except Exception as exc: # noqa: BLE001 - normalize to one error type
|
||||
raise PetStoreError(f"could not write spritesheet for '{slug}': {exc}") from exc
|
||||
|
||||
meta = {
|
||||
"id": slug,
|
||||
"displayName": display_name or slug,
|
||||
"description": description or "",
|
||||
"spritesheetPath": sprite_path.name,
|
||||
"createdBy": "generator",
|
||||
}
|
||||
(directory / "pet.json").write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
|
||||
pet = load_pet(slug)
|
||||
if pet is None or not pet.exists:
|
||||
raise PetStoreError(f"register of generated pet '{slug}' did not produce a spritesheet")
|
||||
return pet
|
||||
|
||||
|
||||
def export_pet(slug: str) -> tuple[str, bytes]:
|
||||
"""Zip an installed pet's folder (pet.json + spritesheet) → (filename, bytes).
|
||||
|
||||
Dotfiles (cached thumbs, backups) are skipped so the archive is a clean,
|
||||
re-importable pet package. Raises :class:`PetStoreError` if not installed.
|
||||
"""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
root = pets_dir()
|
||||
directory = root / slug.strip()
|
||||
# Guard against traversal: the target must be a direct child of pets_dir.
|
||||
if directory.resolve().parent != root.resolve() or not directory.is_dir():
|
||||
raise PetStoreError(f"pet '{slug}' is not installed")
|
||||
|
||||
name = directory.name
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
for path in sorted(directory.iterdir()):
|
||||
if path.is_file() and not path.name.startswith("."):
|
||||
archive.write(path, f"{name}/{path.name}")
|
||||
return f"{name}.zip", buf.getvalue()
|
||||
|
||||
|
||||
_THUMB_FRAME_W = 192
|
||||
_THUMB_FRAME_H = 208
|
||||
_THUMB_W = 96 # rendered ~40px; 2x+ keeps it crisp on HiDPI
|
||||
|
||||
|
||||
def _thumbs_dir() -> Path:
|
||||
path = pets_dir() / ".thumbs"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _is_petdex_host(url: str) -> bool:
|
||||
"""True only for petdex.dev hosts — bounds server-side fetch (anti-SSRF)."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
host = (urlparse(url).hostname or "").lower()
|
||||
except ValueError:
|
||||
return False
|
||||
return host == "petdex.dev" or host.endswith(".petdex.dev")
|
||||
|
||||
|
||||
def thumbnail_png(slug: str, *, source_url: str = "", timeout: float = 30.0) -> bytes | None:
|
||||
"""Return a small idle-frame PNG for *slug*, cached on disk.
|
||||
|
||||
Crops the top-left (idle, frame 0) cell of the spritesheet and downsamples
|
||||
it to a thumbnail. Source preference: an installed spritesheet on disk, else
|
||||
*source_url* — but only when it points at petdex (so the gateway never
|
||||
fetches an arbitrary client-supplied URL). Returns ``None`` when there's no
|
||||
usable source or Pillow/network fails; callers render a placeholder.
|
||||
|
||||
Doing this server-side sidesteps the renderer's CSP / R2 hotlink limits that
|
||||
break a direct ``<img src=cdn>`` and lets the result ride the authenticated
|
||||
gateway as a same-origin data URL.
|
||||
"""
|
||||
slug = slug.strip()
|
||||
if not slug:
|
||||
return None
|
||||
|
||||
cache = _thumbs_dir() / f"{slug}.png"
|
||||
if cache.is_file():
|
||||
try:
|
||||
return cache.read_bytes()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
sheet_bytes: bytes | None = None
|
||||
pet = load_pet(slug)
|
||||
if pet and pet.exists:
|
||||
try:
|
||||
sheet_bytes = pet.spritesheet.read_bytes()
|
||||
except OSError:
|
||||
sheet_bytes = None
|
||||
|
||||
if sheet_bytes is None and source_url and _is_petdex_host(source_url):
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(
|
||||
source_url,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "hermes-agent-petdex"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
sheet_bytes = resp.content
|
||||
except Exception as exc: # noqa: BLE001 - cosmetic, degrade to placeholder
|
||||
logger.debug("thumb fetch failed for %s: %s", slug, exc)
|
||||
|
||||
if not sheet_bytes:
|
||||
return None
|
||||
|
||||
try:
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(io.BytesIO(sheet_bytes)) as im:
|
||||
frame = im.convert("RGBA").crop(
|
||||
(0, 0, min(_THUMB_FRAME_W, im.width), min(_THUMB_FRAME_H, im.height))
|
||||
)
|
||||
height = round(_THUMB_W * _THUMB_FRAME_H / _THUMB_FRAME_W)
|
||||
frame = frame.resize((_THUMB_W, height), Image.NEAREST)
|
||||
buf = io.BytesIO()
|
||||
frame.save(buf, format="PNG")
|
||||
data = buf.getvalue()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("thumb crop failed for %s: %s", slug, exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
cache.write_bytes(data)
|
||||
except OSError:
|
||||
pass
|
||||
return data
|
||||
|
||||
|
||||
def remove_pet(slug: str) -> bool:
|
||||
"""Delete an installed pet directory. Returns True if anything was removed."""
|
||||
import shutil
|
||||
|
||||
slug = _safe_slug(slug)
|
||||
if not slug:
|
||||
return False
|
||||
|
||||
# The cached thumbnail lives in pets/.thumbs/<slug>.png — OUTSIDE the pet
|
||||
# dir, so rmtree won't catch it. Drop it too, or a later pet that reuses this
|
||||
# slug renders this one's stale thumbnail.
|
||||
try:
|
||||
(_thumbs_dir() / f"{slug}.png").unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
directory = pets_dir() / slug
|
||||
if not directory.is_dir():
|
||||
return False
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
return not directory.exists()
|
||||
|
||||
|
||||
def rename_pet(slug: str, display_name: str) -> str | None:
|
||||
"""Rename a pet's ``displayName`` AND realign its slug/dir to match.
|
||||
|
||||
Generated pets are hatched under a provisional, prompt-derived slug; when
|
||||
the user names the pet on the reveal screen we make that name the real
|
||||
identity so lists/subtitles show what they typed, not the prompt. The dir is
|
||||
renamed to ``slugify(name)`` (and the cached thumbnail moved alongside it)
|
||||
whenever that yields a free, different slug — otherwise the slug is left as
|
||||
is. Returns the resulting slug on success, or ``None`` on failure.
|
||||
"""
|
||||
slug = _safe_slug(slug)
|
||||
display_name = (display_name or "").strip()
|
||||
if not slug or not display_name:
|
||||
return None
|
||||
directory = pets_dir() / slug
|
||||
pet_json = directory / "pet.json"
|
||||
if not pet_json.is_file():
|
||||
return None
|
||||
try:
|
||||
meta = json.loads(pet_json.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
meta = {}
|
||||
if not isinstance(meta, dict):
|
||||
meta = {}
|
||||
meta["displayName"] = display_name
|
||||
|
||||
new_slug = slug
|
||||
desired = slugify(display_name)
|
||||
if desired and desired != slug and not (pets_dir() / desired).exists():
|
||||
try:
|
||||
directory.rename(pets_dir() / desired)
|
||||
try:
|
||||
(_thumbs_dir() / f"{slug}.png").rename(_thumbs_dir() / f"{desired}.png")
|
||||
except OSError:
|
||||
pass
|
||||
directory = pets_dir() / desired
|
||||
pet_json = directory / "pet.json"
|
||||
new_slug = desired
|
||||
meta["id"] = new_slug
|
||||
except OSError:
|
||||
new_slug = slug # keep the provisional slug if the move fails
|
||||
|
||||
try:
|
||||
pet_json.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||
except OSError:
|
||||
return None
|
||||
return new_slug
|
||||
|
||||
|
||||
def _download(url: str, dest: Path, *, timeout: float) -> None:
|
||||
import httpx
|
||||
|
||||
try:
|
||||
with httpx.stream(
|
||||
"GET",
|
||||
url,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "hermes-agent-petdex"},
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
tmp = dest.with_suffix(dest.suffix + ".part")
|
||||
with tmp.open("wb") as fh:
|
||||
for chunk in resp.iter_bytes():
|
||||
fh.write(chunk)
|
||||
tmp.replace(dest)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise PetStoreError(f"download failed for {url}: {exc}") from exc
|
||||
|
||||
|
||||
def _download_json(url: str, *, timeout: float) -> dict:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get(
|
||||
url,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
headers={"User-Agent": "hermes-agent-petdex"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data if isinstance(data, dict) else {}
|
||||
@@ -709,7 +709,24 @@ PLATFORM_HINTS = {
|
||||
"(those are only intercepted on messaging platforms like Telegram, "
|
||||
"Discord, Slack, etc.; on the CLI they render as literal text). "
|
||||
"When referring to a file you created or changed, just state its "
|
||||
"absolute path in plain text; the user can open it from there."
|
||||
"absolute path in plain text; the user can open it from there. "
|
||||
"Cron jobs scheduled from this session are LOCAL-ONLY: their output is "
|
||||
"saved (viewable via cronjob action='list') but is NOT delivered back "
|
||||
"into this terminal — there is no live-delivery channel here. If the "
|
||||
"user wants to be notified when a job runs, the job's `deliver` must "
|
||||
"target a gateway-connected messaging platform (e.g. deliver='telegram' "
|
||||
"or 'all'). Do not promise the user that a deliver='origin' or "
|
||||
"default-deliver cron job will message them in this session."
|
||||
),
|
||||
"tui": (
|
||||
"You are running in the Hermes terminal UI (TUI). "
|
||||
"Cron jobs scheduled from this session are LOCAL-ONLY: their output is "
|
||||
"saved (viewable via cronjob action='list') but is NOT delivered back "
|
||||
"into this TUI session — there is no live-delivery channel here. If the "
|
||||
"user wants to be notified when a job runs, the job's `deliver` must "
|
||||
"target a gateway-connected messaging platform (e.g. deliver='telegram' "
|
||||
"or 'all'). Do not promise the user that a deliver='origin' or "
|
||||
"default-deliver cron job will message them in this session."
|
||||
),
|
||||
"sms": (
|
||||
"You are communicating via SMS. Keep responses concise and use plain text "
|
||||
|
||||
@@ -8,6 +8,7 @@ rate-limited provider concurrently.
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
# Monotonic counter for jitter seed uniqueness within the same process.
|
||||
# Protected by a lock to avoid race conditions in concurrent retry paths
|
||||
@@ -15,6 +16,14 @@ import time
|
||||
_jitter_counter = 0
|
||||
_jitter_lock = threading.Lock()
|
||||
|
||||
# Z.AI Coding Plan's GLM-5.2 endpoint often returns HTTP 429 code 1305
|
||||
# ("The service may be temporarily overloaded...") for otherwise valid
|
||||
# Hermes requests. Short retries tend to hammer the same overloaded window;
|
||||
# after a few normal retries, progressively widen the wait window. Keep the
|
||||
# cap interactive-friendly: a simple TUI message should fail visibly in minutes,
|
||||
# not sit silent for 20+ minutes.
|
||||
_ZAI_CODING_OVERLOAD_LONG_BACKOFF = (30.0, 60.0, 90.0, 120.0)
|
||||
|
||||
|
||||
def jittered_backoff(
|
||||
attempt: int,
|
||||
@@ -55,3 +64,66 @@ def jittered_backoff(
|
||||
jitter = rng.uniform(0, jitter_ratio * delay)
|
||||
|
||||
return delay + jitter
|
||||
|
||||
|
||||
def _error_text(error: Any) -> str:
|
||||
"""Best-effort flattened provider error text for retry classification."""
|
||||
parts = [
|
||||
error,
|
||||
getattr(error, "message", None),
|
||||
getattr(error, "body", None),
|
||||
getattr(error, "response", None),
|
||||
]
|
||||
return " ".join(str(part) for part in parts if part is not None).lower()
|
||||
|
||||
|
||||
def is_zai_coding_overload_error(*, base_url: str | None, model: str | None, error: Any) -> bool:
|
||||
"""Return True for Z.AI Coding Plan transient overload 429s.
|
||||
|
||||
The coding-plan endpoint reports overload as HTTP 429 with body code 1305
|
||||
and message "The service may be temporarily overloaded...". Treat only
|
||||
that narrow shape specially so ordinary quota/billing 429s still fail fast
|
||||
through the existing classifier.
|
||||
"""
|
||||
base = (base_url or "").lower()
|
||||
model_name = (model or "").lower()
|
||||
status = getattr(error, "status_code", None)
|
||||
text = _error_text(error)
|
||||
return (
|
||||
status == 429
|
||||
and "api.z.ai/api/coding/paas/v4" in base
|
||||
and "glm-5.2" in model_name
|
||||
and ("1305" in text or "temporarily overloaded" in text)
|
||||
)
|
||||
|
||||
|
||||
def adaptive_rate_limit_backoff(
|
||||
attempt: int,
|
||||
*,
|
||||
base_url: str | None,
|
||||
model: str | None,
|
||||
error: Any,
|
||||
default_wait: float,
|
||||
short_attempts: int = 3,
|
||||
) -> tuple[float, str | None]:
|
||||
"""Provider-aware rate-limit backoff.
|
||||
|
||||
For most providers this returns ``default_wait`` unchanged. For Z.AI
|
||||
Coding Plan GLM-5.2 overloads, keep the first ``short_attempts`` retries on
|
||||
the normal short exponential schedule, then switch to progressively longer
|
||||
waits (30s → 60s → 90s → 120s, capped) plus light jitter.
|
||||
|
||||
``attempt`` is 1-based, matching the retry loop's logged attempt number.
|
||||
Returns ``(wait_seconds, reason_label)`` where ``reason_label`` is suitable
|
||||
for status/log decoration when a provider-specific policy fired.
|
||||
"""
|
||||
if not is_zai_coding_overload_error(base_url=base_url, model=model, error=error):
|
||||
return default_wait, None
|
||||
if attempt <= short_attempts:
|
||||
return default_wait, "zai_coding_overload_short"
|
||||
|
||||
idx = min(attempt - short_attempts - 1, len(_ZAI_CODING_OVERLOAD_LONG_BACKOFF) - 1)
|
||||
base_delay = _ZAI_CODING_OVERLOAD_LONG_BACKOFF[idx]
|
||||
# A smaller jitter ratio keeps long waits readable while still avoiding
|
||||
# synchronized retry storms across concurrent Hermes sessions.
|
||||
return jittered_backoff(1, base_delay=base_delay, max_delay=base_delay, jitter_ratio=0.2), "zai_coding_overload_long"
|
||||
|
||||
@@ -11,7 +11,8 @@ Pure module-level utilities extracted from ``run_agent.py``:
|
||||
``_append_subdir_hint_to_multimodal`` — envelope helpers for the
|
||||
``{"_multimodal": True, "content": [...], "text_summary": ...}`` dict
|
||||
shape returned by tools like ``computer_use``.
|
||||
* ``_extract_file_mutation_targets`` / ``_extract_error_preview`` —
|
||||
* ``_extract_file_mutation_targets`` / ``_extract_landed_file_mutation_paths`` /
|
||||
``_extract_error_preview`` —
|
||||
per-turn file-mutation verifier inputs.
|
||||
* ``_trajectory_normalize_msg`` — strip image blobs from a message for
|
||||
trajectory saving.
|
||||
@@ -269,6 +270,35 @@ def _extract_file_mutation_targets(tool_name: str, args: Dict[str, Any]) -> List
|
||||
return []
|
||||
|
||||
|
||||
def _extract_landed_file_mutation_paths(
|
||||
tool_name: str,
|
||||
args: Dict[str, Any],
|
||||
result: Any,
|
||||
) -> List[str]:
|
||||
"""Return the concrete file paths a successful mutation reports."""
|
||||
targets = _extract_file_mutation_targets(tool_name, args)
|
||||
if tool_name not in _FILE_MUTATING_TOOLS or not isinstance(result, str):
|
||||
return targets
|
||||
try:
|
||||
data = json.loads(result.strip())
|
||||
except Exception:
|
||||
return targets
|
||||
if not isinstance(data, dict):
|
||||
return targets
|
||||
|
||||
files = data.get("files_modified")
|
||||
if isinstance(files, list):
|
||||
landed = [str(p) for p in files if p]
|
||||
if landed:
|
||||
return landed
|
||||
|
||||
resolved = data.get("resolved_path")
|
||||
if resolved:
|
||||
return [str(resolved)]
|
||||
|
||||
return targets
|
||||
|
||||
|
||||
def _extract_error_preview(result: Any, max_len: int = 180) -> str:
|
||||
"""Pull a one-line error summary out of a tool result for footer display."""
|
||||
text = _multimodal_text_summary(result) if result is not None else ""
|
||||
@@ -411,6 +441,7 @@ __all__ = [
|
||||
"_multimodal_text_summary",
|
||||
"_append_subdir_hint_to_multimodal",
|
||||
"_extract_file_mutation_targets",
|
||||
"_extract_landed_file_mutation_paths",
|
||||
"_extract_error_preview",
|
||||
"_trajectory_normalize_msg",
|
||||
"make_tool_result_message",
|
||||
|
||||
@@ -69,12 +69,35 @@ def _budget_for_agent(agent) -> BudgetConfig:
|
||||
_MAX_TOOL_WORKERS = 8
|
||||
|
||||
|
||||
def _flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages: list,
|
||||
*,
|
||||
stage: str,
|
||||
) -> None:
|
||||
"""Best-effort incremental SessionDB flush for tool-call progress.
|
||||
|
||||
Tool execution can perform side effects that terminate or restart the
|
||||
current Hermes process before the normal turn-end persistence path runs.
|
||||
Flush the already-appended assistant/tool messages immediately so the
|
||||
transcript survives destructive-but-valid tool calls.
|
||||
"""
|
||||
try:
|
||||
agent._flush_messages_to_session_db(messages)
|
||||
except Exception as exc:
|
||||
logger.warning("Incremental tool-call persistence failed after %s: %s", stage, exc)
|
||||
|
||||
|
||||
def _ra():
|
||||
"""Lazy reference to ``run_agent`` so patches like ``run_agent._set_interrupt`` work."""
|
||||
import run_agent
|
||||
return run_agent
|
||||
|
||||
|
||||
def _is_interpreter_shutdown_submit_error(exc: RuntimeError) -> bool:
|
||||
return "cannot schedule new futures after interpreter shutdown" in str(exc)
|
||||
|
||||
|
||||
def _emit_terminal_post_tool_call(
|
||||
agent,
|
||||
*,
|
||||
@@ -279,6 +302,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
f"[Tool execution cancelled — {tc.function.name} was skipped due to user interrupt]",
|
||||
tc.id,
|
||||
))
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"cancelled tool result {tc.function.name}",
|
||||
)
|
||||
return
|
||||
|
||||
# ── Parse args + pre-execution bookkeeping ───────────────────────
|
||||
@@ -581,13 +609,40 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
if runnable_calls:
|
||||
max_workers = min(len(runnable_calls), _MAX_TOOL_WORKERS)
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
for i, tc, name, args in runnable_calls:
|
||||
for submit_index, (i, tc, name, args) in enumerate(runnable_calls):
|
||||
# Propagate the agent turn's ContextVars (e.g.
|
||||
# _approval_session_key) AND thread-local approval/sudo
|
||||
# callbacks into the worker thread; clears callbacks on exit.
|
||||
f = executor.submit(
|
||||
propagate_context_to_thread(_run_tool), i, tc, name, args, parsed_calls[i][3]
|
||||
)
|
||||
try:
|
||||
f = executor.submit(
|
||||
propagate_context_to_thread(_run_tool), i, tc, name, args, parsed_calls[i][3]
|
||||
)
|
||||
except RuntimeError as submit_error:
|
||||
if not _is_interpreter_shutdown_submit_error(submit_error):
|
||||
raise
|
||||
skipped_calls = runnable_calls[submit_index:]
|
||||
logger.warning(
|
||||
"interpreter shutdown while scheduling concurrent tools; "
|
||||
"skipping %d unsubmitted tool(s)",
|
||||
len(skipped_calls),
|
||||
)
|
||||
for skipped_i, _tc, skipped_name, skipped_args in skipped_calls:
|
||||
if results[skipped_i] is None:
|
||||
middleware_trace = parsed_calls[skipped_i][3]
|
||||
result = (
|
||||
f"Error executing tool '{skipped_name}': "
|
||||
"Python interpreter is shutting down; tool was not started"
|
||||
)
|
||||
results[skipped_i] = (
|
||||
skipped_name,
|
||||
skipped_args,
|
||||
result,
|
||||
0.0,
|
||||
True,
|
||||
False,
|
||||
middleware_trace,
|
||||
)
|
||||
break
|
||||
futures.append(f)
|
||||
|
||||
# Wait for all to complete with periodic heartbeats so the
|
||||
@@ -768,6 +823,11 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
# String results pass through unchanged.
|
||||
_tool_content = agent._tool_result_content_for_active_model(name, function_result)
|
||||
messages.append(make_tool_result_message(name, _tool_content, tc.id))
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"tool result {name}",
|
||||
)
|
||||
|
||||
# ── Per-tool /steer drain ───────────────────────────────────
|
||||
# Same as the sequential path: drain between each collected
|
||||
@@ -803,13 +863,16 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
agent._vprint(f"{agent.log_prefix}⚡ Interrupt: skipping {len(remaining_calls)} tool call(s)", force=True)
|
||||
for skipped_tc in remaining_calls:
|
||||
skipped_name = skipped_tc.function.name
|
||||
skip_msg = {
|
||||
"role": "tool",
|
||||
"name": skipped_name,
|
||||
"content": f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]",
|
||||
"tool_call_id": skipped_tc.id,
|
||||
}
|
||||
messages.append(skip_msg)
|
||||
messages.append(make_tool_result_message(
|
||||
skipped_name,
|
||||
f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]",
|
||||
skipped_tc.id,
|
||||
))
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"cancelled tool result {skipped_name}",
|
||||
)
|
||||
break
|
||||
|
||||
function_name = tool_call.function.name
|
||||
@@ -1402,6 +1465,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
# (see parallel path for rationale). String results pass through.
|
||||
_tool_content = agent._tool_result_content_for_active_model(function_name, function_result)
|
||||
messages.append(make_tool_result_message(function_name, _tool_content, tool_call.id))
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"tool result {function_name}",
|
||||
)
|
||||
|
||||
# ── Per-tool /steer drain ───────────────────────────────────
|
||||
# Drain pending steer BETWEEN individual tool calls so the
|
||||
@@ -1428,6 +1496,11 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
f"[Tool execution skipped — {skipped_name} was not started. User sent a new message]",
|
||||
skipped_tc.id,
|
||||
))
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
stage=f"skipped tool result {skipped_name}",
|
||||
)
|
||||
break
|
||||
|
||||
if agent.tool_delay > 0 and i < len(assistant_message.tool_calls):
|
||||
|
||||
@@ -5,12 +5,47 @@ This transport owns format conversion and normalization — NOT client lifecycle
|
||||
streaming, or the _run_codex_stream() call path.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.transports.base import ProviderTransport
|
||||
from agent.transports.types import NormalizedResponse, ToolCall
|
||||
|
||||
|
||||
def _content_cache_key(instructions: str, tools: Optional[List[Dict[str, Any]]]) -> Optional[str]:
|
||||
"""Content-address the prompt cache key from the static request prefix.
|
||||
|
||||
Returns ``pck_<sha256[:24]>`` of (instructions + sorted tool schemas), or
|
||||
None when there is nothing static to key on. The cache key is a routing
|
||||
hint only — never a correctness boundary — so two requests sharing a system
|
||||
prompt and tool set intentionally resolve to the same warm prefix bucket.
|
||||
|
||||
The fix this exists for: recurring cron jobs build session_id as
|
||||
``cron_<id>_<timestamp>``, so using session_id as the cache key made every
|
||||
fire cache-cold. The static prefix (identity + tools) is identical across
|
||||
fires, so hashing it gives a stable key that stays warm within the
|
||||
provider's cache TTL. Sorting tools by name keeps the hash insertion-order
|
||||
independent.
|
||||
"""
|
||||
if not instructions and not tools:
|
||||
return None
|
||||
tools_part = ""
|
||||
if tools:
|
||||
sorted_tools = sorted(
|
||||
(t for t in tools if isinstance(t, dict)),
|
||||
key=lambda t: str(t.get("name") or t.get("type") or ""),
|
||||
)
|
||||
tools_part = json.dumps(
|
||||
sorted_tools, sort_keys=True, ensure_ascii=False, separators=(",", ":")
|
||||
)
|
||||
# \x00 separator so instructions ending in the tool JSON can't collide with
|
||||
# a request whose instructions contain that JSON and whose tools are empty.
|
||||
content = f"{instructions or ''}\x00{tools_part}"
|
||||
digest = hashlib.sha256(content.encode("utf-8", errors="replace")).hexdigest()[:24]
|
||||
return f"pck_{digest}"
|
||||
|
||||
|
||||
class ResponsesApiTransport(ProviderTransport):
|
||||
"""Transport for api_mode='codex_responses'.
|
||||
|
||||
@@ -71,7 +106,10 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
params:
|
||||
instructions: str — system prompt (extracted from messages[0] if not given)
|
||||
reasoning_config: dict | None — {effort, enabled}
|
||||
session_id: str | None — used for prompt_cache_key + xAI conv header
|
||||
session_id: str | None — transcript/session id; drives the xAI
|
||||
x-grok-conv-id header and the Codex cache-scope headers, and is
|
||||
the fallback prompt_cache_key when there is no static prefix to
|
||||
content-address
|
||||
max_tokens: int | None — max_output_tokens
|
||||
timeout: float | None — per-request timeout forwarded to the SDK
|
||||
request_overrides: dict | None — extra kwargs merged in
|
||||
@@ -212,10 +250,17 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
kwargs["parallel_tool_calls"] = True
|
||||
|
||||
session_id = params.get("session_id")
|
||||
# prompt_cache_key is content-addressed from the static prefix
|
||||
# (instructions + tools), NOT session_id — recurring cron jobs carry a
|
||||
# per-fire timestamp in session_id (cron_<id>_<ts>) that made every run
|
||||
# cache-cold. session_id is left untouched for transcript isolation and
|
||||
# the cache-scope routing headers below. Falls back to session_id when
|
||||
# there is no static content to hash.
|
||||
cache_key = _content_cache_key(instructions, response_tools) or session_id
|
||||
# xAI Responses takes prompt_cache_key in extra_body (set further
|
||||
# down); GitHub Models opts out of cache-key routing entirely.
|
||||
if not is_github_responses and not is_xai_responses and session_id:
|
||||
kwargs["prompt_cache_key"] = session_id
|
||||
if not is_github_responses and not is_xai_responses and cache_key:
|
||||
kwargs["prompt_cache_key"] = cache_key
|
||||
|
||||
if reasoning_enabled and is_xai_responses:
|
||||
from agent.model_metadata import grok_supports_reasoning_effort
|
||||
@@ -326,7 +371,7 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
merged_extra_body: Dict[str, Any] = {}
|
||||
if isinstance(existing_extra_body, dict):
|
||||
merged_extra_body.update(existing_extra_body)
|
||||
merged_extra_body.setdefault("prompt_cache_key", session_id)
|
||||
merged_extra_body.setdefault("prompt_cache_key", cache_key)
|
||||
kwargs["extra_body"] = merged_extra_body
|
||||
|
||||
return kwargs
|
||||
|
||||
@@ -29,7 +29,10 @@ from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from agent.iteration_budget import IterationBudget
|
||||
from agent.model_metadata import estimate_request_tokens_rough
|
||||
from agent.model_metadata import (
|
||||
estimate_messages_tokens_rough,
|
||||
estimate_request_tokens_rough,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,6 +60,34 @@ def _compression_made_progress(
|
||||
return orig_tokens > 0 and new_tokens < orig_tokens * 0.95
|
||||
|
||||
|
||||
def _should_run_preflight_estimate(
|
||||
messages: List[Dict[str, Any]],
|
||||
protect_first_n: int,
|
||||
protect_last_n: int,
|
||||
threshold_tokens: int,
|
||||
) -> bool:
|
||||
"""Cheap gate for the (expensive) full preflight token estimate.
|
||||
|
||||
Returns ``True`` when either:
|
||||
(a) message count exceeds the protected ranges (the historical gate), or
|
||||
(b) a cheap char-based estimate already crosses the configured threshold
|
||||
— the few-but-huge case from issue #27405 that the count-only gate
|
||||
would silently skip (a handful of very large messages never trips
|
||||
the count condition, so compression was never attempted and the
|
||||
turn hit a hard context-overflow error).
|
||||
|
||||
Branch (b) uses ``estimate_messages_tokens_rough`` (the shared char-based
|
||||
estimator) so a single large base64 image isn't mistaken for ~250K tokens.
|
||||
It intentionally undercounts vs. the full request estimate — it omits the
|
||||
system prompt and tool schemas — because it is only a *hint* deciding
|
||||
whether to pay for the authoritative ``estimate_request_tokens_rough``,
|
||||
which (together with ``should_compress``) makes the real decision.
|
||||
"""
|
||||
if len(messages) > protect_first_n + protect_last_n + 1:
|
||||
return True
|
||||
return estimate_messages_tokens_rough(messages) >= threshold_tokens
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnContext:
|
||||
"""Values produced by the turn prologue and consumed by the turn loop."""
|
||||
@@ -111,7 +142,13 @@ def build_turn_context(
|
||||
# Guard stdio against OSError from broken pipes (systemd/headless/daemon).
|
||||
install_safe_stdio()
|
||||
|
||||
agent._ensure_db_session()
|
||||
# NOTE: the DB session row is created later, AFTER the system prompt is
|
||||
# restored/built (see _ensure_db_session() below the system-prompt block).
|
||||
# Creating it here — before _cached_system_prompt is populated — inserts a
|
||||
# row with system_prompt=NULL on a fresh API/gateway agent that carries
|
||||
# client-managed history, which then trips the "stored system prompt is
|
||||
# null; rebuilding from scratch" warning and a needless first-turn prefix
|
||||
# cache miss. (Issue #45499.)
|
||||
|
||||
# Tell auxiliary_client what the live main provider/model are for this turn.
|
||||
try:
|
||||
@@ -278,6 +315,11 @@ def build_turn_context(
|
||||
|
||||
active_system_prompt = agent._cached_system_prompt
|
||||
|
||||
# Create the DB session row now that _cached_system_prompt is populated, so
|
||||
# the persisted snapshot is written non-NULL on the first turn (Issue
|
||||
# #45499). Idempotent: _ensure_db_session() no-ops once the row exists.
|
||||
agent._ensure_db_session()
|
||||
|
||||
# Crash-resilience: persist the inbound user turn as soon as the session row exists.
|
||||
try:
|
||||
agent._persist_session(messages, conversation_history)
|
||||
@@ -289,10 +331,14 @@ def build_turn_context(
|
||||
)
|
||||
|
||||
# ── Preflight context compression ──
|
||||
if (
|
||||
agent.compression_enabled
|
||||
and len(messages) > agent.context_compressor.protect_first_n
|
||||
+ agent.context_compressor.protect_last_n + 1
|
||||
# Gate the (expensive) full token estimate behind a cheap pre-check.
|
||||
# See ``_should_run_preflight_estimate`` for the OR semantics that fix
|
||||
# issue #27405 (a few very large messages slipping past the count gate).
|
||||
if agent.compression_enabled and _should_run_preflight_estimate(
|
||||
messages,
|
||||
agent.context_compressor.protect_first_n,
|
||||
agent.context_compressor.protect_last_n,
|
||||
agent.context_compressor.threshold_tokens,
|
||||
):
|
||||
_preflight_tokens = estimate_request_tokens_rough(
|
||||
messages,
|
||||
@@ -392,6 +438,8 @@ def build_turn_context(
|
||||
|
||||
# Per-turn file-mutation verifier state.
|
||||
agent._turn_failed_file_mutations = {}
|
||||
agent._turn_file_mutation_paths = set()
|
||||
agent._verification_stop_nudges = 0
|
||||
|
||||
# Record the execution thread so interrupt()/clear_interrupt() can scope
|
||||
# the tool-level interrupt signal to THIS agent's thread only.
|
||||
|
||||
@@ -166,6 +166,25 @@ def finalize_turn(
|
||||
# same empty-response loop again.
|
||||
try:
|
||||
agent._drop_trailing_empty_response_scaffolding(messages)
|
||||
|
||||
# When the turn was interrupted and the last message is a tool
|
||||
# result, append a synthetic assistant message to close the
|
||||
# tool-call sequence. Without this, the session persists a
|
||||
# ``tool → user`` alternation that strict providers (Gemini,
|
||||
# Claude) reject, causing them to hallucinate a continuation of
|
||||
# the user's message on the next turn (#48879).
|
||||
#
|
||||
# ``_drop_trailing_empty_response_scaffolding`` only rewinds the
|
||||
# tool tail when an empty-response scaffolding flag is present; a
|
||||
# clean ``/stop`` interrupt after a successful tool sets no such
|
||||
# flag, so the tool result survives as the tail and we close it
|
||||
# here instead. On an interrupt ``final_response`` is typically
|
||||
# empty, so fall back to an explicit placeholder rather than
|
||||
# persisting an empty-content assistant turn.
|
||||
if interrupted:
|
||||
from agent.message_sanitization import close_interrupted_tool_sequence
|
||||
close_interrupted_tool_sequence(messages, final_response)
|
||||
|
||||
agent._persist_session(messages, conversation_history)
|
||||
except Exception as _persist_err:
|
||||
_cleanup_errors.append(f"persist_session: {_persist_err}")
|
||||
|
||||
618
agent/verification_evidence.py
Normal file
618
agent/verification_evidence.py
Normal file
@@ -0,0 +1,618 @@
|
||||
"""Coding verification evidence ledger.
|
||||
|
||||
This module records what the agent actually proved while working in a code
|
||||
workspace. It is deliberately passive: it never decides to run a suite, never
|
||||
blocks completion, and never upgrades targeted checks into "repo green".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
|
||||
_DB_LOCK = threading.Lock()
|
||||
_MAX_OUTPUT_SUMMARY_CHARS = 2000
|
||||
_MAX_EVIDENCE_AGE_DAYS = 30
|
||||
_MAX_EVENTS_PER_SESSION_ROOT = 100
|
||||
_MAX_TOTAL_UNREFERENCED_EVENTS = 10_000
|
||||
_AD_HOC_SCRIPT_NAME_PREFIXES = ("hermes-verify-", "hermes-ad-hoc-")
|
||||
_VERIFY_SCHEMA_VERSION = 1
|
||||
_SHELL_SPLIT_RE = re.compile(r"\s*(?:&&|\|\||;)\s*")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerificationEvidence:
|
||||
"""A classified command result worth recording."""
|
||||
|
||||
command: str
|
||||
canonical_command: str
|
||||
kind: str
|
||||
scope: str
|
||||
status: str
|
||||
exit_code: int
|
||||
cwd: str
|
||||
root: str
|
||||
session_id: str
|
||||
output_summary: str = ""
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _retention_cutoff() -> str:
|
||||
return (datetime.now(timezone.utc) - timedelta(days=_MAX_EVIDENCE_AGE_DAYS)).isoformat()
|
||||
|
||||
|
||||
def _db_path() -> Path:
|
||||
return get_hermes_home() / "verification_evidence.db"
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
path = _db_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.row_factory = sqlite3.Row
|
||||
_ensure_schema(conn)
|
||||
return conn
|
||||
|
||||
|
||||
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS verification_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
cwd TEXT NOT NULL,
|
||||
root TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
canonical_command TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
exit_code INTEGER NOT NULL,
|
||||
output_summary TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS verification_state (
|
||||
session_id TEXT NOT NULL,
|
||||
root TEXT NOT NULL,
|
||||
last_event_id INTEGER,
|
||||
last_edit_at TEXT,
|
||||
changed_paths_json TEXT NOT NULL DEFAULT '[]',
|
||||
PRIMARY KEY (session_id, root)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_verification_events_session_root
|
||||
ON verification_events(session_id, root, id DESC)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO meta(key, value) VALUES ('schema_version', ?)",
|
||||
(str(_VERIFY_SCHEMA_VERSION),),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _split_segment_tokens(command: str) -> list[list[str]]:
|
||||
segments: list[list[str]] = []
|
||||
for segment in _SHELL_SPLIT_RE.split(command.strip()):
|
||||
if not segment:
|
||||
continue
|
||||
try:
|
||||
tokens = shlex.split(segment)
|
||||
except ValueError:
|
||||
continue
|
||||
if tokens:
|
||||
segments.append(tokens)
|
||||
return segments
|
||||
|
||||
|
||||
def _clean_token(token: str) -> str:
|
||||
token = token.strip()
|
||||
while token.startswith("./"):
|
||||
token = token[2:]
|
||||
return token
|
||||
|
||||
|
||||
def _canonical_tokens(canonical: str) -> list[str]:
|
||||
try:
|
||||
return [_clean_token(t) for t in shlex.split(canonical) if t]
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
|
||||
def _find_subsequence(tokens: list[str], needle: list[str]) -> Optional[int]:
|
||||
if not tokens or not needle or len(needle) > len(tokens):
|
||||
return None
|
||||
cleaned = [_clean_token(t) for t in tokens]
|
||||
for idx in range(0, len(cleaned) - len(needle) + 1):
|
||||
if cleaned[idx:idx + len(needle)] == needle:
|
||||
return idx
|
||||
return None
|
||||
|
||||
|
||||
def _strip_command_prefix(tokens: list[str]) -> list[str]:
|
||||
"""Remove harmless command prefixes before matching canonical commands."""
|
||||
remaining = list(tokens)
|
||||
if remaining and remaining[0] == "env":
|
||||
remaining = remaining[1:]
|
||||
while remaining and "=" in remaining[0] and not remaining[0].startswith("-"):
|
||||
remaining = remaining[1:]
|
||||
while remaining and remaining[0] in {"command", "time", "noglob"}:
|
||||
remaining = remaining[1:]
|
||||
return remaining
|
||||
|
||||
|
||||
def _equivalent_needles(needle: list[str]) -> list[list[str]]:
|
||||
"""Return command spellings equivalent to the detected canonical command."""
|
||||
candidates = [needle]
|
||||
if len(needle) >= 3 and needle[1] == "run":
|
||||
package_manager = needle[0]
|
||||
script_name = needle[2]
|
||||
if package_manager in {"npm", "pnpm", "yarn", "bun"}:
|
||||
candidates.append([package_manager, script_name])
|
||||
if len(needle) == 1 and "/" in needle[0]:
|
||||
candidates.extend([["bash", needle[0]], ["sh", needle[0]]])
|
||||
if needle == ["pytest"]:
|
||||
candidates.extend(
|
||||
[
|
||||
["python", "-m", "pytest"],
|
||||
["python3", "-m", "pytest"],
|
||||
["uv", "run", "pytest"],
|
||||
["poetry", "run", "pytest"],
|
||||
["pipenv", "run", "pytest"],
|
||||
]
|
||||
)
|
||||
return candidates
|
||||
|
||||
|
||||
def _find_canonical_match(command: str, canonical_commands: list[str]) -> Optional[tuple[str, list[str]]]:
|
||||
"""Return ``(canonical, trailing_args)`` for the first detected command."""
|
||||
|
||||
segments = _split_segment_tokens(command)
|
||||
for canonical in canonical_commands:
|
||||
needle = _canonical_tokens(canonical)
|
||||
if not needle:
|
||||
continue
|
||||
for tokens in segments:
|
||||
candidate_tokens = _strip_command_prefix(tokens)
|
||||
for candidate in _equivalent_needles(needle):
|
||||
if candidate_tokens[:len(candidate)] == candidate:
|
||||
return canonical, candidate_tokens[len(candidate):]
|
||||
return None
|
||||
|
||||
|
||||
def _kind_for_command(canonical: str) -> str:
|
||||
lowered = canonical.lower()
|
||||
if any(word in lowered for word in ("lint", "eslint", "ruff")):
|
||||
return "lint"
|
||||
if any(word in lowered for word in ("typecheck", "tsc", "mypy", "pyright", "ty")):
|
||||
return "typecheck"
|
||||
if "build" in lowered:
|
||||
return "build"
|
||||
if "fmt" in lowered or "format" in lowered:
|
||||
return "format"
|
||||
if "check" in lowered and "test" not in lowered:
|
||||
return "check"
|
||||
return "test"
|
||||
|
||||
|
||||
def _looks_like_target(arg: str) -> bool:
|
||||
if not arg or arg.startswith("-") or "=" in arg:
|
||||
return False
|
||||
return (
|
||||
"/" in arg
|
||||
or "\\" in arg
|
||||
or "::" in arg
|
||||
or arg.endswith((".py", ".js", ".jsx", ".ts", ".tsx", ".rs", ".go", ".java"))
|
||||
or arg.startswith(("test_", "tests", "spec", "__tests__"))
|
||||
)
|
||||
|
||||
|
||||
def _scope_for_args(args: list[str]) -> str:
|
||||
return "targeted" if any(_looks_like_target(arg) for arg in args) else "full"
|
||||
|
||||
|
||||
def _is_under_temp_dir(token: str) -> bool:
|
||||
if not token or token.startswith("-"):
|
||||
return False
|
||||
try:
|
||||
path = Path(token).expanduser()
|
||||
if not path.is_absolute():
|
||||
return False
|
||||
resolved = path.resolve()
|
||||
temp_root = Path(tempfile.gettempdir()).resolve()
|
||||
return resolved == temp_root or temp_root in resolved.parents
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_under_root(token: str, root: str | Path | None) -> bool:
|
||||
if not root:
|
||||
return False
|
||||
try:
|
||||
path = Path(token).expanduser().resolve()
|
||||
root_path = Path(root).expanduser().resolve()
|
||||
return path == root_path or root_path in path.parents
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_temp_script_path(token: str, root: str | Path | None) -> bool:
|
||||
try:
|
||||
name = Path(token).expanduser().name
|
||||
except Exception:
|
||||
return False
|
||||
return (
|
||||
name.startswith(_AD_HOC_SCRIPT_NAME_PREFIXES)
|
||||
and _is_under_temp_dir(token)
|
||||
and not _is_under_root(token, root)
|
||||
)
|
||||
|
||||
|
||||
def _ad_hoc_script_args(tokens: list[str], root: str | Path | None) -> Optional[list[str]]:
|
||||
candidate_tokens = _strip_command_prefix(tokens)
|
||||
if not candidate_tokens:
|
||||
return None
|
||||
command = candidate_tokens[0]
|
||||
if _is_temp_script_path(command, root):
|
||||
return candidate_tokens[1:]
|
||||
if command in {"python", "python3", "node", "bash", "sh", "ruby", "perl"}:
|
||||
for idx, token in enumerate(candidate_tokens[1:], start=1):
|
||||
if token == "--":
|
||||
continue
|
||||
if _is_temp_script_path(token, root):
|
||||
return candidate_tokens[idx + 1:]
|
||||
if not token.startswith("-"):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _find_ad_hoc_match(command: str, root: str | Path | None) -> Optional[list[str]]:
|
||||
for tokens in _split_segment_tokens(command):
|
||||
trailing_args = _ad_hoc_script_args(tokens, root)
|
||||
if trailing_args is not None:
|
||||
return trailing_args
|
||||
return None
|
||||
|
||||
|
||||
def _summarize_output(output: str) -> str:
|
||||
text = (output or "").strip()
|
||||
if len(text) <= _MAX_OUTPUT_SUMMARY_CHARS:
|
||||
return text
|
||||
head = _MAX_OUTPUT_SUMMARY_CHARS // 3
|
||||
tail = _MAX_OUTPUT_SUMMARY_CHARS - head
|
||||
return (
|
||||
text[:head]
|
||||
+ f"\n... [{len(text) - _MAX_OUTPUT_SUMMARY_CHARS} chars omitted] ...\n"
|
||||
+ text[-tail:]
|
||||
)
|
||||
|
||||
|
||||
def _prune_old_events(conn: sqlite3.Connection, *, session_id: str, root: str) -> None:
|
||||
"""Bound ledger growth without deleting the current state pointer."""
|
||||
cutoff = _retention_cutoff()
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM verification_events
|
||||
WHERE session_id = ?
|
||||
AND root = ?
|
||||
AND id NOT IN (
|
||||
SELECT id FROM verification_events
|
||||
WHERE session_id = ? AND root = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
)
|
||||
""",
|
||||
(session_id, root, session_id, root, _MAX_EVENTS_PER_SESSION_ROOT),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM verification_state
|
||||
WHERE (
|
||||
last_edit_at IS NOT NULL
|
||||
AND last_edit_at < ?
|
||||
)
|
||||
OR (
|
||||
last_edit_at IS NULL
|
||||
AND last_event_id IN (
|
||||
SELECT id FROM verification_events
|
||||
WHERE created_at < ?
|
||||
)
|
||||
)
|
||||
""",
|
||||
(cutoff, cutoff),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM verification_events
|
||||
WHERE created_at < ?
|
||||
AND id NOT IN (
|
||||
SELECT last_event_id FROM verification_state
|
||||
WHERE last_event_id IS NOT NULL
|
||||
)
|
||||
""",
|
||||
(cutoff,),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM verification_events
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM verification_events
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
)
|
||||
AND id NOT IN (
|
||||
SELECT last_event_id FROM verification_state
|
||||
WHERE last_event_id IS NOT NULL
|
||||
)
|
||||
""",
|
||||
(_MAX_TOTAL_UNREFERENCED_EVENTS,),
|
||||
)
|
||||
|
||||
|
||||
def classify_verification_command(
|
||||
command: str,
|
||||
*,
|
||||
cwd: str | Path | None = None,
|
||||
session_id: str | None = None,
|
||||
exit_code: int = 0,
|
||||
output: str = "",
|
||||
) -> Optional[VerificationEvidence]:
|
||||
"""Classify a terminal command as verification evidence, if applicable."""
|
||||
|
||||
if not command or not isinstance(command, str):
|
||||
return None
|
||||
try:
|
||||
from agent.coding_context import project_facts_for
|
||||
|
||||
facts = project_facts_for(cwd)
|
||||
except Exception:
|
||||
facts = None
|
||||
if not facts:
|
||||
return None
|
||||
|
||||
verify_commands = list(facts.get("verifyCommands") or [])
|
||||
match = _find_canonical_match(command, verify_commands)
|
||||
is_ad_hoc = False
|
||||
if match is None and not verify_commands:
|
||||
ad_hoc_args = _find_ad_hoc_match(command, facts.get("root"))
|
||||
if ad_hoc_args is not None:
|
||||
match = ("ad-hoc verification script", ad_hoc_args)
|
||||
is_ad_hoc = True
|
||||
if match is None:
|
||||
return None
|
||||
|
||||
canonical, trailing_args = match
|
||||
return VerificationEvidence(
|
||||
command=command,
|
||||
canonical_command=canonical,
|
||||
kind="ad_hoc" if is_ad_hoc else _kind_for_command(canonical),
|
||||
scope="targeted" if is_ad_hoc else _scope_for_args(trailing_args),
|
||||
status="passed" if int(exit_code) == 0 else "failed",
|
||||
exit_code=int(exit_code),
|
||||
cwd=str(Path(cwd or ".").resolve()),
|
||||
root=str(facts.get("root") or Path(cwd or ".").resolve()),
|
||||
session_id=str(session_id or "default"),
|
||||
output_summary=_summarize_output(output),
|
||||
)
|
||||
|
||||
|
||||
def record_terminal_result(
|
||||
*,
|
||||
command: str,
|
||||
cwd: str | Path | None,
|
||||
session_id: str | None,
|
||||
exit_code: int,
|
||||
output: str = "",
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Record a foreground terminal result when it is verification evidence."""
|
||||
|
||||
evidence = classify_verification_command(
|
||||
command,
|
||||
cwd=cwd,
|
||||
session_id=session_id,
|
||||
exit_code=exit_code,
|
||||
output=output,
|
||||
)
|
||||
if evidence is None:
|
||||
return None
|
||||
|
||||
created_at = _utc_now()
|
||||
with _DB_LOCK:
|
||||
with _connect() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO verification_events(
|
||||
created_at, session_id, cwd, root, command, canonical_command,
|
||||
kind, scope, status, exit_code, output_summary
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
created_at,
|
||||
evidence.session_id,
|
||||
evidence.cwd,
|
||||
evidence.root,
|
||||
evidence.command,
|
||||
evidence.canonical_command,
|
||||
evidence.kind,
|
||||
evidence.scope,
|
||||
evidence.status,
|
||||
evidence.exit_code,
|
||||
evidence.output_summary,
|
||||
),
|
||||
)
|
||||
if cur.lastrowid is None:
|
||||
raise RuntimeError("verification event insert did not return an id")
|
||||
event_id = int(cur.lastrowid)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO verification_state(
|
||||
session_id, root, last_event_id, last_edit_at, changed_paths_json
|
||||
) VALUES (?, ?, ?, NULL, '[]')
|
||||
ON CONFLICT(session_id, root) DO UPDATE SET
|
||||
last_event_id = excluded.last_event_id,
|
||||
last_edit_at = NULL,
|
||||
changed_paths_json = '[]'
|
||||
""",
|
||||
(evidence.session_id, evidence.root, event_id),
|
||||
)
|
||||
_prune_old_events(conn, session_id=evidence.session_id, root=evidence.root)
|
||||
conn.commit()
|
||||
|
||||
return {"id": event_id, **evidence.__dict__, "created_at": created_at}
|
||||
|
||||
|
||||
def mark_workspace_edited(
|
||||
*,
|
||||
session_id: str | None,
|
||||
cwd: str | Path | None,
|
||||
paths: list[str] | tuple[str, ...] | None = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Mark verification evidence stale after a successful file edit."""
|
||||
|
||||
try:
|
||||
from agent.coding_context import project_facts_for
|
||||
|
||||
facts = project_facts_for(cwd)
|
||||
except Exception:
|
||||
facts = None
|
||||
if not facts:
|
||||
return None
|
||||
|
||||
sid = str(session_id or "default")
|
||||
root = str(facts.get("root") or Path(cwd or ".").resolve())
|
||||
changed_paths = sorted({str(p) for p in (paths or []) if p})
|
||||
edited_at = _utc_now()
|
||||
|
||||
with _DB_LOCK:
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT changed_paths_json FROM verification_state
|
||||
WHERE session_id = ? AND root = ?
|
||||
""",
|
||||
(sid, root),
|
||||
).fetchone()
|
||||
existing: set[str] = set()
|
||||
if row is not None:
|
||||
try:
|
||||
existing = set(json.loads(row["changed_paths_json"] or "[]"))
|
||||
except (TypeError, ValueError):
|
||||
existing = set()
|
||||
merged = sorted((existing | set(changed_paths)))[-200:]
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO verification_state(
|
||||
session_id, root, last_event_id, last_edit_at, changed_paths_json
|
||||
) VALUES (?, ?, NULL, ?, ?)
|
||||
ON CONFLICT(session_id, root) DO UPDATE SET
|
||||
last_edit_at = excluded.last_edit_at,
|
||||
changed_paths_json = excluded.changed_paths_json
|
||||
""",
|
||||
(sid, root, edited_at, json.dumps(merged)),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"session_id": sid, "root": root, "last_edit_at": edited_at, "changed_paths": changed_paths}
|
||||
|
||||
|
||||
def verification_status(
|
||||
*,
|
||||
session_id: str | None,
|
||||
cwd: str | Path | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the best known verification state for a session/workspace."""
|
||||
|
||||
try:
|
||||
from agent.coding_context import project_facts_for
|
||||
|
||||
facts = project_facts_for(cwd)
|
||||
except Exception:
|
||||
facts = None
|
||||
if not facts:
|
||||
return {"status": "not_applicable", "evidence": None}
|
||||
|
||||
sid = str(session_id or "default")
|
||||
root = str(facts.get("root") or Path(cwd or ".").resolve())
|
||||
with _DB_LOCK:
|
||||
with _connect() as conn:
|
||||
state = conn.execute(
|
||||
"""
|
||||
SELECT last_event_id, last_edit_at, changed_paths_json
|
||||
FROM verification_state
|
||||
WHERE session_id = ? AND root = ?
|
||||
""",
|
||||
(sid, root),
|
||||
).fetchone()
|
||||
if state is None:
|
||||
return {
|
||||
"status": "unverified",
|
||||
"evidence": None,
|
||||
"root": root,
|
||||
"session_id": sid,
|
||||
"changed_paths": [],
|
||||
}
|
||||
event = None
|
||||
if state["last_event_id"] is not None:
|
||||
event = conn.execute(
|
||||
"SELECT * FROM verification_events WHERE id = ?",
|
||||
(state["last_event_id"],),
|
||||
).fetchone()
|
||||
|
||||
changed_paths: list[str] = []
|
||||
try:
|
||||
changed_paths = json.loads(state["changed_paths_json"] or "[]")
|
||||
except (TypeError, ValueError):
|
||||
changed_paths = []
|
||||
|
||||
if event is None:
|
||||
return {
|
||||
"status": "unverified",
|
||||
"evidence": None,
|
||||
"root": root,
|
||||
"session_id": sid,
|
||||
"changed_paths": changed_paths,
|
||||
}
|
||||
|
||||
evidence = dict(event)
|
||||
if state["last_edit_at"] and state["last_edit_at"] > evidence["created_at"]:
|
||||
status = "stale"
|
||||
else:
|
||||
status = evidence["status"]
|
||||
return {
|
||||
"status": status,
|
||||
"evidence": evidence,
|
||||
"root": root,
|
||||
"session_id": sid,
|
||||
"changed_paths": changed_paths,
|
||||
}
|
||||
164
agent/verification_stop.py
Normal file
164
agent/verification_stop.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Turn-end verification guard for coding edits.
|
||||
|
||||
This module is intentionally policy-only. It never runs checks itself; it turns
|
||||
the passive verification ledger into a bounded follow-up when the model tries to
|
||||
finish immediately after editing code without fresh evidence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
_MAX_CHANGED_PATHS_IN_NUDGE = 8
|
||||
|
||||
|
||||
def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool:
|
||||
"""Return whether edit -> verify-before-finish behavior is enabled."""
|
||||
env = os.environ.get("HERMES_VERIFY_ON_STOP")
|
||||
if env is not None:
|
||||
return env.strip().lower() not in {"0", "false", "no", "off"}
|
||||
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
|
||||
if isinstance(agent_cfg, dict) and "verify_on_stop" in agent_cfg:
|
||||
return bool(agent_cfg.get("verify_on_stop"))
|
||||
return True
|
||||
|
||||
|
||||
def _candidate_cwds(paths: Iterable[str]) -> list[Path]:
|
||||
candidates: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for raw in paths:
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
path = Path(raw).expanduser()
|
||||
candidate = path if path.is_dir() else path.parent
|
||||
resolved = str(candidate.resolve())
|
||||
except Exception:
|
||||
continue
|
||||
if resolved not in seen:
|
||||
seen.add(resolved)
|
||||
candidates.append(Path(resolved))
|
||||
return candidates
|
||||
|
||||
|
||||
def _verification_snapshot(
|
||||
*,
|
||||
session_id: str | None,
|
||||
changed_paths: list[str],
|
||||
) -> tuple[dict[str, Any], dict[str, Any]] | None:
|
||||
"""Return ``(status, facts)`` for the first edited workspace needing proof."""
|
||||
try:
|
||||
from agent.coding_context import project_facts_for
|
||||
from agent.verification_evidence import verification_status
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
first_snapshot: tuple[dict[str, Any], dict[str, Any]] | None = None
|
||||
for cwd in _candidate_cwds(changed_paths):
|
||||
facts = project_facts_for(cwd)
|
||||
if not facts:
|
||||
continue
|
||||
status = verification_status(session_id=session_id, cwd=cwd)
|
||||
snapshot = (status, facts)
|
||||
if first_snapshot is None:
|
||||
first_snapshot = snapshot
|
||||
if str(status.get("status") or "unverified") != "passed":
|
||||
return snapshot
|
||||
return first_snapshot
|
||||
|
||||
|
||||
def _format_changed_paths(paths: list[str]) -> str:
|
||||
shown = paths[:_MAX_CHANGED_PATHS_IN_NUDGE]
|
||||
lines = [f"- `{path}`" for path in shown]
|
||||
remaining = len(paths) - len(shown)
|
||||
if remaining > 0:
|
||||
lines.append(f"- ... and {remaining} more")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _status_detail(status: dict[str, Any]) -> str:
|
||||
state = str(status.get("status") or "unverified")
|
||||
evidence = status.get("evidence") if isinstance(status.get("evidence"), dict) else None
|
||||
if not evidence:
|
||||
return state
|
||||
|
||||
command = evidence.get("canonical_command") or evidence.get("command")
|
||||
summary = str(evidence.get("output_summary") or "").strip()
|
||||
parts = [state]
|
||||
if command:
|
||||
parts.append(f"last command `{command}`")
|
||||
if summary:
|
||||
max_summary = 1200
|
||||
if len(summary) > max_summary:
|
||||
summary = summary[:max_summary].rstrip() + "\n... [truncated]"
|
||||
parts.append(f"last output:\n{summary}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def build_verify_on_stop_nudge(
|
||||
*,
|
||||
session_id: str | None,
|
||||
changed_paths: Iterable[str],
|
||||
attempts: int = 0,
|
||||
max_attempts: int = 2,
|
||||
) -> str | None:
|
||||
"""Return a synthetic follow-up when edited code lacks fresh verification."""
|
||||
paths = sorted({str(p) for p in changed_paths if p})
|
||||
if not paths or attempts >= max_attempts:
|
||||
return None
|
||||
|
||||
snapshot = _verification_snapshot(session_id=session_id, changed_paths=paths)
|
||||
if snapshot is None:
|
||||
return None
|
||||
status, facts = snapshot
|
||||
|
||||
verify_commands = [
|
||||
str(cmd).strip()
|
||||
for cmd in (facts.get("verifyCommands") or [])
|
||||
if str(cmd).strip()
|
||||
]
|
||||
|
||||
state = str(status.get("status") or "unverified")
|
||||
if state == "passed":
|
||||
return None
|
||||
|
||||
if verify_commands:
|
||||
command_instruction = (
|
||||
"Run the relevant verification command now ("
|
||||
+ ", ".join(f"`{cmd}`" for cmd in verify_commands[:3])
|
||||
+ (", ..." if len(verify_commands) > 3 else "")
|
||||
+ "), read any failure, repair the code, and summarize what passed."
|
||||
)
|
||||
else:
|
||||
temp_dir = tempfile.gettempdir()
|
||||
command_instruction = (
|
||||
"No canonical test/lint/build command was detected. Create a focused "
|
||||
f"temporary verification script under `{temp_dir}` using an OS-safe "
|
||||
"`tempfile` path with a `hermes-verify-` filename prefix, run it "
|
||||
"against the changed behavior, clean it up when possible, and "
|
||||
"summarize it explicitly as ad-hoc verification rather than suite "
|
||||
"green."
|
||||
)
|
||||
|
||||
return (
|
||||
"[System: You edited code in this turn, but the workspace does not have "
|
||||
"fresh passing verification evidence yet.\n\n"
|
||||
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.]"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["build_verify_on_stop_nudge", "verify_on_stop_enabled"]
|
||||
@@ -269,94 +269,6 @@ function cookiesHaveLiveSession(cookies) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a stored SSH connection entry into a clean descriptor, or null when
|
||||
* it is not a usable SSH config. Pure: no secrets here — the per-connection
|
||||
* dashboard token is persisted separately (encrypted) and decrypted by main.cjs,
|
||||
* exactly like the token-remote secret. An SSH entry needs at least a host.
|
||||
*
|
||||
* Shape in/out: { mode:'ssh', host, user?, port?, keyPath?, remoteHermesPath? }
|
||||
*/
|
||||
function normalizeSshConfig(entry) {
|
||||
if (!entry || typeof entry !== 'object' || entry.mode !== 'ssh') {
|
||||
return null
|
||||
}
|
||||
let host = String(entry.host || '').trim()
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
// Parse a user@host[:port] target typed into the single host field. Explicit
|
||||
// user/port fields win, so filling the User field after typing user@host does
|
||||
// NOT double up into user@user@host. A bare ~/.ssh/config alias is preserved.
|
||||
let parsedUser
|
||||
let parsedPort
|
||||
const at = host.indexOf('@')
|
||||
if (at > 0) {
|
||||
parsedUser = host.slice(0, at)
|
||||
host = host.slice(at + 1)
|
||||
}
|
||||
// Only split a trailing :port when there's exactly one colon and a numeric
|
||||
// suffix — leaves IPv6 literals (multiple colons) and bare aliases alone.
|
||||
if ((host.match(/:/g) || []).length === 1) {
|
||||
const [h, p] = host.split(':')
|
||||
if (/^\d+$/.test(p)) {
|
||||
host = h
|
||||
parsedPort = Number.parseInt(p, 10)
|
||||
}
|
||||
}
|
||||
if (!host) {
|
||||
return null
|
||||
}
|
||||
const out = { mode: 'ssh', host }
|
||||
const user = String(entry.user || '').trim() || parsedUser || ''
|
||||
if (user) out.user = user
|
||||
const explicitPort = Number.parseInt(String(entry.port ?? ''), 10)
|
||||
const port = Number.isInteger(explicitPort) && explicitPort > 0 ? explicitPort : parsedPort
|
||||
if (Number.isInteger(port) && port > 0 && port !== 22) {
|
||||
out.port = port
|
||||
}
|
||||
const keyPath = String(entry.keyPath || '').trim()
|
||||
if (keyPath) out.keyPath = keyPath
|
||||
const remoteHermesPath = String(entry.remoteHermesPath || '').trim()
|
||||
if (remoteHermesPath) out.remoteHermesPath = remoteHermesPath
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a profile's SSH connection override from a connection config, or null
|
||||
* when it has none. Mirrors profileRemoteOverride() but for `mode: 'ssh'`
|
||||
* entries. Returns the normalized SSH descriptor (no token).
|
||||
*/
|
||||
function profileSshOverride(config, profile) {
|
||||
const key = connectionScopeKey(profile)
|
||||
const entry = key ? config?.profiles?.[key] : null
|
||||
return normalizeSshConfig(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-facing host label for the connection statusbar pill. For SSH mode the
|
||||
* caller passes the resolved/entered host directly; for token/oauth remotes we
|
||||
* derive it from the (real) backend URL — NOT the loopback tunnel URL. Returns
|
||||
* a bare hostname (and :port when non-default) or null.
|
||||
*/
|
||||
function hostLabelFromBaseUrl(baseUrl) {
|
||||
const raw = String(baseUrl || '').trim()
|
||||
if (!raw) return null
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const host = parsed.hostname
|
||||
if (!host) return null
|
||||
const port = parsed.port
|
||||
if (port && port !== '80' && port !== '443') {
|
||||
return `${host}:${port}`
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AT_COOKIE_VARIANTS,
|
||||
RT_COOKIE_VARIANTS,
|
||||
@@ -366,13 +278,10 @@ module.exports = {
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
hostLabelFromBaseUrl,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
normalizeSshConfig,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
profileSshOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
|
||||
@@ -22,13 +22,10 @@ const {
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
hostLabelFromBaseUrl,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
normalizeSshConfig,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
profileSshOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
tokenPreview
|
||||
@@ -397,82 +394,3 @@ test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => {
|
||||
/mintTicket function is required/
|
||||
)
|
||||
})
|
||||
|
||||
// --- SSH mode helpers ---
|
||||
|
||||
test('normalizeSshConfig requires mode:ssh and a host', () => {
|
||||
assert.equal(normalizeSshConfig(null), null)
|
||||
assert.equal(normalizeSshConfig({ mode: 'remote', url: 'http://x' }), null)
|
||||
assert.equal(normalizeSshConfig({ mode: 'ssh' }), null)
|
||||
assert.equal(normalizeSshConfig({ mode: 'ssh', host: ' ' }), null)
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box' }), { mode: 'ssh', host: 'box' })
|
||||
})
|
||||
|
||||
test('normalizeSshConfig keeps user/keyPath/remoteHermesPath and drops the default port', () => {
|
||||
assert.deepEqual(
|
||||
normalizeSshConfig({
|
||||
mode: 'ssh',
|
||||
host: 'box',
|
||||
user: 'me',
|
||||
port: 22,
|
||||
keyPath: '~/.ssh/id_ed25519',
|
||||
remoteHermesPath: '/opt/hermes'
|
||||
}),
|
||||
{ mode: 'ssh', host: 'box', user: 'me', keyPath: '~/.ssh/id_ed25519', remoteHermesPath: '/opt/hermes' }
|
||||
)
|
||||
})
|
||||
|
||||
test('normalizeSshConfig preserves a non-default port', () => {
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box', port: 2222 }), {
|
||||
mode: 'ssh',
|
||||
host: 'box',
|
||||
port: 2222
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizeSshConfig parses user@host typed into the host field', () => {
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'jonny@mac-mini' }), {
|
||||
mode: 'ssh',
|
||||
host: 'mac-mini',
|
||||
user: 'jonny'
|
||||
})
|
||||
})
|
||||
|
||||
test('normalizeSshConfig parses user@host:port and drops a default :22', () => {
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'jonny@box:2222' }), {
|
||||
mode: 'ssh',
|
||||
host: 'box',
|
||||
user: 'jonny',
|
||||
port: 2222
|
||||
})
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'box:22' }), { mode: 'ssh', host: 'box' })
|
||||
})
|
||||
|
||||
test('normalizeSshConfig: explicit user/port win over user@host:port (no user@user@host)', () => {
|
||||
assert.deepEqual(
|
||||
normalizeSshConfig({ mode: 'ssh', host: 'jonny@box:2222', user: 'admin', port: 2200 }),
|
||||
{ mode: 'ssh', host: 'box', user: 'admin', port: 2200 }
|
||||
)
|
||||
})
|
||||
|
||||
test('normalizeSshConfig leaves a bare ~/.ssh/config alias and IPv6 literals alone', () => {
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'mac-mini' }), { mode: 'ssh', host: 'mac-mini' })
|
||||
// IPv6 (multiple colons) must NOT be split as host:port
|
||||
assert.deepEqual(normalizeSshConfig({ mode: 'ssh', host: 'fe80::1' }), { mode: 'ssh', host: 'fe80::1' })
|
||||
})
|
||||
|
||||
test('profileSshOverride returns a profile-scoped ssh descriptor or null', () => {
|
||||
const config = { profiles: { work: { mode: 'ssh', host: 'mac-mini', user: 'jonny' }, other: { mode: 'remote', url: 'http://x' } } }
|
||||
assert.deepEqual(profileSshOverride(config, 'work'), { mode: 'ssh', host: 'mac-mini', user: 'jonny' })
|
||||
assert.equal(profileSshOverride(config, 'other'), null, 'token-remote entry is not an ssh override')
|
||||
assert.equal(profileSshOverride(config, 'missing'), null)
|
||||
assert.equal(profileSshOverride(config, ''), null, 'global scope has no profile entry')
|
||||
})
|
||||
|
||||
test('hostLabelFromBaseUrl gives a bare host, with :port only when non-default', () => {
|
||||
assert.equal(hostLabelFromBaseUrl('https://box.tail1234.ts.net'), 'box.tail1234.ts.net')
|
||||
assert.equal(hostLabelFromBaseUrl('http://box.local:8080'), 'box.local:8080')
|
||||
assert.equal(hostLabelFromBaseUrl('https://box:443'), 'box')
|
||||
assert.equal(hostLabelFromBaseUrl(''), null)
|
||||
assert.equal(hostLabelFromBaseUrl('not a url'), null)
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,13 +7,37 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
||||
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
|
||||
openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'),
|
||||
petOverlay: {
|
||||
// Main renderer → main process: window lifecycle + drag. `request` is
|
||||
// `{ bounds, screen }`; resolves with the screen bounds it actually used.
|
||||
open: request => ipcRenderer.invoke('hermes:pet-overlay:open', request),
|
||||
close: () => ipcRenderer.invoke('hermes:pet-overlay:close'),
|
||||
setBounds: bounds => ipcRenderer.send('hermes:pet-overlay:set-bounds', bounds),
|
||||
setIgnoreMouse: ignore => ipcRenderer.send('hermes:pet-overlay:ignore-mouse', ignore),
|
||||
// Flip the overlay focusable (and focus it) while the composer needs keys.
|
||||
setFocusable: focusable => ipcRenderer.send('hermes:pet-overlay:set-focusable', focusable),
|
||||
// Main renderer → overlay (forwarded by main): push the latest pet state.
|
||||
pushState: payload => ipcRenderer.send('hermes:pet-overlay:state', payload),
|
||||
// Overlay → main renderer (forwarded by main): pop back in / composer submit.
|
||||
control: payload => ipcRenderer.send('hermes:pet-overlay:control', payload),
|
||||
// Overlay subscribes to state pushes.
|
||||
onState: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:pet-overlay:state', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:pet-overlay:state', listener)
|
||||
},
|
||||
// Main renderer subscribes to overlay control messages.
|
||||
onControl: callback => {
|
||||
const listener = (_event, payload) => callback(payload)
|
||||
ipcRenderer.on('hermes:pet-overlay:control', listener)
|
||||
return () => ipcRenderer.removeListener('hermes:pet-overlay:control', listener)
|
||||
}
|
||||
},
|
||||
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
|
||||
getConnectionConfig: profile => ipcRenderer.invoke('hermes:connection-config:get', profile),
|
||||
saveConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:save', payload),
|
||||
applyConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:apply', payload),
|
||||
testConnectionConfig: payload => ipcRenderer.invoke('hermes:connection-config:test', payload),
|
||||
sshConfigHosts: () => ipcRenderer.invoke('hermes:connection-config:ssh-hosts'),
|
||||
sshResolveHost: host => ipcRenderer.invoke('hermes:connection-config:ssh-resolve', host),
|
||||
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),
|
||||
|
||||
@@ -1,505 +0,0 @@
|
||||
/**
|
||||
* remote-lifecycle.cjs
|
||||
*
|
||||
* Pure, electron-free remote Hermes dashboard lifecycle over SSH for Desktop
|
||||
* SSH remote mode. Composes an SshConnection (injected) with HTTP probes
|
||||
* through the established tunnel (injected fetch) and the served-token adoption
|
||||
* step (injected). Knows how to:
|
||||
*
|
||||
* - locate the Hermes install on the remote (login-shell probe),
|
||||
* - gate the remote platform to Linux/macOS via `uname`,
|
||||
* - reuse an existing desktop-dedicated dashboard via a lockfile + an
|
||||
* AUTHENTICATED /api/status probe (pid liveness alone is insufficient),
|
||||
* - spawn a fresh detached `--isolated --port 0` dashboard and scrape its
|
||||
* `HERMES_DASHBOARD_READY port=<n>` readiness line,
|
||||
* - adopt the token the dashboard actually serves (served-token adoption),
|
||||
* - clean up a stale dashboard only when it is provably ours.
|
||||
*
|
||||
* Electron-free so it can be unit-tested with `node --test`. main.cjs wires the
|
||||
* real SshConnection, fetch, adoptServedDashboardToken, and waitForHermes in.
|
||||
*
|
||||
* The minted HERMES_DASHBOARD_SESSION_TOKEN is the SPAWN credential. After
|
||||
* readiness the caller (or connect() here) runs served-token adoption against
|
||||
* the tunneled baseUrl and the SERVED token's fingerprint is what lands in the
|
||||
* lockfile — so the reuse probe checks the credential that actually
|
||||
* authenticates /api/ws, not the minted one (which the dashboard may regen).
|
||||
*/
|
||||
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
const LOCKFILE_SCHEMA_VERSION = 1
|
||||
// Bumped when the desktop<->dashboard reuse contract changes in a way that
|
||||
// makes an old running dashboard unsafe to reattach to (token handling, the
|
||||
// readiness/spawn args, the served-token reconciliation). A lockfile whose
|
||||
// protocolVersion doesn't match forces a clean respawn rather than a reattach.
|
||||
const PROTOCOL_VERSION = 1
|
||||
const READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m
|
||||
// Remote log the detached dashboard appends to; also where we scrape readiness.
|
||||
const REMOTE_LOG = '~/.hermes/logs/desktop-ssh.log'
|
||||
const REMOTE_LOCK_DIR = '~/.hermes/desktop-ssh'
|
||||
const SUPPORTED_REMOTE_OS = new Set(['Linux', 'Darwin'])
|
||||
const DEFAULT_READY_TIMEOUT_MS = 45_000
|
||||
const READY_POLL_INTERVAL_MS = 750
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mintToken() {
|
||||
return crypto.randomBytes(32).toString('hex')
|
||||
}
|
||||
|
||||
// Fingerprint a token for the lockfile — never store the raw secret on the
|
||||
// remote. SHA256, truncated; comparison is constant-shape.
|
||||
function fingerprintToken(token) {
|
||||
return crypto.createHash('sha256').update(String(token || '')).digest('hex').slice(0, 32)
|
||||
}
|
||||
|
||||
// Stable per-client lock id so a given desktop client reuses its own dashboard
|
||||
// across reconnects but never collides with another client's.
|
||||
function clientLockId(clientId) {
|
||||
const safe = String(clientId || 'default').replace(/[^A-Za-z0-9_.-]/g, '_')
|
||||
return safe.slice(0, 64) || 'default'
|
||||
}
|
||||
|
||||
function lockfilePath(clientId) {
|
||||
return `${REMOTE_LOCK_DIR}/${clientLockId(clientId)}.lock.json`
|
||||
}
|
||||
|
||||
// shell-single-quote a value for safe interpolation into a remote command.
|
||||
function shq(value) {
|
||||
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Locate hermes on the remote
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Try, in order: an explicit profile path; `command -v hermes` in a LOGIN
|
||||
// shell (non-login `ssh host cmd` PATH frequently misses user installs — the
|
||||
// login-shell probe is load-bearing, same pitfall ssh.py works around); the
|
||||
// conventional venv path. Returns the resolved absolute path or throws an
|
||||
// install-hint error.
|
||||
async function locateHermes(ssh, remoteHermesPath) {
|
||||
const candidates = []
|
||||
if (remoteHermesPath) {
|
||||
candidates.push(remoteHermesPath)
|
||||
}
|
||||
|
||||
// login-shell `command -v` — quoted so the remote shell resolves PATH the
|
||||
// way an interactive login would.
|
||||
try {
|
||||
const found = (await ssh.exec(`bash -lc ${shq('command -v hermes')}`)).trim()
|
||||
if (found) {
|
||||
candidates.push(found.split('\n').pop().trim())
|
||||
}
|
||||
} catch {
|
||||
// fall through to the explicit candidates below
|
||||
}
|
||||
|
||||
candidates.push('~/.hermes/hermes-agent/venv/bin/hermes')
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue
|
||||
try {
|
||||
// -x test resolves ~ and verifies it's executable in one round trip.
|
||||
const ok = (await ssh.exec(`[ -x "$(eval echo ${shq(candidate)})" ] && echo OK || true`)).trim()
|
||||
if (ok === 'OK') {
|
||||
return candidate
|
||||
}
|
||||
} catch {
|
||||
// try the next candidate
|
||||
}
|
||||
}
|
||||
|
||||
const err = new Error(
|
||||
'Hermes is not installed on the remote host (could not find a `hermes` executable). ' +
|
||||
'Install it on the remote with: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | sh ' +
|
||||
'— or set the Hermes path explicitly in the SSH connection settings.'
|
||||
)
|
||||
err.kind = 'hermes-not-found'
|
||||
throw err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remote platform gate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function probeRemotePlatform(ssh) {
|
||||
const out = (await ssh.exec('uname -s; uname -m')).trim().split('\n')
|
||||
const osName = (out[0] || '').trim()
|
||||
const arch = (out[1] || '').trim()
|
||||
if (!SUPPORTED_REMOTE_OS.has(osName)) {
|
||||
const err = new Error(
|
||||
`Unsupported remote platform "${osName || 'unknown'}". Hermes Desktop SSH mode supports Linux and macOS remote hosts only.`
|
||||
)
|
||||
err.kind = 'unsupported-platform'
|
||||
throw err
|
||||
}
|
||||
return { os: osName, arch }
|
||||
}
|
||||
|
||||
// The HERMES_HOME the remote dashboard will use (explicit env wins, else
|
||||
// ~/.hermes). Recorded in the lockfile so a future reuse can tell it's the same
|
||||
// state store; best-effort (a probe failure falls back to '~/.hermes').
|
||||
async function probeRemoteHermesHome(ssh) {
|
||||
try {
|
||||
const out = (await ssh.exec('echo "${HERMES_HOME:-$HOME/.hermes}"')).trim().split('\n').pop()
|
||||
return out || '~/.hermes'
|
||||
} catch {
|
||||
return '~/.hermes'
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lockfile (lives on the REMOTE, read/written via ssh.exec)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function readLockfile(ssh, clientId) {
|
||||
const path = lockfilePath(clientId)
|
||||
let raw
|
||||
try {
|
||||
raw = await ssh.exec(`cat "$(eval echo ${shq(path)})" 2>/dev/null || true`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const text = String(raw || '').trim()
|
||||
if (!text) return null
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!parsed || parsed.schemaVersion !== LOCKFILE_SCHEMA_VERSION) {
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
async function writeLockfile(ssh, clientId, lock) {
|
||||
const path = lockfilePath(clientId)
|
||||
const json = JSON.stringify({ ...lock, schemaVersion: LOCKFILE_SCHEMA_VERSION })
|
||||
await ssh.exec(
|
||||
`mkdir -p "$(eval echo ${shq(REMOTE_LOCK_DIR)})" && ` +
|
||||
`printf '%s' ${shq(json)} > "$(eval echo ${shq(path)})"`
|
||||
)
|
||||
}
|
||||
|
||||
async function removeLockfile(ssh, clientId) {
|
||||
const path = lockfilePath(clientId)
|
||||
try {
|
||||
await ssh.exec(`rm -f "$(eval echo ${shq(path)})"`)
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
// True iff the pid is alive on the remote.
|
||||
async function remotePidAlive(ssh, pid) {
|
||||
if (!pid || !Number.isInteger(Number(pid))) return false
|
||||
try {
|
||||
const out = (await ssh.exec(`kill -0 ${Number(pid)} 2>/dev/null && echo ALIVE || echo DEAD`)).trim()
|
||||
return out === 'ALIVE'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// A pid is "provably ours" only if its remote cmdline carries our dashboard
|
||||
// args — never kill a pid we can't positively identify as our dashboard.
|
||||
async function pidIsOurDashboard(ssh, pid) {
|
||||
if (!pid) return false
|
||||
try {
|
||||
// /proc on Linux; `ps` fallback covers macOS. Tolerate either being absent.
|
||||
const out = await ssh.exec(
|
||||
`(cat /proc/${Number(pid)}/cmdline 2>/dev/null | tr '\\0' ' '; ` +
|
||||
`ps -o command= -p ${Number(pid)} 2>/dev/null) || true`
|
||||
)
|
||||
const cmd = String(out || '')
|
||||
return /hermes\b/.test(cmd) && /dashboard/.test(cmd) && /--isolated/.test(cmd)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Kill the stale dashboard ONLY if provably ours, then drop the lockfile.
|
||||
async function cleanupStale(ssh, clientId, pid) {
|
||||
if (await pidIsOurDashboard(ssh, pid)) {
|
||||
try {
|
||||
await ssh.exec(`kill ${Number(pid)} 2>/dev/null || true`)
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
await removeLockfile(ssh, clientId)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawn a fresh detached dashboard + scrape the readiness line
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Build the detached spawn command. setsid + </dev/null + redirect-to-log so it
|
||||
// survives the SSH channel closing; echo $! returns the pid. The token rides as
|
||||
// a spawn-time env var only — callers MUST redact this command before logging.
|
||||
function buildSpawnCommand(hermesPath, profile, token) {
|
||||
// Assembled from parts so the secret env var name is never a literal in one
|
||||
// place; the value itself is shell-quoted.
|
||||
const tokenEnvName = ['HERMES', 'DASHBOARD', 'SESSION', 'TOKEN'].join('_')
|
||||
const envPrefix = `env ${tokenEnvName}=${shq(token)} HERMES_DESKTOP=1`
|
||||
const hermes = `"$(eval echo ${shq(hermesPath)})"`
|
||||
const profileArgs = profile ? `--profile ${shq(profile)} ` : ''
|
||||
const logPath = `"$(eval echo ${shq(REMOTE_LOG)})"`
|
||||
// --isolated => dedicated loopback dashboard, NOT routed into the host's
|
||||
// unified machine dashboard. --port 0 => server picks a free port and prints
|
||||
// HERMES_DASHBOARD_READY port=<n>. --skip-build => never trigger an npm web-UI
|
||||
// build in this headless SSH bootstrap; if no built dist exists the backend
|
||||
// fails loudly (which scrapeReadyPort surfaces) instead of hanging on a build.
|
||||
const dashCmd =
|
||||
`${envPrefix} ${hermes} ${profileArgs}dashboard --isolated --no-open ` +
|
||||
`--host 127.0.0.1 --port 0 --skip-build`
|
||||
return (
|
||||
`mkdir -p "$(dirname ${logPath})" && ` +
|
||||
`setsid sh -c ${shq(`${dashCmd} </dev/null >> ${logPath} 2>&1 & echo $!`)}`
|
||||
)
|
||||
}
|
||||
|
||||
// Scrape the most recent HERMES_DASHBOARD_READY line from the remote log,
|
||||
// polling until it appears or the timeout fires. Returns the bound port.
|
||||
//
|
||||
// We mark the log with a unique sentinel BEFORE spawning so we only read the
|
||||
// readiness line belonging to THIS spawn, never a stale one from a prior run.
|
||||
async function scrapeReadyPort(ssh, sentinel, { timeoutMs = DEFAULT_READY_TIMEOUT_MS, isAlive } = {}) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
const logPath = `"$(eval echo ${shq(REMOTE_LOG)})"`
|
||||
while (Date.now() < deadline) {
|
||||
if (isAlive && !(await isAlive())) {
|
||||
const err = new Error('Remote dashboard process exited before announcing its port.')
|
||||
err.kind = 'spawn-failed'
|
||||
throw err
|
||||
}
|
||||
let tail
|
||||
try {
|
||||
// Read only the portion AFTER our sentinel so prior runs' READY lines
|
||||
// can't satisfy us.
|
||||
tail = await ssh.exec(
|
||||
`awk ${shq(`/${sentinel}/{seen=1; next} seen{print}`)} ${logPath} 2>/dev/null || true`
|
||||
)
|
||||
} catch {
|
||||
tail = ''
|
||||
}
|
||||
const m = READY_RE.exec(String(tail || ''))
|
||||
if (m) {
|
||||
return parseInt(m[1], 10)
|
||||
}
|
||||
await new Promise(r => setTimeout(r, READY_POLL_INTERVAL_MS))
|
||||
}
|
||||
const err = new Error(`Timed out waiting for the remote dashboard to announce its port (${timeoutMs}ms).`)
|
||||
err.kind = 'ready-timeout'
|
||||
throw err
|
||||
}
|
||||
|
||||
// Write a unique sentinel into the remote log, then spawn. Returns { pid,
|
||||
// sentinel }.
|
||||
async function spawnRemoteDashboard(ssh, { hermesPath, profile, token }) {
|
||||
const sentinel = `HERMES_SSH_SPAWN_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`
|
||||
const logPath = `"$(eval echo ${shq(REMOTE_LOG)})"`
|
||||
await ssh.exec(`mkdir -p "$(dirname ${logPath})" && printf '%s\\n' ${shq(sentinel)} >> ${logPath}`)
|
||||
const out = await ssh.exec(buildSpawnCommand(hermesPath, profile, token))
|
||||
const pid = parseInt(String(out || '').trim().split('\n').pop(), 10)
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
const err = new Error('Failed to launch the remote dashboard (no pid returned).')
|
||||
err.kind = 'spawn-failed'
|
||||
throw err
|
||||
}
|
||||
return { pid, sentinel }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// connect() — the orchestrator
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Best-effort forward teardown when a reuse attempt fails mid-flight, so we
|
||||
// don't leak a forward before respawning. `deps.cancelForward` is optional.
|
||||
async function cancelForwardSafe(deps, localPort, remotePort) {
|
||||
if (typeof deps.cancelForward !== 'function') return
|
||||
try {
|
||||
await deps.cancelForward(localPort, remotePort)
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Establish (or reuse) a remote dashboard and a tunnel to it.
|
||||
*
|
||||
* @param {object} deps
|
||||
* @param {object} deps.ssh an opened SshConnection
|
||||
* @param {string} [deps.profile] hermes profile to launch
|
||||
* @param {string} [deps.remoteHermesPath] explicit hermes path override
|
||||
* @param {string} deps.clientId stable per-client id for the lockfile
|
||||
* @param {(localPort:number, remotePort:number)=>Promise<void>} deps.forward
|
||||
* @param {()=>Promise<number>} deps.pickLocalPort
|
||||
* @param {(baseUrl:string, token:string)=>Promise<void>} deps.waitForHermes
|
||||
* @param {(baseUrl:string, token:string)=>Promise<boolean>} deps.probeStatus
|
||||
* authenticated GET /api/status — true iff it returns ok with `token`
|
||||
* @param {(baseUrl:string, spawnToken:string, opts:object)=>Promise<string>} deps.adoptServedToken
|
||||
* @param {(msg:string)=>void} [deps.rememberLog] already redaction-wrapped by caller
|
||||
* @param {number} [deps.readyTimeoutMs]
|
||||
* @returns {Promise<{baseUrl, token, tokenFingerprint, remotePort, localPort, pid, reused, platform}>}
|
||||
*/
|
||||
async function connect(deps) {
|
||||
const {
|
||||
ssh,
|
||||
profile = '',
|
||||
remoteHermesPath = '',
|
||||
clientId,
|
||||
forward,
|
||||
pickLocalPort,
|
||||
waitForHermes,
|
||||
probeStatus,
|
||||
adoptServedToken,
|
||||
rememberLog = () => {},
|
||||
readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS
|
||||
} = deps
|
||||
|
||||
const log = msg => rememberLog(`[ssh-lifecycle] ${msg}`)
|
||||
|
||||
const platform = await probeRemotePlatform(ssh)
|
||||
log(`remote platform ${platform.os}/${platform.arch}`)
|
||||
const hermesPath = await locateHermes(ssh, remoteHermesPath)
|
||||
log(`located hermes at ${hermesPath}`)
|
||||
|
||||
// --- Try lockfile reuse --------------------------------------------------
|
||||
// The reuse credential (`reuseToken`) comes from the client's encrypted
|
||||
// storage; the lockfile holds only its fingerprint. Reuse requires ALL of:
|
||||
// schema parses (readLockfile enforces), pid alive, the stored token's
|
||||
// fingerprint matches the lockfile, AND an authenticated /api/status probe
|
||||
// through the tunnel succeeds with that token. PID liveness alone is not
|
||||
// sufficient (recycled pid, wedged dashboard, rotated token).
|
||||
const reuseToken = deps.reuseToken || ''
|
||||
const lock = await readLockfile(ssh, clientId)
|
||||
if (lock && lock.pid && lock.port) {
|
||||
const pidAlive = await remotePidAlive(ssh, lock.pid)
|
||||
const fpMatch = Boolean(reuseToken) && lock.tokenFingerprint === fingerprintToken(reuseToken)
|
||||
// A lockfile written by an incompatible protocol (older/newer reuse
|
||||
// contract) is not safe to reattach to — treat it like a stale lock and
|
||||
// respawn. Absent protocolVersion (pre-versioning) also fails closed.
|
||||
const protoMatch = lock.protocolVersion === PROTOCOL_VERSION
|
||||
if (pidAlive && fpMatch && protoMatch) {
|
||||
const localPort = await pickLocalPort()
|
||||
try {
|
||||
await forward(localPort, lock.port)
|
||||
const baseUrl = `http://127.0.0.1:${localPort}`
|
||||
const ok = await probeStatus(baseUrl, reuseToken)
|
||||
if (ok) {
|
||||
// Re-run served-token adoption so a token the dashboard rotated since
|
||||
// the lockfile was written is picked up; the remote pid is alive so
|
||||
// a served-token mismatch is benign (our backend regenerated it).
|
||||
const token = await adoptServedToken(baseUrl, reuseToken, {
|
||||
// pidAlive was checked above as the reuse gate; reuse it for the
|
||||
// foreign-backend guard rather than asserting () => true.
|
||||
childAlive: () => pidAlive,
|
||||
label: 'reused remote dashboard'
|
||||
})
|
||||
log(`reusing remote dashboard pid=${lock.pid} port=${lock.port}`)
|
||||
const tokenFingerprint = fingerprintToken(token)
|
||||
if (tokenFingerprint !== lock.tokenFingerprint) {
|
||||
await writeLockfile(ssh, clientId, { ...lock, tokenFingerprint })
|
||||
}
|
||||
return {
|
||||
baseUrl,
|
||||
token,
|
||||
tokenFingerprint,
|
||||
remotePort: lock.port,
|
||||
localPort,
|
||||
pid: lock.pid,
|
||||
reused: true,
|
||||
platform
|
||||
}
|
||||
}
|
||||
log('reuse /api/status probe did not authenticate; spawning fresh')
|
||||
await cancelForwardSafe(deps, localPort, lock.port)
|
||||
} catch (error) {
|
||||
log(`reuse probe failed (${error.message}); spawning fresh`)
|
||||
await cancelForwardSafe(deps, localPort, lock.port)
|
||||
}
|
||||
} else {
|
||||
log(`lockfile present but not reusable (pidAlive=${pidAlive}, fpMatch=${fpMatch}, protoMatch=${protoMatch})`)
|
||||
}
|
||||
// Any failed condition → cleanup (kill only if provably ours) and respawn.
|
||||
await cleanupStale(ssh, clientId, lock.pid)
|
||||
}
|
||||
|
||||
// --- Spawn fresh ---------------------------------------------------------
|
||||
const spawnToken = mintToken()
|
||||
const { pid, sentinel } = await spawnRemoteDashboard(ssh, { hermesPath, profile, token: spawnToken })
|
||||
log(`spawned remote dashboard pid=${pid}`)
|
||||
|
||||
const remotePort = await scrapeReadyPort(ssh, sentinel, {
|
||||
timeoutMs: readyTimeoutMs,
|
||||
isAlive: () => remotePidAlive(ssh, pid)
|
||||
})
|
||||
log(`remote dashboard bound port ${remotePort}`)
|
||||
|
||||
const localPort = await pickLocalPort()
|
||||
await forward(localPort, remotePort)
|
||||
const baseUrl = `http://127.0.0.1:${localPort}`
|
||||
|
||||
await waitForHermes(baseUrl, spawnToken)
|
||||
|
||||
// Served-token adoption against the TUNNELED baseUrl — the served token is
|
||||
// what /api/ws will accept; the minted token is only the spawn credential.
|
||||
// Confirm the remote pid we just spawned is still alive at adoption time and
|
||||
// pass that into the foreign-backend guard — if the dashboard exited between
|
||||
// readiness and adoption, a served token from a DIFFERENT backend now bound to
|
||||
// the same forwarded port must be rejected, not silently adopted.
|
||||
const spawnedAlive = await remotePidAlive(ssh, pid)
|
||||
const token = await adoptServedToken(baseUrl, spawnToken, {
|
||||
childAlive: () => spawnedAlive,
|
||||
label: 'remote dashboard'
|
||||
})
|
||||
const tokenFingerprint = fingerprintToken(token)
|
||||
|
||||
const hermesHome = await probeRemoteHermesHome(ssh)
|
||||
await writeLockfile(ssh, clientId, {
|
||||
pid,
|
||||
port: remotePort,
|
||||
profile,
|
||||
hermesPath,
|
||||
hermesHome,
|
||||
tokenFingerprint,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
startedAt: new Date().toISOString()
|
||||
})
|
||||
|
||||
return { baseUrl, token, tokenFingerprint, remotePort, localPort, pid, reused: false, platform }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_READY_TIMEOUT_MS,
|
||||
LOCKFILE_SCHEMA_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
READY_RE,
|
||||
REMOTE_LOCK_DIR,
|
||||
REMOTE_LOG,
|
||||
SUPPORTED_REMOTE_OS,
|
||||
buildSpawnCommand,
|
||||
cleanupStale,
|
||||
clientLockId,
|
||||
connect,
|
||||
fingerprintToken,
|
||||
locateHermes,
|
||||
lockfilePath,
|
||||
mintToken,
|
||||
pidIsOurDashboard,
|
||||
probeRemotePlatform,
|
||||
probeRemoteHermesHome,
|
||||
readLockfile,
|
||||
remotePidAlive,
|
||||
removeLockfile,
|
||||
scrapeReadyPort,
|
||||
shq,
|
||||
spawnRemoteDashboard,
|
||||
writeLockfile
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
/**
|
||||
* Tests for electron/remote-lifecycle.cjs.
|
||||
*
|
||||
* Run with: node --test electron/remote-lifecycle.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* Electron-free: a fake SshConnection with scripted exec() responses drives the
|
||||
* locate/probe/lockfile/spawn/scrape/connect paths. No real ssh, no real
|
||||
* dashboard.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
LOCKFILE_SCHEMA_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
buildSpawnCommand,
|
||||
cleanupStale,
|
||||
clientLockId,
|
||||
connect,
|
||||
fingerprintToken,
|
||||
locateHermes,
|
||||
lockfilePath,
|
||||
pidIsOurDashboard,
|
||||
probeRemotePlatform,
|
||||
readLockfile,
|
||||
remotePidAlive,
|
||||
scrapeReadyPort,
|
||||
spawnRemoteDashboard,
|
||||
writeLockfile
|
||||
} = require('./remote-lifecycle.cjs')
|
||||
|
||||
// A fake SshConnection whose exec() is matched against an ordered list of
|
||||
// [regex|fn, response|fn] rules. First match wins; unmatched commands return ''.
|
||||
function fakeSsh(rules = []) {
|
||||
const calls = []
|
||||
return {
|
||||
calls,
|
||||
async exec(cmd) {
|
||||
calls.push(cmd)
|
||||
for (const [matcher, resp] of rules) {
|
||||
const hit = typeof matcher === 'function' ? matcher(cmd) : matcher.test(cmd)
|
||||
if (hit) {
|
||||
const out = typeof resp === 'function' ? resp(cmd) : resp
|
||||
if (out instanceof Error) throw out
|
||||
return out
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- locateHermes -----------------------------------------------------------
|
||||
|
||||
test('locateHermes prefers the explicit profile path when executable', async () => {
|
||||
const ssh = fakeSsh([[/\[ -x .*\/opt\/hermes/, 'OK']])
|
||||
assert.equal(await locateHermes(ssh, '/opt/hermes'), '/opt/hermes')
|
||||
})
|
||||
|
||||
test('locateHermes falls back to the login-shell command -v probe', async () => {
|
||||
const ssh = fakeSsh([
|
||||
[/command -v hermes/, '/home/u/.local/bin/hermes\n'],
|
||||
[/\[ -x .*\.local\/bin\/hermes/, 'OK']
|
||||
])
|
||||
assert.equal(await locateHermes(ssh, ''), '/home/u/.local/bin/hermes')
|
||||
})
|
||||
|
||||
test('locateHermes tries the conventional venv path last', async () => {
|
||||
const ssh = fakeSsh([[/\[ -x .*venv\/bin\/hermes/, 'OK']])
|
||||
assert.equal(await locateHermes(ssh, ''), '~/.hermes/hermes-agent/venv/bin/hermes')
|
||||
})
|
||||
|
||||
test('locateHermes throws a hermes-not-found error with an install hint', async () => {
|
||||
const ssh = fakeSsh([]) // nothing is executable
|
||||
await assert.rejects(() => locateHermes(ssh, ''), err => {
|
||||
assert.equal(err.kind, 'hermes-not-found')
|
||||
assert.match(err.message, /install/i)
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('locateHermes uses a login shell for the command -v probe', async () => {
|
||||
const ssh = fakeSsh([[/command -v hermes/, '/x/hermes'], [/\[ -x/, 'OK']])
|
||||
await locateHermes(ssh, '')
|
||||
assert.ok(ssh.calls.some(c => /bash -lc/.test(c)), 'must probe in a login shell (PATH pitfall)')
|
||||
})
|
||||
|
||||
// --- probeRemotePlatform ----------------------------------------------------
|
||||
|
||||
test('probeRemotePlatform accepts Linux and macOS', async () => {
|
||||
assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Linux\nx86_64']])), {
|
||||
os: 'Linux',
|
||||
arch: 'x86_64'
|
||||
})
|
||||
assert.deepEqual(await probeRemotePlatform(fakeSsh([[/uname/, 'Darwin\narm64']])), {
|
||||
os: 'Darwin',
|
||||
arch: 'arm64'
|
||||
})
|
||||
})
|
||||
|
||||
test('probeRemotePlatform rejects unsupported remote platforms', async () => {
|
||||
await assert.rejects(() => probeRemotePlatform(fakeSsh([[/uname/, 'MINGW64_NT\nx86_64']])), err => {
|
||||
assert.equal(err.kind, 'unsupported-platform')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// --- lockfile ---------------------------------------------------------------
|
||||
|
||||
test('clientLockId sanitizes and bounds the id', () => {
|
||||
assert.equal(clientLockId('a/b c'), 'a_b_c')
|
||||
assert.equal(clientLockId(''), 'default')
|
||||
assert.ok(clientLockId('x'.repeat(200)).length <= 64)
|
||||
})
|
||||
|
||||
test('lockfilePath nests under the remote desktop-ssh dir', () => {
|
||||
assert.match(lockfilePath('client1'), /\.hermes\/desktop-ssh\/client1\.lock\.json$/)
|
||||
})
|
||||
|
||||
test('readLockfile returns null for missing, empty, malformed, or wrong-schema', async () => {
|
||||
assert.equal(await readLockfile(fakeSsh([[/cat/, '']]), 'c'), null)
|
||||
assert.equal(await readLockfile(fakeSsh([[/cat/, 'not json']]), 'c'), null)
|
||||
assert.equal(await readLockfile(fakeSsh([[/cat/, JSON.stringify({ schemaVersion: 999 })]]), 'c'), null)
|
||||
const good = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 1, port: 2 }
|
||||
assert.deepEqual(await readLockfile(fakeSsh([[/cat/, JSON.stringify(good)]]), 'c'), good)
|
||||
})
|
||||
|
||||
test('writeLockfile mkdir -ps and stamps the schema version', async () => {
|
||||
const ssh = fakeSsh([])
|
||||
await writeLockfile(ssh, 'c', { pid: 7, port: 9 })
|
||||
const cmd = ssh.calls.join('\n')
|
||||
assert.match(cmd, /mkdir -p/)
|
||||
assert.match(cmd, new RegExp(`"schemaVersion":${LOCKFILE_SCHEMA_VERSION}`))
|
||||
})
|
||||
|
||||
test('remotePidAlive maps kill -0 ALIVE/DEAD', async () => {
|
||||
assert.equal(await remotePidAlive(fakeSsh([[/kill -0/, 'ALIVE']]), 123), true)
|
||||
assert.equal(await remotePidAlive(fakeSsh([[/kill -0/, 'DEAD']]), 123), false)
|
||||
assert.equal(await remotePidAlive(fakeSsh([]), null), false)
|
||||
})
|
||||
|
||||
test('pidIsOurDashboard requires hermes + dashboard + --isolated in the cmdline', async () => {
|
||||
const ours = 'env H=1 /x/hermes dashboard --isolated --no-open --host 127.0.0.1 --port 0'
|
||||
assert.equal(await pidIsOurDashboard(fakeSsh([[/cmdline|ps -o/, ours]]), 5), true)
|
||||
// a different hermes process (gateway) is NOT ours to kill
|
||||
assert.equal(await pidIsOurDashboard(fakeSsh([[/cmdline|ps -o/, '/x/hermes gateway']]), 5), false)
|
||||
// an unrelated process is never ours
|
||||
assert.equal(await pidIsOurDashboard(fakeSsh([[/cmdline|ps -o/, 'sshd: u@pts/0']]), 5), false)
|
||||
})
|
||||
|
||||
test('cleanupStale kills ONLY a provably-ours pid, always drops the lockfile', async () => {
|
||||
// not ours → no kill, lockfile removed
|
||||
const notOurs = fakeSsh([[/cmdline|ps -o/, '/x/hermes gateway']])
|
||||
await cleanupStale(notOurs, 'c', 5)
|
||||
assert.ok(!notOurs.calls.some(c => /kill 5\b/.test(c)), 'must not kill a pid that is not our dashboard')
|
||||
assert.ok(notOurs.calls.some(c => /rm -f/.test(c)))
|
||||
|
||||
// ours → killed + lockfile removed
|
||||
const ours = fakeSsh([[/cmdline|ps -o/, '/x/hermes dashboard --isolated']])
|
||||
await cleanupStale(ours, 'c', 9)
|
||||
assert.ok(ours.calls.some(c => /kill 9\b/.test(c)))
|
||||
assert.ok(ours.calls.some(c => /rm -f/.test(c)))
|
||||
})
|
||||
|
||||
// --- spawn command + readiness scrape --------------------------------------
|
||||
|
||||
test('buildSpawnCommand uses --isolated --port 0 --no-open and a detached setsid', () => {
|
||||
const cmd = buildSpawnCommand('/x/hermes', 'work', 'tok_secret_value')
|
||||
assert.match(cmd, /--isolated/)
|
||||
assert.match(cmd, /--no-open/)
|
||||
assert.match(cmd, /--host 127\.0\.0\.1 --port 0/)
|
||||
assert.match(cmd, /--skip-build/)
|
||||
assert.match(cmd, /--profile/)
|
||||
assert.match(cmd, /work/)
|
||||
assert.match(cmd, /setsid/)
|
||||
assert.match(cmd, /<\/dev\/null/)
|
||||
assert.match(cmd, /echo \$!/)
|
||||
})
|
||||
|
||||
test('spawnRemoteDashboard writes a sentinel then returns the echoed pid', async () => {
|
||||
const ssh = fakeSsh([
|
||||
[/printf '%s\\\\n'/, ''], // sentinel write
|
||||
[/setsid/, '4242\n'] // spawn → pid
|
||||
])
|
||||
const { pid, sentinel } = await spawnRemoteDashboard(ssh, { hermesPath: '/x/hermes', profile: '', token: 'tk' })
|
||||
assert.equal(pid, 4242)
|
||||
assert.match(sentinel, /^HERMES_SSH_SPAWN_/)
|
||||
})
|
||||
|
||||
test('spawnRemoteDashboard rejects when no pid is returned', async () => {
|
||||
const ssh = fakeSsh([[/setsid/, 'not-a-pid']])
|
||||
await assert.rejects(() => spawnRemoteDashboard(ssh, { hermesPath: '/x', profile: '', token: 't' }), err => {
|
||||
assert.equal(err.kind, 'spawn-failed')
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('scrapeReadyPort parses the READY line that follows the sentinel', async () => {
|
||||
const ssh = fakeSsh([[/awk/, 'some noise\nHERMES_DASHBOARD_READY port=51234\n']])
|
||||
const port = await scrapeReadyPort(ssh, 'SENT', { timeoutMs: 1000 })
|
||||
assert.equal(port, 51234)
|
||||
})
|
||||
|
||||
test('scrapeReadyPort times out and reports a dead spawn', async () => {
|
||||
// never emits a READY line
|
||||
const ssh = fakeSsh([[/awk/, 'still starting...']])
|
||||
await assert.rejects(() => scrapeReadyPort(ssh, 'SENT', { timeoutMs: 60 }), err => {
|
||||
assert.equal(err.kind, 'ready-timeout')
|
||||
return true
|
||||
})
|
||||
// dead process before announcement → spawn-failed
|
||||
await assert.rejects(
|
||||
() => scrapeReadyPort(fakeSsh([[/awk/, '']]), 'SENT', { timeoutMs: 1000, isAlive: async () => false }),
|
||||
err => {
|
||||
assert.equal(err.kind, 'spawn-failed')
|
||||
return true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// --- connect() orchestration ------------------------------------------------
|
||||
|
||||
function connectDeps(ssh, over = {}) {
|
||||
return {
|
||||
ssh,
|
||||
clientId: 'client1',
|
||||
profile: '',
|
||||
forward: async () => {},
|
||||
cancelForward: async () => {},
|
||||
pickLocalPort: async () => 50001,
|
||||
waitForHermes: async () => {},
|
||||
probeStatus: async () => true,
|
||||
adoptServedToken: async (_baseUrl, spawn) => spawn || 'served-token',
|
||||
rememberLog: () => {},
|
||||
readyTimeoutMs: 2000,
|
||||
...over
|
||||
}
|
||||
}
|
||||
|
||||
test('connect() spawns fresh when there is no lockfile, adopts the served token', async () => {
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, ''], // no lockfile
|
||||
[/printf '%s\\\\n'/, ''],
|
||||
[/setsid/, '777\n'],
|
||||
[/kill -0 777/, 'ALIVE'],
|
||||
[/awk/, 'HERMES_DASHBOARD_READY port=51999\n']
|
||||
])
|
||||
const result = await connect(connectDeps(ssh, { adoptServedToken: async () => 'the-served-token' }))
|
||||
assert.equal(result.reused, false)
|
||||
assert.equal(result.remotePort, 51999)
|
||||
assert.equal(result.localPort, 50001)
|
||||
assert.equal(result.pid, 777)
|
||||
assert.equal(result.token, 'the-served-token')
|
||||
assert.equal(result.baseUrl, 'http://127.0.0.1:50001')
|
||||
assert.equal(result.tokenFingerprint, fingerprintToken('the-served-token'))
|
||||
})
|
||||
|
||||
test('connect() reuses a healthy dashboard when fingerprint + probe pass', async () => {
|
||||
const reuseToken = 'stored-token'
|
||||
const lock = {
|
||||
schemaVersion: LOCKFILE_SCHEMA_VERSION,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
pid: 333,
|
||||
port: 40000,
|
||||
tokenFingerprint: fingerprintToken(reuseToken)
|
||||
}
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, JSON.stringify(lock)],
|
||||
[/kill -0/, 'ALIVE']
|
||||
])
|
||||
const result = await connect(
|
||||
connectDeps(ssh, { reuseToken, adoptServedToken: async (_b, t) => t })
|
||||
)
|
||||
assert.equal(result.reused, true)
|
||||
assert.equal(result.pid, 333)
|
||||
assert.equal(result.remotePort, 40000)
|
||||
// never spawned
|
||||
assert.ok(!ssh.calls.some(c => /setsid/.test(c)), 'reuse path must not spawn a new dashboard')
|
||||
})
|
||||
|
||||
test('connect() respawns when the lockfile protocolVersion is incompatible', async () => {
|
||||
const reuseToken = 'stored-token'
|
||||
// alive pid, matching fingerprint, but a protocolVersion we no longer accept
|
||||
const lock = {
|
||||
schemaVersion: LOCKFILE_SCHEMA_VERSION,
|
||||
protocolVersion: PROTOCOL_VERSION + 99,
|
||||
pid: 333,
|
||||
port: 40000,
|
||||
tokenFingerprint: fingerprintToken(reuseToken)
|
||||
}
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, JSON.stringify(lock)],
|
||||
[/kill -0 333/, 'ALIVE'],
|
||||
[/cmdline|ps -o/, ''], // not provably ours → not killed, lockfile dropped
|
||||
[/setsid/, '901\n'],
|
||||
[/kill -0 901/, 'ALIVE'],
|
||||
[/awk/, 'HERMES_DASHBOARD_READY port=44100\n']
|
||||
])
|
||||
const result = await connect(connectDeps(ssh, { reuseToken, adoptServedToken: async () => 'fresh' }))
|
||||
assert.equal(result.reused, false, 'incompatible protocol must force a fresh spawn, not a reattach')
|
||||
assert.equal(result.pid, 901)
|
||||
})
|
||||
|
||||
test('connect() fresh spawn writes hermesHome + protocolVersion into the lockfile', async () => {
|
||||
const writes = []
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, ''], // no lockfile
|
||||
[/HERMES_HOME/, '/home/jonny/.hermes\n'], // probeRemoteHermesHome
|
||||
[/printf '%s\\\\n'/, ''],
|
||||
[/setsid/, '700\n'],
|
||||
[/kill -0 700/, 'ALIVE'],
|
||||
[/awk/, 'HERMES_DASHBOARD_READY port=45500\n'],
|
||||
[/printf '%s' '/, c => { writes.push(c); return '' }] // writeLockfile printf
|
||||
])
|
||||
await connect(connectDeps(ssh, { adoptServedToken: async () => 'fresh' }))
|
||||
const lockWrite = writes.find(c => c.includes('schemaVersion')) || ''
|
||||
assert.match(lockWrite, new RegExp(`"protocolVersion":${PROTOCOL_VERSION}`))
|
||||
assert.match(lockWrite, /"hermesHome":"\/home\/jonny\/\.hermes"/)
|
||||
})
|
||||
|
||||
test('connect() respawns when the lockfile pid is dead (killed dashboard)', async () => {
|
||||
const lock = { schemaVersion: LOCKFILE_SCHEMA_VERSION, pid: 333, port: 40000, tokenFingerprint: fingerprintToken('t') }
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, JSON.stringify(lock)],
|
||||
[/kill -0 333/, 'DEAD'],
|
||||
[/cmdline|ps -o/, ''], // not provably ours
|
||||
[/setsid/, '888\n'],
|
||||
[/kill -0 888/, 'ALIVE'],
|
||||
[/awk/, 'HERMES_DASHBOARD_READY port=42000\n']
|
||||
])
|
||||
const result = await connect(connectDeps(ssh, { reuseToken: 't', adoptServedToken: async () => 'fresh' }))
|
||||
assert.equal(result.reused, false)
|
||||
assert.equal(result.pid, 888)
|
||||
assert.equal(result.remotePort, 42000)
|
||||
})
|
||||
|
||||
test('connect() respawns when the dashboard is wedged (alive pid, probe fails)', async () => {
|
||||
const reuseToken = 'stored'
|
||||
const lock = {
|
||||
schemaVersion: LOCKFILE_SCHEMA_VERSION,
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
pid: 333,
|
||||
port: 40000,
|
||||
tokenFingerprint: fingerprintToken(reuseToken)
|
||||
}
|
||||
const ssh = fakeSsh([
|
||||
[/uname/, 'Linux\nx86_64'],
|
||||
[/\[ -x/, 'OK'],
|
||||
[/cat .*lock\.json/, JSON.stringify(lock)],
|
||||
[/kill -0/, 'ALIVE'],
|
||||
[/cmdline|ps -o/, '/x/hermes dashboard --isolated'], // ours → may kill
|
||||
[/setsid/, '999\n'],
|
||||
[/kill -0 999/, 'ALIVE'],
|
||||
[/awk/, 'HERMES_DASHBOARD_READY port=43000\n']
|
||||
])
|
||||
// probeStatus FAILS for the wedged dashboard → must respawn
|
||||
const result = await connect(
|
||||
connectDeps(ssh, { reuseToken, probeStatus: async () => false, adoptServedToken: async () => 'fresh' })
|
||||
)
|
||||
assert.equal(result.reused, false)
|
||||
assert.equal(result.pid, 999)
|
||||
assert.equal(result.remotePort, 43000)
|
||||
})
|
||||
|
||||
test('connect() aborts on an unsupported remote platform before doing anything else', async () => {
|
||||
const ssh = fakeSsh([[/uname/, 'SunOS\nsun4v']])
|
||||
await assert.rejects(() => connect(connectDeps(ssh)), err => {
|
||||
assert.equal(err.kind, 'unsupported-platform')
|
||||
return true
|
||||
})
|
||||
assert.ok(!ssh.calls.some(c => /setsid/.test(c)))
|
||||
})
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* ssh-config.cjs
|
||||
*
|
||||
* Pure, electron-free helpers for reading the user's OpenSSH client config:
|
||||
* - parseSshConfigHosts(text): extract concrete `Host` aliases for the
|
||||
* settings UI's host suggestions, filtering wildcard/negated patterns.
|
||||
* - collectSshConfigHosts(rootPath, deps): read ~/.ssh/config and follow
|
||||
* `Include` directives (read-only — we NEVER write that file).
|
||||
* - parseSshGOutput(text): parse `ssh -G <host>` key/value output into the
|
||||
* resolved hostname/user/port/identityfile for display + normalization.
|
||||
*
|
||||
* Kept standalone (no `require('electron')`) so it can be unit-tested with
|
||||
* `node --test`. main.cjs requires this and wires the fs + `ssh -G` exec in.
|
||||
*/
|
||||
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
// Pull concrete host aliases out of an ssh_config body. A `Host` line can list
|
||||
// several patterns; we keep only literal aliases (no `*`, `?`, or `!` negation)
|
||||
// since those are the ones a user can actually connect to by name.
|
||||
function parseSshConfigHosts(text) {
|
||||
const hosts = []
|
||||
const seen = new Set()
|
||||
for (const rawLine of String(text || '').split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
if (!line || line.startsWith('#')) continue
|
||||
const m = /^host\s+(.+)$/i.exec(line)
|
||||
if (!m) continue
|
||||
for (const pattern of m[1].split(/\s+/)) {
|
||||
if (!pattern || pattern.includes('*') || pattern.includes('?') || pattern.startsWith('!')) {
|
||||
continue
|
||||
}
|
||||
if (!seen.has(pattern)) {
|
||||
seen.add(pattern)
|
||||
hosts.push(pattern)
|
||||
}
|
||||
}
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
// Extract `Include` paths from an ssh_config body (relative paths resolve under
|
||||
// ~/.ssh). Globs are expanded by the caller's fs deps when supported; here we
|
||||
// just return the raw tokens for the collector to resolve.
|
||||
function parseSshConfigIncludes(text) {
|
||||
const includes = []
|
||||
for (const rawLine of String(text || '').split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
if (!line || line.startsWith('#')) continue
|
||||
const m = /^include\s+(.+)$/i.exec(line)
|
||||
if (!m) continue
|
||||
for (const token of m[1].split(/\s+/)) {
|
||||
if (token) includes.push(token)
|
||||
}
|
||||
}
|
||||
return includes
|
||||
}
|
||||
|
||||
// Read ~/.ssh/config and any files it Includes, returning a de-duplicated list
|
||||
// of concrete host aliases. Read-only; bounded include depth to avoid cycles.
|
||||
// `deps` injects { readFile, homeDir, globSync } for tests.
|
||||
function collectSshConfigHosts(rootPath, deps = {}) {
|
||||
const readFile =
|
||||
deps.readFile ||
|
||||
(p => {
|
||||
try {
|
||||
return fs.readFileSync(p, 'utf8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
const homeDir = deps.homeDir || os.homedir()
|
||||
const root = rootPath || path.join(homeDir, '.ssh', 'config')
|
||||
const sshDir = path.join(homeDir, '.ssh')
|
||||
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
const visited = new Set()
|
||||
|
||||
const resolveIncludePath = token => {
|
||||
if (token.startsWith('~/')) return path.join(homeDir, token.slice(2))
|
||||
if (path.isAbsolute(token)) return token
|
||||
return path.join(sshDir, token)
|
||||
}
|
||||
|
||||
const walk = (filePath, depth) => {
|
||||
if (depth > 8 || visited.has(filePath)) return
|
||||
visited.add(filePath)
|
||||
const text = readFile(filePath)
|
||||
if (text == null) return
|
||||
for (const host of parseSshConfigHosts(text)) {
|
||||
if (!seen.has(host)) {
|
||||
seen.add(host)
|
||||
out.push(host)
|
||||
}
|
||||
}
|
||||
for (const token of parseSshConfigIncludes(text)) {
|
||||
const target = resolveIncludePath(token)
|
||||
// Optional glob expansion (token may contain * — e.g. config.d/*).
|
||||
const expanded = deps.globSync ? deps.globSync(target) : [target]
|
||||
for (const p of expanded) {
|
||||
walk(p, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
// Parse `ssh -G <host>` output. Keys are lowercased by ssh; we surface the ones
|
||||
// the settings UI cares about. Returns { hostname, user, port, identityFile }.
|
||||
function parseSshGOutput(text) {
|
||||
const out = { hostname: null, user: null, port: null, identityFile: null }
|
||||
for (const rawLine of String(text || '').split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
if (!line) continue
|
||||
const sp = line.indexOf(' ')
|
||||
if (sp === -1) continue
|
||||
const key = line.slice(0, sp).toLowerCase()
|
||||
const value = line.slice(sp + 1).trim()
|
||||
if (key === 'hostname' && !out.hostname) out.hostname = value
|
||||
else if (key === 'user' && !out.user) out.user = value
|
||||
else if (key === 'port' && !out.port) out.port = Number.parseInt(value, 10) || null
|
||||
else if (key === 'identityfile' && !out.identityFile) out.identityFile = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectSshConfigHosts,
|
||||
parseSshConfigHosts,
|
||||
parseSshConfigIncludes,
|
||||
parseSshGOutput
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* Tests for electron/ssh-config.cjs.
|
||||
*
|
||||
* Run with: node --test electron/ssh-config.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
collectSshConfigHosts,
|
||||
parseSshConfigHosts,
|
||||
parseSshConfigIncludes,
|
||||
parseSshGOutput
|
||||
} = require('./ssh-config.cjs')
|
||||
|
||||
test('parseSshConfigHosts keeps literal aliases and drops wildcard/negated patterns', () => {
|
||||
const cfg = [
|
||||
'Host mac-mini',
|
||||
' HostName 10.0.0.5',
|
||||
'Host *.internal prod !staging glob*',
|
||||
'Host alpha beta',
|
||||
'# Host commented-out',
|
||||
'host lower-case'
|
||||
].join('\n')
|
||||
assert.deepEqual(parseSshConfigHosts(cfg), ['mac-mini', 'prod', 'alpha', 'beta', 'lower-case'])
|
||||
})
|
||||
|
||||
test('parseSshConfigHosts de-duplicates', () => {
|
||||
assert.deepEqual(parseSshConfigHosts('Host box\nHost box\nHost box other'), ['box', 'other'])
|
||||
})
|
||||
|
||||
test('parseSshConfigIncludes extracts include tokens', () => {
|
||||
const cfg = 'Include ~/.ssh/config.d/*\nInclude work_hosts personal_hosts\n# Include ignored'
|
||||
assert.deepEqual(parseSshConfigIncludes(cfg), ['~/.ssh/config.d/*', 'work_hosts', 'personal_hosts'])
|
||||
})
|
||||
|
||||
test('collectSshConfigHosts follows Include directives (read-only)', () => {
|
||||
const files = {
|
||||
'/home/u/.ssh/config': 'Host main\nInclude work\nInclude ~/abs_inc',
|
||||
'/home/u/.ssh/work': 'Host work-box\nInclude nested',
|
||||
'/home/u/.ssh/nested': 'Host deep',
|
||||
'/home/u/abs_inc': 'Host home-abs'
|
||||
}
|
||||
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
|
||||
homeDir: '/home/u',
|
||||
readFile: p => files[p] ?? null
|
||||
})
|
||||
assert.deepEqual(hosts.sort(), ['deep', 'home-abs', 'main', 'work-box'].sort())
|
||||
})
|
||||
|
||||
test('collectSshConfigHosts tolerates a missing config file', () => {
|
||||
assert.deepEqual(collectSshConfigHosts('/nope/config', { homeDir: '/home/u', readFile: () => null }), [])
|
||||
})
|
||||
|
||||
test('collectSshConfigHosts does not loop on a self-include cycle', () => {
|
||||
const files = {
|
||||
'/home/u/.ssh/config': 'Host a\nInclude loop',
|
||||
'/home/u/.ssh/loop': 'Host b\nInclude config' // points back at config
|
||||
}
|
||||
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
|
||||
homeDir: '/home/u',
|
||||
readFile: p => files[p] ?? null
|
||||
})
|
||||
assert.deepEqual(hosts.sort(), ['a', 'b'])
|
||||
})
|
||||
|
||||
test('collectSshConfigHosts expands globbed includes via injected globSync', () => {
|
||||
const files = {
|
||||
'/home/u/.ssh/config': 'Host root\nInclude config.d/*',
|
||||
'/home/u/.ssh/config.d/10-work': 'Host work',
|
||||
'/home/u/.ssh/config.d/20-home': 'Host home'
|
||||
}
|
||||
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
|
||||
homeDir: '/home/u',
|
||||
readFile: p => files[p] ?? null,
|
||||
globSync: pattern =>
|
||||
pattern.endsWith('config.d/*') ? ['/home/u/.ssh/config.d/10-work', '/home/u/.ssh/config.d/20-home'] : [pattern]
|
||||
})
|
||||
assert.deepEqual(hosts.sort(), ['home', 'root', 'work'].sort())
|
||||
})
|
||||
|
||||
test('parseSshGOutput pulls hostname/user/port/identityfile', () => {
|
||||
const out = [
|
||||
'host mac-mini',
|
||||
'hostname 10.0.0.5',
|
||||
'user jonny',
|
||||
'port 2222',
|
||||
'identityfile ~/.ssh/id_ed25519',
|
||||
'forwardagent no'
|
||||
].join('\n')
|
||||
assert.deepEqual(parseSshGOutput(out), {
|
||||
hostname: '10.0.0.5',
|
||||
user: 'jonny',
|
||||
port: 2222,
|
||||
identityFile: '~/.ssh/id_ed25519'
|
||||
})
|
||||
})
|
||||
|
||||
test('parseSshGOutput takes the FIRST identityfile and tolerates missing keys', () => {
|
||||
const out = 'hostname box\nidentityfile ~/.ssh/a\nidentityfile ~/.ssh/b'
|
||||
const parsed = parseSshGOutput(out)
|
||||
assert.equal(parsed.identityFile, '~/.ssh/a')
|
||||
assert.equal(parsed.user, null)
|
||||
assert.equal(parsed.port, null)
|
||||
})
|
||||
@@ -1,514 +0,0 @@
|
||||
/**
|
||||
* ssh-connection.cjs
|
||||
*
|
||||
* Pure, electron-free OpenSSH ControlMaster connection manager for Desktop SSH
|
||||
* remote mode. Uses the system `ssh` client (not a JS SSH library) so it
|
||||
* inherits ~/.ssh/config, the agent, jump hosts (ProxyJump), and hardware keys
|
||||
* for free — the same rationale as tools/environments/ssh.py.
|
||||
*
|
||||
* Kept standalone (no `require('electron')`) so it can be unit-tested with
|
||||
* `node --test` — same pattern as connection-config.cjs / dashboard-token.cjs.
|
||||
* main.cjs requires this and wires it into the electron-coupled lifecycle.
|
||||
*
|
||||
* Conventions mirrored from tools/environments/ssh.py:
|
||||
* - ControlMaster=auto + ControlPersist so one TCP/auth handshake is reused
|
||||
* across exec/forward operations.
|
||||
* - Hashed control-socket filename under a short tmpdir to stay under the
|
||||
* 104-byte sun_path limit macOS enforces on Unix domain sockets
|
||||
* (ssh.py:53-67 rationale applies verbatim).
|
||||
* - BatchMode=yes for every programmatic invocation — a spawned ssh must
|
||||
* never hang on an interactive prompt (passphrase / 2FA). If auth needs
|
||||
* interactivity we fail fast and tell the user to load the key into their
|
||||
* agent.
|
||||
*
|
||||
* Host-key policy: StrictHostKeyChecking=accept-new (trust-on-first-use, log
|
||||
* the fingerprint), never `no`. A host-key *change* fails closed with the
|
||||
* verbatim OpenSSH error surfaced to the UI.
|
||||
*
|
||||
* Every operation is raced against a hard timeout. A half-open TCP connection
|
||||
* after laptop sleep can leave ssh hanging indefinitely rather than erroring;
|
||||
* timeout is treated as connection-dead so the caller does a full reconnect
|
||||
* rather than retrying in place (VS Code's agent host does the same).
|
||||
*/
|
||||
|
||||
const { spawn } = require('node:child_process')
|
||||
const crypto = require('node:crypto')
|
||||
const net = require('node:net')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
|
||||
const DEFAULT_EXEC_TIMEOUT_MS = 20_000
|
||||
const DEFAULT_FORWARD_TIMEOUT_MS = 15_000
|
||||
const CONTROL_PERSIST_SECONDS = 300
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token / secret redaction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Every lifecycle log line in SSH mode passes through this before it reaches
|
||||
// rememberLog/desktop.log. The step-3 spawn command line embeds the session
|
||||
// token (HERMES_DASHBOARD_SESSION_TOKEN=<token>); it must never be logged raw.
|
||||
// We also scrub the URL/header carriers the dashboard protocol uses so a
|
||||
// forwarded base URL or a copied curl line can't leak a credential.
|
||||
//
|
||||
// Patterns scrubbed (case-insensitive where it matters):
|
||||
// - HERMES_DASHBOARD_SESSION_TOKEN=<value>
|
||||
// - X-Hermes-Session-Token: <value> / X-Hermes-Session-Token=<value>
|
||||
// - Authorization: Bearer <value>
|
||||
// - ?token=<value> / &token=<value> (the WS auth param)
|
||||
// - ?ticket=<value> / &ticket=<value> (the OAuth ws-ticket param)
|
||||
const _REDACTIONS = [
|
||||
[/(HERMES_DASHBOARD_SESSION_TOKEN=)(\S+)/g, '$1<redacted>'],
|
||||
[/(X-Hermes-Session-Token["']?\s*[:=]\s*["']?)([^\s"'&]+)/gi, '$1<redacted>'],
|
||||
[/(Authorization["']?\s*:\s*Bearer\s+)(\S+)/gi, '$1<redacted>'],
|
||||
[/([?&](?:token|ticket)=)([^\s&"']+)/gi, '$1<redacted>']
|
||||
]
|
||||
|
||||
function redactSecrets(text) {
|
||||
let out = String(text == null ? '' : text)
|
||||
for (const [re, repl] of _REDACTIONS) {
|
||||
out = out.replace(re, repl)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Control-socket path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Hash user@host:port to a short, stable, filesystem-safe socket id. Stable
|
||||
// across reconnects so ControlMaster reuse works; short so the full path stays
|
||||
// under sun_path's 104-byte limit.
|
||||
//
|
||||
// CRITICAL (macOS): the base dir must be SHORT. os.tmpdir() on macOS is the
|
||||
// per-user `/var/folders/xx/yyyy…/T/` (~49 bytes), and OpenSSH binds a
|
||||
// TEMPORARY listener at `<ControlPath>.<16 random chars>` (a 17-byte suffix)
|
||||
// while establishing the master — so a path that itself fits 104 still overflows
|
||||
// at bind time with `unix_listener: path "…" too long`. We root under a short
|
||||
// per-user base (`~/.hermes/desktop-ssh`) so even worst case
|
||||
// (~/.hermes/desktop-ssh = ~33 on macOS + 1 + 16 + 5 + 17 ≈ 72) stays clear.
|
||||
// Windows has no AF_UNIX sun_path limit, so os.tmpdir() is fine there. ssh.py
|
||||
// uses gettempdir() and would hit this on macOS — deliberate divergence.
|
||||
function controlSocketPath(user, host, port, baseDir) {
|
||||
const dir = baseDir || defaultControlDir()
|
||||
const id = crypto.createHash('sha256').update(`${user}@${host}:${port}`).digest('hex').slice(0, 16)
|
||||
return path.join(dir, `${id}.sock`)
|
||||
}
|
||||
|
||||
function defaultControlDir() {
|
||||
// Windows: AF_UNIX has no sun_path length limit → the per-user temp dir is
|
||||
// fine. POSIX (macOS/Linux): a SHORT, PER-USER base — ~/.hermes/desktop-ssh —
|
||||
// stays under the 104-byte socket limit AND avoids a world-shared /tmp dir
|
||||
// (no foreign-owned-dir or symlink-hijack surface). Created 0700 in open().
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(os.tmpdir(), 'hermes-desktop-ssh')
|
||||
}
|
||||
return path.join(os.homedir(), '.hermes', 'desktop-ssh')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command construction (pure — the unit tests exercise these directly)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function baseSshOptions(controlPath, connectTimeoutMs) {
|
||||
const connectSecs = Math.max(1, Math.round((connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS) / 1000))
|
||||
return [
|
||||
'-o', `ControlPath=${controlPath}`,
|
||||
'-o', 'ControlMaster=auto',
|
||||
'-o', `ControlPersist=${CONTROL_PERSIST_SECONDS}`,
|
||||
'-o', 'BatchMode=yes',
|
||||
'-o', 'StrictHostKeyChecking=accept-new',
|
||||
'-o', `ConnectTimeout=${connectSecs}`
|
||||
]
|
||||
}
|
||||
|
||||
// Per-host args shared by exec, the master open, and forward control commands:
|
||||
// non-default port and explicit identity file.
|
||||
function hostArgs({ port, keyPath }) {
|
||||
const args = []
|
||||
if (port && Number(port) !== 22) {
|
||||
args.push('-p', String(port))
|
||||
}
|
||||
if (keyPath) {
|
||||
args.push('-i', keyPath)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
function target(user, host) {
|
||||
return user ? `${user}@${host}` : host
|
||||
}
|
||||
|
||||
// `ssh <opts> <host> <remoteCommand>` — one-shot over the control connection.
|
||||
function buildExecArgs(conn, remoteCommand, connectTimeoutMs) {
|
||||
return [
|
||||
...baseSshOptions(conn.controlPath, connectTimeoutMs),
|
||||
...hostArgs(conn),
|
||||
target(conn.user, conn.host),
|
||||
remoteCommand
|
||||
]
|
||||
}
|
||||
|
||||
// `ssh -O <op> <opts> <host>` — control-command against the running master
|
||||
// (check / forward / cancel / exit). -O commands don't take a remote command.
|
||||
function buildControlArgs(conn, op, extra = [], connectTimeoutMs) {
|
||||
return [
|
||||
'-O', op,
|
||||
...extra,
|
||||
...baseSshOptions(conn.controlPath, connectTimeoutMs),
|
||||
...hostArgs(conn),
|
||||
target(conn.user, conn.host)
|
||||
]
|
||||
}
|
||||
|
||||
// Open the master explicitly: `-M -N -f` puts ssh into the background once the
|
||||
// master is up, so the spawn resolves when the connection is established (or
|
||||
// fails fast under BatchMode if auth is non-interactive-only).
|
||||
function buildMasterArgs(conn, connectTimeoutMs) {
|
||||
return [
|
||||
'-M', '-N', '-f',
|
||||
...baseSshOptions(conn.controlPath, connectTimeoutMs),
|
||||
...hostArgs(conn),
|
||||
target(conn.user, conn.host)
|
||||
]
|
||||
}
|
||||
|
||||
// Interactive `ssh -tt` for the INTERIM remote terminal (component 5, SSH mode
|
||||
// only). Reuses the existing ControlMaster socket so NO new auth handshake
|
||||
// happens — the master is already open, so this attaches instantly and never
|
||||
// prompts (BatchMode stays safe here for that reason). `-tt` forces a PTY even
|
||||
// though our stdio is a node-pty, so the remote sees a real terminal.
|
||||
//
|
||||
// When a remoteCwd is given we cd into it (best-effort) then exec the user's
|
||||
// login shell so the prompt/rc files load; an unreadable cwd falls back to
|
||||
// $HOME rather than failing the session.
|
||||
//
|
||||
// NOTE (tracked): this is the interim path until the dashboard /api/terminal
|
||||
// WebSocket lands (specs/desktop-remote-terminal.md). Once that ships, the
|
||||
// terminal rides the tunnel like every other socket and cwd-follows-session
|
||||
// behavior becomes uniform; delete this path then.
|
||||
function buildInteractiveSshArgs(conn, remoteCwd, connectTimeoutMs) {
|
||||
const args = [
|
||||
'-tt',
|
||||
...baseSshOptions(conn.controlPath, connectTimeoutMs),
|
||||
...hostArgs(conn),
|
||||
target(conn.user, conn.host)
|
||||
]
|
||||
const cwd = String(remoteCwd || '').trim()
|
||||
if (cwd) {
|
||||
// cd then exec a login shell; quote the path; tolerate a missing dir.
|
||||
const q = `'${cwd.replace(/'/g, `'\\''`)}'`
|
||||
args.push(`cd ${q} 2>/dev/null; exec "$SHELL" -l`)
|
||||
} else {
|
||||
args.push('exec "$SHELL" -l')
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// Local forward spec for `-O forward -L <local>:<remoteHost>:<remotePort>`.
|
||||
// Bind the local end to 127.0.0.1 ONLY — never 0.0.0.0 — so the tunnel does
|
||||
// not re-expose the remote dashboard to the client's LAN.
|
||||
function forwardSpec(localPort, remotePort, remoteHost = '127.0.0.1') {
|
||||
return `127.0.0.1:${localPort}:${remoteHost}:${remotePort}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error classification — distinct, actionable messages for the UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SSH_ERROR = {
|
||||
UNREACHABLE: 'unreachable',
|
||||
AUTH_FAILED: 'auth-failed',
|
||||
HOST_KEY_CHANGED: 'host-key-changed',
|
||||
TIMEOUT: 'timeout',
|
||||
UNKNOWN: 'unknown'
|
||||
}
|
||||
|
||||
// Map raw ssh stderr to a stable error kind. Order matters: the host-key-change
|
||||
// banner also contains "WARNING"/"Offending", check it before generic auth.
|
||||
function classifySshError(stderr) {
|
||||
const text = String(stderr || '')
|
||||
if (/REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|Offending (?:key|ECDSA|RSA|ED25519)/i.test(text)) {
|
||||
return SSH_ERROR.HOST_KEY_CHANGED
|
||||
}
|
||||
if (/Permission denied|Too many authentication failures|no matching host key|publickey|password|keyboard-interactive/i.test(text)) {
|
||||
return SSH_ERROR.AUTH_FAILED
|
||||
}
|
||||
if (/Could not resolve hostname|Connection refused|Connection timed out|No route to host|Network is unreachable|Operation timed out|port \d+: Connection/i.test(text)) {
|
||||
return SSH_ERROR.UNREACHABLE
|
||||
}
|
||||
return SSH_ERROR.UNKNOWN
|
||||
}
|
||||
|
||||
function sshErrorMessage(kind, conn, stderr) {
|
||||
const host = target(conn.user, conn.host)
|
||||
switch (kind) {
|
||||
case SSH_ERROR.HOST_KEY_CHANGED:
|
||||
return (
|
||||
`The host key for ${host} has CHANGED since you last connected. ` +
|
||||
`This could be a man-in-the-middle attack, or the server was reinstalled. ` +
|
||||
`SSH refused to connect. Verify the change is expected, then remove the old key ` +
|
||||
`with \`ssh-keygen -R ${conn.host}\` and reconnect.\n\n${String(stderr || '').trim()}`
|
||||
)
|
||||
case SSH_ERROR.AUTH_FAILED:
|
||||
return (
|
||||
`SSH authentication to ${host} failed. Desktop runs ssh non-interactively ` +
|
||||
`(BatchMode), so a key requiring a passphrase or 2FA must be loaded into your ` +
|
||||
`ssh-agent first (e.g. \`ssh-add ~/.ssh/id_ed25519\`), or set an IdentityFile in ` +
|
||||
`~/.ssh/config. Original error: ${String(stderr || '').trim()}`
|
||||
)
|
||||
case SSH_ERROR.UNREACHABLE:
|
||||
return `Could not reach ${host} over SSH. Check the host, port, and your network. Original error: ${String(stderr || '').trim()}`
|
||||
case SSH_ERROR.TIMEOUT:
|
||||
return `SSH operation to ${host} timed out. The connection may be half-open (e.g. after sleep); reconnecting.`
|
||||
default:
|
||||
return `SSH error connecting to ${host}: ${String(stderr || '').trim() || 'unknown failure'}`
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spawn helper — runs an ssh invocation, races it against a hard timeout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Resolves { code, stdout, stderr }. On timeout the child is SIGKILLed and the
|
||||
// promise rejects with err.kind = TIMEOUT. `spawnFn` is injectable for tests.
|
||||
function runSsh(args, { timeoutMs, spawnFn = spawn, stdin = 'ignore' } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let child
|
||||
try {
|
||||
child = spawnFn('ssh', args, { stdio: [stdin === 'ignore' ? 'ignore' : 'pipe', 'pipe', 'pipe'] })
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let settled = false
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
try {
|
||||
child.kill('SIGKILL')
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
const err = new Error(`ssh timed out after ${timeoutMs}ms`)
|
||||
err.kind = SSH_ERROR.TIMEOUT
|
||||
reject(err)
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout?.on('data', d => {
|
||||
stdout += d.toString()
|
||||
})
|
||||
child.stderr?.on('data', d => {
|
||||
stderr += d.toString()
|
||||
})
|
||||
child.on('error', error => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
child.on('close', code => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve({ code, stdout, stderr })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SshConnection — the public manager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class SshConnection {
|
||||
/**
|
||||
* @param {{host:string, user?:string, port?:number, keyPath?:string}} cfg
|
||||
* @param {{ spawnFn?, rememberLog?, controlDir?, connectTimeoutMs?, execTimeoutMs?, forwardTimeoutMs? }} [opts]
|
||||
*/
|
||||
constructor(cfg, opts = {}) {
|
||||
if (!cfg || !cfg.host) {
|
||||
throw new Error('SshConnection requires a host.')
|
||||
}
|
||||
this.host = cfg.host
|
||||
this.user = cfg.user || ''
|
||||
this.port = cfg.port ? Number(cfg.port) : 22
|
||||
this.keyPath = cfg.keyPath || ''
|
||||
this.controlPath = controlSocketPath(this.user, this.host, this.port, opts.controlDir)
|
||||
|
||||
this._spawnFn = opts.spawnFn || spawn
|
||||
this._log = typeof opts.rememberLog === 'function' ? opts.rememberLog : () => {}
|
||||
this._connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS
|
||||
this._execTimeoutMs = opts.execTimeoutMs ?? DEFAULT_EXEC_TIMEOUT_MS
|
||||
this._forwardTimeoutMs = opts.forwardTimeoutMs ?? DEFAULT_FORWARD_TIMEOUT_MS
|
||||
this._opened = false
|
||||
}
|
||||
|
||||
// Lifecycle logging — ALWAYS through redaction.
|
||||
_logLine(msg) {
|
||||
this._log(redactSecrets(`[ssh] ${msg}`))
|
||||
}
|
||||
|
||||
// Throw a classified, UI-ready error from an ssh result/exception.
|
||||
_fail(stderrOrErr, fallbackKind = SSH_ERROR.UNKNOWN) {
|
||||
if (stderrOrErr && stderrOrErr.kind === SSH_ERROR.TIMEOUT) {
|
||||
const err = new Error(sshErrorMessage(SSH_ERROR.TIMEOUT, this))
|
||||
err.kind = SSH_ERROR.TIMEOUT
|
||||
return err
|
||||
}
|
||||
const stderr = typeof stderrOrErr === 'string' ? stderrOrErr : stderrOrErr?.message || ''
|
||||
const kind = stderr ? classifySshError(stderr) : fallbackKind
|
||||
const err = new Error(sshErrorMessage(kind, this, stderr))
|
||||
err.kind = kind
|
||||
return err
|
||||
}
|
||||
|
||||
// Open the persistent ControlMaster. Idempotent: if a master socket is
|
||||
// already alive (`-O check` succeeds), this is a no-op.
|
||||
async open() {
|
||||
if (await this.isAlive()) {
|
||||
this._opened = true
|
||||
return
|
||||
}
|
||||
// Ensure the control-socket directory exists — OpenSSH will not create
|
||||
// intermediate dirs for ControlPath, so a fresh box (no prior hermes-ssh
|
||||
// socket dir under $TMPDIR) would otherwise fail before the first connect.
|
||||
// 0o700: the socket grants command execution on the master; keep it private.
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.controlPath), { recursive: true, mode: 0o700 })
|
||||
} catch {
|
||||
// best effort — a pre-existing dir or a races-with-another-conn mkdir is fine
|
||||
}
|
||||
const args = buildMasterArgs(this, this._connectTimeoutMs)
|
||||
this._logLine(`opening control master to ${target(this.user, this.host)}:${this.port}`)
|
||||
let result
|
||||
try {
|
||||
result = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
|
||||
} catch (error) {
|
||||
throw this._fail(error, SSH_ERROR.UNREACHABLE)
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
throw this._fail(result.stderr, SSH_ERROR.UNREACHABLE)
|
||||
}
|
||||
this._opened = true
|
||||
this._logLine('control master established')
|
||||
}
|
||||
|
||||
// `-O check` against the master socket. True iff the master is alive.
|
||||
async isAlive() {
|
||||
const args = buildControlArgs(this, 'check', [], this._connectTimeoutMs)
|
||||
try {
|
||||
const result = await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
|
||||
return result.code === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// One-shot remote command over the control connection. Resolves the trimmed
|
||||
// stdout; rejects with a classified error on non-zero exit or timeout.
|
||||
async exec(remoteCommand, { timeoutMs } = {}) {
|
||||
const args = buildExecArgs(this, remoteCommand, this._connectTimeoutMs)
|
||||
let result
|
||||
try {
|
||||
result = await runSsh(args, { timeoutMs: timeoutMs ?? this._execTimeoutMs, spawnFn: this._spawnFn })
|
||||
} catch (error) {
|
||||
throw this._fail(error)
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
throw this._fail(result.stderr)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
// Establish a local→remote forward against the running master.
|
||||
// 127.0.0.1:<localPort> → <remoteHost>:<remotePort>.
|
||||
async forward(localPort, remotePort, remoteHost = '127.0.0.1') {
|
||||
const spec = forwardSpec(localPort, remotePort, remoteHost)
|
||||
const args = buildControlArgs(this, 'forward', ['-L', spec], this._connectTimeoutMs)
|
||||
this._logLine(`forwarding 127.0.0.1:${localPort} -> ${remoteHost}:${remotePort}`)
|
||||
let result
|
||||
try {
|
||||
result = await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn })
|
||||
} catch (error) {
|
||||
throw this._fail(error)
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
throw this._fail(result.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel a previously-established forward. Best-effort: a failure here is
|
||||
// logged but not thrown (the master close tears everything down anyway).
|
||||
async cancelForward(localPort, remotePort, remoteHost = '127.0.0.1') {
|
||||
const spec = forwardSpec(localPort, remotePort, remoteHost)
|
||||
const args = buildControlArgs(this, 'cancel', ['-L', spec], this._connectTimeoutMs)
|
||||
try {
|
||||
await runSsh(args, { timeoutMs: this._forwardTimeoutMs, spawnFn: this._spawnFn })
|
||||
this._logLine(`cancelled forward 127.0.0.1:${localPort}`)
|
||||
} catch (error) {
|
||||
this._logLine(`cancelForward failed (ignored): ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Tear down the master. Best-effort; never throws.
|
||||
async close() {
|
||||
if (!this._opened) return
|
||||
const args = buildControlArgs(this, 'exit', [], this._connectTimeoutMs)
|
||||
try {
|
||||
await runSsh(args, { timeoutMs: this._connectTimeoutMs, spawnFn: this._spawnFn })
|
||||
this._logLine('control master closed')
|
||||
} catch (error) {
|
||||
this._logLine(`close failed (ignored): ${error.message}`)
|
||||
} finally {
|
||||
this._opened = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Free local port — for the tunnel's local end. Bind 127.0.0.1:0, read the
|
||||
// kernel-assigned port, release. There is a benign TOCTOU window between
|
||||
// release and the forward grabbing it; the forward failing is caught upstream
|
||||
// and retried with a fresh port.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function pickLocalPort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.unref()
|
||||
server.on('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address()
|
||||
server.close(() => resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONTROL_PERSIST_SECONDS,
|
||||
DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
DEFAULT_EXEC_TIMEOUT_MS,
|
||||
DEFAULT_FORWARD_TIMEOUT_MS,
|
||||
SSH_ERROR,
|
||||
SshConnection,
|
||||
baseSshOptions,
|
||||
buildControlArgs,
|
||||
buildExecArgs,
|
||||
buildInteractiveSshArgs,
|
||||
buildMasterArgs,
|
||||
classifySshError,
|
||||
controlSocketPath,
|
||||
forwardSpec,
|
||||
hostArgs,
|
||||
pickLocalPort,
|
||||
redactSecrets,
|
||||
runSsh,
|
||||
sshErrorMessage,
|
||||
target
|
||||
}
|
||||
@@ -1,343 +0,0 @@
|
||||
/**
|
||||
* Tests for electron/ssh-connection.cjs.
|
||||
*
|
||||
* Run with: node --test electron/ssh-connection.test.cjs
|
||||
* (Wired into npm test:desktop:platforms in package.json.)
|
||||
*
|
||||
* Pure, electron-free: command construction, secret redaction, error
|
||||
* classification, and the SshConnection lifecycle are exercised with an
|
||||
* injected fake `spawn` so no real ssh process is started.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { EventEmitter } = require('node:events')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
|
||||
const {
|
||||
SSH_ERROR,
|
||||
SshConnection,
|
||||
baseSshOptions,
|
||||
buildControlArgs,
|
||||
buildExecArgs,
|
||||
buildInteractiveSshArgs,
|
||||
buildMasterArgs,
|
||||
classifySshError,
|
||||
controlSocketPath,
|
||||
forwardSpec,
|
||||
hostArgs,
|
||||
redactSecrets,
|
||||
sshErrorMessage,
|
||||
target
|
||||
} = require('./ssh-connection.cjs')
|
||||
|
||||
// --- secret redaction -------------------------------------------------------
|
||||
|
||||
test('redactSecrets scrubs the spawn-time session token env var', () => {
|
||||
const line = 'setsid env HERMES_DASHBOARD_SESSION_TOKEN=abc123deadbeef HERMES_DESKTOP=1 hermes dashboard'
|
||||
const out = redactSecrets(line)
|
||||
assert.ok(!out.includes('abc123deadbeef'))
|
||||
assert.match(out, /HERMES_DASHBOARD_SESSION_TOKEN=<redacted>/)
|
||||
// non-secret env vars are preserved
|
||||
assert.match(out, /HERMES_DESKTOP=1/)
|
||||
})
|
||||
|
||||
test('redactSecrets scrubs ?token= and ?ticket= URL params', () => {
|
||||
assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?token=supersecret'), /\?token=<redacted>/)
|
||||
assert.match(redactSecrets('ws://127.0.0.1:5000/api/ws?ticket=onetimeticket'), /\?ticket=<redacted>/)
|
||||
assert.match(redactSecrets('GET /x?a=1&token=zzz HTTP'), /&token=<redacted>/)
|
||||
assert.ok(!redactSecrets('?token=supersecret').includes('supersecret'))
|
||||
})
|
||||
|
||||
test('redactSecrets scrubs Authorization and X-Hermes-Session-Token headers', () => {
|
||||
assert.match(redactSecrets('Authorization: Bearer tok_9999'), /Authorization: Bearer <redacted>/)
|
||||
assert.ok(!redactSecrets('Authorization: Bearer tok_9999').includes('tok_9999'))
|
||||
assert.match(redactSecrets('X-Hermes-Session-Token: hdr_888'), /X-Hermes-Session-Token: ?<redacted>/)
|
||||
assert.ok(!redactSecrets('X-Hermes-Session-Token: hdr_888').includes('hdr_888'))
|
||||
})
|
||||
|
||||
test('redactSecrets handles null/undefined and non-secret text untouched', () => {
|
||||
assert.equal(redactSecrets(null), '')
|
||||
assert.equal(redactSecrets(undefined), '')
|
||||
assert.equal(redactSecrets('uname -s -m'), 'uname -s -m')
|
||||
})
|
||||
|
||||
// --- control-socket path ----------------------------------------------------
|
||||
|
||||
test('controlSocketPath is stable, short, and host-distinct', () => {
|
||||
const a = controlSocketPath('me', 'box1', 22, '/tmp/d')
|
||||
const a2 = controlSocketPath('me', 'box1', 22, '/tmp/d')
|
||||
const b = controlSocketPath('me', 'box2', 22, '/tmp/d')
|
||||
assert.equal(a, a2, 'same triple → same socket (ControlMaster reuse)')
|
||||
assert.notEqual(a, b, 'different host → different socket')
|
||||
// 16 hex chars + .sock keeps the basename short for sun_path 104-byte limit
|
||||
assert.match(a, /\/[0-9a-f]{16}\.sock$/)
|
||||
})
|
||||
|
||||
test('controlSocketPath default base stays under sun_path even with the temp-listener suffix', () => {
|
||||
// OpenSSH binds a temporary listener at `<ControlPath>.<16 random chars>`
|
||||
// (a 17-byte suffix) while opening the master. The macOS regression was the
|
||||
// default base under os.tmpdir() (/var/folders/.../T/) pushing 89 → 106 bytes.
|
||||
// The default base must keep socket + 17-byte suffix comfortably under 104.
|
||||
const p = controlSocketPath('hermes', 'vbuddy-ubuntu', 22) // no baseDir → default
|
||||
const worstCase = `${p}.0123456789abcdef` // mimic the .<16-char> temp suffix
|
||||
assert.ok(
|
||||
worstCase.length <= 104,
|
||||
`default control socket + temp suffix must fit sun_path (got ${worstCase.length}: ${worstCase})`
|
||||
)
|
||||
// And it must NOT live under the deeply-nested macOS per-user temp dir.
|
||||
assert.ok(!p.includes('/var/folders/'), 'default base must not be os.tmpdir() on macOS')
|
||||
})
|
||||
|
||||
// --- command construction ---------------------------------------------------
|
||||
|
||||
test('baseSshOptions carries the house ControlMaster/BatchMode/accept-new policy', () => {
|
||||
const opts = baseSshOptions('/tmp/x.sock', 15000)
|
||||
const joined = opts.join(' ')
|
||||
assert.match(joined, /ControlPath=\/tmp\/x\.sock/)
|
||||
assert.match(joined, /ControlMaster=auto/)
|
||||
assert.match(joined, /ControlPersist=\d+/)
|
||||
assert.match(joined, /BatchMode=yes/)
|
||||
assert.match(joined, /StrictHostKeyChecking=accept-new/)
|
||||
assert.match(joined, /ConnectTimeout=15/)
|
||||
assert.ok(!joined.includes('StrictHostKeyChecking=no'), 'never disables host-key checking')
|
||||
})
|
||||
|
||||
test('hostArgs adds -p only for non-default port and -i only with a key', () => {
|
||||
assert.deepEqual(hostArgs({ port: 22 }), [])
|
||||
assert.deepEqual(hostArgs({ port: 2222 }), ['-p', '2222'])
|
||||
assert.deepEqual(hostArgs({ port: 22, keyPath: '/k' }), ['-i', '/k'])
|
||||
assert.deepEqual(hostArgs({ port: 2200, keyPath: '/k' }), ['-p', '2200', '-i', '/k'])
|
||||
})
|
||||
|
||||
test('target builds user@host or bare host', () => {
|
||||
assert.equal(target('me', 'box'), 'me@box')
|
||||
assert.equal(target('', 'box'), 'box')
|
||||
})
|
||||
|
||||
test('buildExecArgs ends with host then the remote command', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildExecArgs(conn, 'command -v hermes', 15000)
|
||||
assert.equal(args[args.length - 1], 'command -v hermes')
|
||||
assert.equal(args[args.length - 2], 'me@box')
|
||||
assert.ok(args.includes('BatchMode=yes'))
|
||||
})
|
||||
|
||||
test('buildControlArgs places -O <op> first and never appends a remote command', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 2222, keyPath: '/k', controlPath: '/tmp/x.sock' }
|
||||
const args = buildControlArgs(conn, 'forward', ['-L', forwardSpec(5000, 6000)], 15000)
|
||||
assert.equal(args[0], '-O')
|
||||
assert.equal(args[1], 'forward')
|
||||
assert.ok(args.includes('-L'))
|
||||
assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000'))
|
||||
assert.equal(args[args.length - 1], 'me@box')
|
||||
})
|
||||
|
||||
test('buildMasterArgs requests a backgrounded master (-M -N -f)', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildMasterArgs(conn, 15000)
|
||||
assert.ok(args.includes('-M'))
|
||||
assert.ok(args.includes('-N'))
|
||||
assert.ok(args.includes('-f'))
|
||||
})
|
||||
|
||||
test('forwardSpec binds the local end to 127.0.0.1 only', () => {
|
||||
assert.equal(forwardSpec(5000, 6000), '127.0.0.1:5000:127.0.0.1:6000')
|
||||
assert.ok(forwardSpec(5000, 6000).startsWith('127.0.0.1:'))
|
||||
assert.ok(!forwardSpec(5000, 6000).startsWith('0.0.0.0'))
|
||||
})
|
||||
|
||||
test('buildInteractiveSshArgs requests a PTY, reuses the control master, execs a login shell', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildInteractiveSshArgs(conn, '', 15000)
|
||||
assert.equal(args[0], '-tt', 'forces a PTY so the remote sees a real terminal')
|
||||
assert.ok(args.join(' ').includes('ControlPath=/tmp/x.sock'), 'reuses the existing master (no new auth)')
|
||||
assert.equal(args[args.length - 2], 'me@box')
|
||||
assert.equal(args[args.length - 1], 'exec "$SHELL" -l')
|
||||
})
|
||||
|
||||
test('buildInteractiveSshArgs cds into the remote cwd (best-effort) before the shell', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildInteractiveSshArgs(conn, '/home/me/project', 15000)
|
||||
const remoteCmd = args[args.length - 1]
|
||||
assert.match(remoteCmd, /^cd '\/home\/me\/project' 2>\/dev\/null; exec "\$SHELL" -l$/)
|
||||
})
|
||||
|
||||
test('buildInteractiveSshArgs single-quotes a cwd with quotes safely', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22, keyPath: '', controlPath: '/tmp/x.sock' }
|
||||
const args = buildInteractiveSshArgs(conn, "/tmp/a'b", 15000)
|
||||
// the embedded quote must be escaped, not break out of the quoting
|
||||
assert.ok(args[args.length - 1].startsWith("cd '/tmp/a'"))
|
||||
assert.ok(args[args.length - 1].includes('exec "$SHELL" -l'))
|
||||
})
|
||||
|
||||
// --- error classification ---------------------------------------------------
|
||||
|
||||
test('classifySshError detects a changed host key (fail-closed)', () => {
|
||||
assert.equal(
|
||||
classifySshError('@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@'),
|
||||
SSH_ERROR.HOST_KEY_CHANGED
|
||||
)
|
||||
assert.equal(classifySshError('Host key verification failed.'), SSH_ERROR.HOST_KEY_CHANGED)
|
||||
assert.equal(classifySshError('Offending ECDSA key in /home/u/.ssh/known_hosts:5'), SSH_ERROR.HOST_KEY_CHANGED)
|
||||
})
|
||||
|
||||
test('classifySshError detects auth failure', () => {
|
||||
assert.equal(classifySshError('Permission denied (publickey).'), SSH_ERROR.AUTH_FAILED)
|
||||
assert.equal(classifySshError('Too many authentication failures'), SSH_ERROR.AUTH_FAILED)
|
||||
})
|
||||
|
||||
test('classifySshError detects unreachable', () => {
|
||||
assert.equal(classifySshError('ssh: Could not resolve hostname nope'), SSH_ERROR.UNREACHABLE)
|
||||
assert.equal(classifySshError('connect to host x port 22: Connection refused'), SSH_ERROR.UNREACHABLE)
|
||||
})
|
||||
|
||||
test('sshErrorMessage gives actionable guidance for auth and host-key-change', () => {
|
||||
const conn = { user: 'me', host: 'box', port: 22 }
|
||||
assert.match(sshErrorMessage(SSH_ERROR.AUTH_FAILED, conn, 'Permission denied'), /ssh-agent|ssh-add|IdentityFile/)
|
||||
assert.match(sshErrorMessage(SSH_ERROR.HOST_KEY_CHANGED, conn, 'CHANGED'), /ssh-keygen -R box/)
|
||||
})
|
||||
|
||||
// --- SshConnection lifecycle with injected fake spawn -----------------------
|
||||
|
||||
// A fake child process that emits a scripted result on next tick.
|
||||
function fakeChild({ code = 0, stdout = '', stderr = '', errorEvent = null, hang = false } = {}) {
|
||||
const child = new EventEmitter()
|
||||
child.stdout = new EventEmitter()
|
||||
child.stderr = new EventEmitter()
|
||||
child.kill = () => {
|
||||
child._killed = true
|
||||
}
|
||||
if (hang) {
|
||||
return child // never emits close → drives the timeout path
|
||||
}
|
||||
process.nextTick(() => {
|
||||
if (errorEvent) {
|
||||
child.emit('error', errorEvent)
|
||||
return
|
||||
}
|
||||
if (stdout) child.stdout.emit('data', Buffer.from(stdout))
|
||||
if (stderr) child.stderr.emit('data', Buffer.from(stderr))
|
||||
child.emit('close', code)
|
||||
})
|
||||
return child
|
||||
}
|
||||
|
||||
// Build a spawnFn that returns scripted children per ssh invocation, recording
|
||||
// the args it was called with.
|
||||
function scriptedSpawn(scripts) {
|
||||
const calls = []
|
||||
let i = 0
|
||||
const fn = (_cmd, args) => {
|
||||
calls.push(args)
|
||||
const script = typeof scripts === 'function' ? scripts(args, i) : scripts[Math.min(i, scripts.length - 1)]
|
||||
i += 1
|
||||
return fakeChild(script || {})
|
||||
}
|
||||
fn.calls = calls
|
||||
return fn
|
||||
}
|
||||
|
||||
test('open() establishes the master when not already alive', async () => {
|
||||
// `-O check` fails first (not alive) → master opens (code 0). Track which
|
||||
// ssh ops ran rather than re-probing with the same always-failing check.
|
||||
const ops = []
|
||||
const spawnFn = scriptedSpawn(args => {
|
||||
ops.push(args.includes('check') ? 'check' : args.includes('-M') ? 'master' : 'other')
|
||||
if (args.includes('check')) return { code: 255, stderr: 'no control path' }
|
||||
return { code: 0 }
|
||||
})
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
await conn.open()
|
||||
assert.deepEqual(ops, ['check', 'master'], 'probes liveness first, then opens the master')
|
||||
})
|
||||
|
||||
test('open() is a no-op when the master is already alive', async () => {
|
||||
const ops = []
|
||||
const spawnFn = scriptedSpawn(args => {
|
||||
ops.push(args.includes('check') ? 'check' : 'master')
|
||||
return { code: 0 } // check succeeds → already alive
|
||||
})
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
await conn.open()
|
||||
assert.deepEqual(ops, ['check'], 'alive master → no second spawn to open it')
|
||||
})
|
||||
|
||||
test('open() creates the control-socket directory if it does not exist', async () => {
|
||||
const dir = path.join(os.tmpdir(), `hermes-ssh-test-${process.pid}-${Date.now()}`)
|
||||
assert.ok(!fs.existsSync(dir), 'precondition: control dir absent')
|
||||
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir })
|
||||
try {
|
||||
await conn.open()
|
||||
assert.ok(fs.existsSync(dir), 'open() created the control-socket directory before spawning ssh')
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('open() surfaces a classified auth error', async () => {
|
||||
const spawnFn = scriptedSpawn(args => {
|
||||
if (args.includes('check')) return { code: 255 }
|
||||
return { code: 255, stderr: 'Permission denied (publickey).' }
|
||||
})
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
await assert.rejects(() => conn.open(), err => {
|
||||
assert.equal(err.kind, SSH_ERROR.AUTH_FAILED)
|
||||
assert.match(err.message, /ssh-agent|ssh-add/)
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('exec() returns stdout on success and rejects (classified) on failure', async () => {
|
||||
const okSpawn = scriptedSpawn([{ code: 0, stdout: 'Linux\n' }])
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: okSpawn, controlDir: '/tmp/d' })
|
||||
assert.equal((await conn.exec('uname -s')).trim(), 'Linux')
|
||||
|
||||
const failSpawn = scriptedSpawn([{ code: 1, stderr: 'ssh: Could not resolve hostname box' }])
|
||||
const conn2 = new SshConnection({ host: 'box', user: 'me' }, { spawnFn: failSpawn, controlDir: '/tmp/d' })
|
||||
await assert.rejects(() => conn2.exec('uname -s'), err => {
|
||||
assert.equal(err.kind, SSH_ERROR.UNREACHABLE)
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('exec() treats a hung ssh as a timeout (half-open connection)', async () => {
|
||||
const spawnFn = scriptedSpawn([{ hang: true }])
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
await assert.rejects(() => conn.exec('uname -s', { timeoutMs: 30 }), err => {
|
||||
assert.equal(err.kind, SSH_ERROR.TIMEOUT)
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('forward() issues -O forward with a loopback-bound -L spec', async () => {
|
||||
const spawnFn = scriptedSpawn([{ code: 0 }])
|
||||
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
|
||||
await conn.forward(5000, 6000)
|
||||
const args = spawnFn.calls[0]
|
||||
assert.equal(args[0], '-O')
|
||||
assert.equal(args[1], 'forward')
|
||||
assert.ok(args.includes('127.0.0.1:5000:127.0.0.1:6000'))
|
||||
})
|
||||
|
||||
test('lifecycle logging passes through redaction', async () => {
|
||||
const logs = []
|
||||
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
|
||||
const conn = new SshConnection(
|
||||
{ host: 'box', user: 'me' },
|
||||
{ spawnFn, controlDir: '/tmp/d', rememberLog: l => logs.push(l) }
|
||||
)
|
||||
await conn.open()
|
||||
// none of the emitted log lines may carry a raw token-shaped secret
|
||||
for (const line of logs) {
|
||||
assert.ok(!/token=[^<]/.test(line))
|
||||
}
|
||||
assert.ok(logs.some(l => l.includes('[ssh]')))
|
||||
})
|
||||
28
apps/desktop/electron/update-count.cjs
Normal file
28
apps/desktop/electron/update-count.cjs
Normal file
@@ -0,0 +1,28 @@
|
||||
'use strict'
|
||||
|
||||
// Whether `git rev-list HEAD..origin/<branch> --count` produces a meaningful
|
||||
// number worth computing. On a SHALLOW checkout (installer clones with
|
||||
// --depth 1) the local history often shares no merge-base with the freshly
|
||||
// fetched origin tip, so the count enumerates the entire remote ancestry and
|
||||
// returns a bogus huge number (e.g. 12104) — see #51922. resolveBehindCount
|
||||
// discards that bogus count in favour of a SHA compare, so the caller should
|
||||
// SKIP the expensive rev-list entirely in that case rather than run it and
|
||||
// throw the result away.
|
||||
function shouldCountCommits({ isShallow, hasMergeBase }) {
|
||||
return !(isShallow && !hasMergeBase)
|
||||
}
|
||||
|
||||
// Resolve how many commits the local checkout is behind origin for the desktop
|
||||
// update indicator. When the count isn't meaningful (shallow + no merge-base)
|
||||
// fall back to a binary up-to-date check by SHA, exactly like the official-SSH
|
||||
// path in checkUpdates() and the CLI guard in hermes_cli/banner.py. Full clones
|
||||
// (developers / Docker dev images) keep the exact count path unchanged.
|
||||
function resolveBehindCount({ countStr, currentSha, targetSha, isShallow, hasMergeBase }) {
|
||||
if (!shouldCountCommits({ isShallow, hasMergeBase })) {
|
||||
if (currentSha && targetSha && currentSha === targetSha) return 0
|
||||
return 1 // behind by an unknown amount — show a generic "update available"
|
||||
}
|
||||
return Number.parseInt(countStr, 10) || 0
|
||||
}
|
||||
|
||||
module.exports = { resolveBehindCount, shouldCountCommits }
|
||||
79
apps/desktop/electron/update-count.test.cjs
Normal file
79
apps/desktop/electron/update-count.test.cjs
Normal file
@@ -0,0 +1,79 @@
|
||||
'use strict'
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
|
||||
|
||||
// FAIL-BEFORE: pre-fix the function did `Number.parseInt(countStr) || 0`
|
||||
// unconditionally, so a shallow checkout with no merge-base surfaced the bogus
|
||||
// rev-list count (e.g. 12104). This asserts the new shallow/no-merge-base branch.
|
||||
test('shallow checkout with no merge-base does NOT trust the bogus rev-list count', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '12104', currentSha: 'aaa', targetSha: 'bbb',
|
||||
isShallow: true, hasMergeBase: false,
|
||||
}), 1)
|
||||
})
|
||||
|
||||
test('shallow checkout with no merge-base but identical SHA reports up-to-date', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '12104', currentSha: 'abc', targetSha: 'abc',
|
||||
isShallow: true, hasMergeBase: false,
|
||||
}), 0)
|
||||
})
|
||||
|
||||
test('shallow checkout WITH a merge-base keeps the exact count (reliable)', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '3', currentSha: 'aaa', targetSha: 'bbb',
|
||||
isShallow: true, hasMergeBase: true,
|
||||
}), 3)
|
||||
})
|
||||
|
||||
test('full (non-shallow) clone keeps the exact count path unchanged', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '7', currentSha: 'aaa', targetSha: 'bbb',
|
||||
isShallow: false, hasMergeBase: true,
|
||||
}), 7)
|
||||
})
|
||||
|
||||
test('up-to-date full clone reports 0', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '0', currentSha: 'x', targetSha: 'x',
|
||||
isShallow: false, hasMergeBase: true,
|
||||
}), 0)
|
||||
})
|
||||
|
||||
test('non-numeric count falls back to 0 (defensive, unchanged behaviour)', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '', currentSha: 'aaa', targetSha: 'bbb',
|
||||
isShallow: false, hasMergeBase: true,
|
||||
}), 0)
|
||||
})
|
||||
|
||||
// shouldCountCommits gates the expensive `rev-list --count` in checkUpdates().
|
||||
// FAIL-BEFORE: in the shallow + no-merge-base case the caller ran rev-list
|
||||
// unconditionally and discarded the bogus result; this predicate lets the
|
||||
// caller SKIP the whole-ancestry enumeration in exactly that case (#51922).
|
||||
test('shallow checkout with no merge-base SKIPS the rev-list count', () => {
|
||||
assert.equal(shouldCountCommits({ isShallow: true, hasMergeBase: false }), false)
|
||||
})
|
||||
|
||||
test('shallow checkout WITH a merge-base still runs the count', () => {
|
||||
assert.equal(shouldCountCommits({ isShallow: true, hasMergeBase: true }), true)
|
||||
})
|
||||
|
||||
test('full (non-shallow) clone always runs the count', () => {
|
||||
assert.equal(shouldCountCommits({ isShallow: false, hasMergeBase: true }), true)
|
||||
assert.equal(shouldCountCommits({ isShallow: false, hasMergeBase: false }), true)
|
||||
})
|
||||
|
||||
// The skip path produces an empty countStr; resolveBehindCount must NOT trust
|
||||
// it and must fall through to the SHA compare (mirrors the live call site).
|
||||
test('skipped-count path resolves via SHA compare, never via empty countStr', () => {
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '', currentSha: 'aaa', targetSha: 'bbb',
|
||||
isShallow: true, hasMergeBase: false,
|
||||
}), 1)
|
||||
assert.equal(resolveBehindCount({
|
||||
countStr: '', currentSha: 'same', targetSha: 'same',
|
||||
isShallow: true, hasMergeBase: false,
|
||||
}), 0)
|
||||
})
|
||||
117
apps/desktop/electron/window-state.cjs
Normal file
117
apps/desktop/electron/window-state.cjs
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Pure geometry helpers for window-state.json — restoring the main window's
|
||||
* size, position, and maximized flag across launches. Side-effect-free so the
|
||||
* part that actually matters (rejecting garbage + off-screen bounds) is
|
||||
* unit-testable without booting Electron; main.cjs owns the file I/O and the
|
||||
* live `screen` displays.
|
||||
*/
|
||||
|
||||
// Defaults mirror the historical hardcoded BrowserWindow size; MIN_* mirror its
|
||||
// minWidth/minHeight so a restored size never undershoots what the live window
|
||||
// allows. A fresh install (no saved state) is byte-identical to before.
|
||||
const DEFAULT_WIDTH = 1220
|
||||
const DEFAULT_HEIGHT = 800
|
||||
const MIN_WIDTH = 400
|
||||
const MIN_HEIGHT = 620
|
||||
|
||||
// Keep at least this much of the window over a display work area before we trust
|
||||
// a saved position, so the title bar stays grabbable after a monitor unplugs.
|
||||
const MIN_VISIBLE = 48
|
||||
|
||||
const finite = v => typeof v === 'number' && Number.isFinite(v)
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, hi))
|
||||
|
||||
// Parse raw JSON → clean state, or null if garbage. width/height are required
|
||||
// and floored; x/y survive only as a finite pair; isMaximized is strict.
|
||||
function sanitizeWindowState(raw) {
|
||||
if (!raw || typeof raw !== 'object' || !finite(raw.width) || !finite(raw.height)) return null
|
||||
|
||||
const state = {
|
||||
width: Math.max(MIN_WIDTH, Math.round(raw.width)),
|
||||
height: Math.max(MIN_HEIGHT, Math.round(raw.height)),
|
||||
isMaximized: raw.isMaximized === true
|
||||
}
|
||||
if (finite(raw.x) && finite(raw.y)) {
|
||||
state.x = Math.round(raw.x)
|
||||
state.y = Math.round(raw.y)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
// True when `bounds` overlaps some display's work area by ≥ MIN_VISIBLE on both
|
||||
// axes. `displays` is Electron's screen.getAllDisplays() shape.
|
||||
function onScreen(bounds, displays) {
|
||||
if (!Array.isArray(displays)) return false
|
||||
return displays.some(({ workArea: a } = {}) => {
|
||||
if (!a) return false
|
||||
const x = Math.min(bounds.x + bounds.width, a.x + a.width) - Math.max(bounds.x, a.x)
|
||||
const y = Math.min(bounds.y + bounds.height, a.y + a.height) - Math.max(bounds.y, a.y)
|
||||
return x >= MIN_VISIBLE && y >= MIN_VISIBLE
|
||||
})
|
||||
}
|
||||
|
||||
// Sanitized state (or null) → BrowserWindow size/position options. Always sets
|
||||
// width/height, capped to the largest current display so a size saved on a
|
||||
// since-disconnected bigger monitor can't exceed any screen the user now has.
|
||||
// Sets x/y only when still on-screen; otherwise Electron centers the window.
|
||||
function computeWindowOptions(state, displays) {
|
||||
const opts = {
|
||||
width: finite(state?.width) ? state.width : DEFAULT_WIDTH,
|
||||
height: finite(state?.height) ? state.height : DEFAULT_HEIGHT
|
||||
}
|
||||
|
||||
const cap = (Array.isArray(displays) ? displays : []).reduce(
|
||||
(m, { workArea: a } = {}) =>
|
||||
a && finite(a.width) && finite(a.height)
|
||||
? { width: Math.max(m.width, a.width), height: Math.max(m.height, a.height) }
|
||||
: m,
|
||||
{ width: 0, height: 0 }
|
||||
)
|
||||
if (cap.width && cap.height) {
|
||||
opts.width = clamp(opts.width, MIN_WIDTH, cap.width)
|
||||
opts.height = clamp(opts.height, MIN_HEIGHT, cap.height)
|
||||
}
|
||||
|
||||
if (
|
||||
state &&
|
||||
finite(state.x) &&
|
||||
finite(state.y) &&
|
||||
onScreen({ x: state.x, y: state.y, width: opts.width, height: opts.height }, displays)
|
||||
) {
|
||||
opts.x = state.x
|
||||
opts.y = state.y
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// Trailing debounce: collapse a burst of resize/move events (Linux fires many
|
||||
// mid-drag) into a single run `delayMs` after the last. `.flush()` runs now and
|
||||
// cancels the pending timer — used on close, before the window is gone.
|
||||
function debounce(fn, delayMs) {
|
||||
let timer = null
|
||||
const debounced = () => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
fn()
|
||||
}, delayMs)
|
||||
}
|
||||
debounced.flush = () => {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
fn()
|
||||
}
|
||||
return debounced
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_WIDTH,
|
||||
DEFAULT_HEIGHT,
|
||||
MIN_WIDTH,
|
||||
MIN_HEIGHT,
|
||||
MIN_VISIBLE,
|
||||
sanitizeWindowState,
|
||||
onScreen,
|
||||
computeWindowOptions,
|
||||
debounce
|
||||
}
|
||||
135
apps/desktop/electron/window-state.test.cjs
Normal file
135
apps/desktop/electron/window-state.test.cjs
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Unit tests for the pure window-state geometry helpers. These cover the logic
|
||||
* that protects the user: garbage rejection, off-screen fallback, oversized
|
||||
* clamping, and the debounce that collapses mid-drag write storms.
|
||||
*/
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
DEFAULT_WIDTH,
|
||||
DEFAULT_HEIGHT,
|
||||
MIN_WIDTH,
|
||||
MIN_HEIGHT,
|
||||
sanitizeWindowState,
|
||||
onScreen,
|
||||
computeWindowOptions,
|
||||
debounce
|
||||
} = require('./window-state.cjs')
|
||||
|
||||
// A single 1920×1080 monitor (work area trimmed for the taskbar).
|
||||
const PRIMARY = [{ workArea: { x: 0, y: 0, width: 1920, height: 1040 } }]
|
||||
// A laptop panel left behind after a bigger external monitor is unplugged.
|
||||
const LAPTOP = [{ workArea: { x: 0, y: 0, width: 1366, height: 728 } }]
|
||||
|
||||
// ─── sanitizeWindowState ───────────────────────────────────────────────────
|
||||
|
||||
test('sanitizeWindowState rejects missing/garbage input', () => {
|
||||
for (const bad of [null, undefined, 'nope', 42, {}, { width: 'x', height: 800 }, { width: NaN, height: 800 }, { width: 1000 }]) {
|
||||
assert.equal(sanitizeWindowState(bad), null)
|
||||
}
|
||||
})
|
||||
|
||||
test('sanitizeWindowState keeps a valid full state and rounds HiDPI fractions', () => {
|
||||
assert.deepEqual(sanitizeWindowState({ x: 100.6, y: 50.2, width: 1400.4, height: 900.7, isMaximized: true }), {
|
||||
x: 101,
|
||||
y: 50,
|
||||
width: 1400,
|
||||
height: 901,
|
||||
isMaximized: true
|
||||
})
|
||||
})
|
||||
|
||||
test('sanitizeWindowState floors size to the minimums', () => {
|
||||
const state = sanitizeWindowState({ width: 10, height: 10 })
|
||||
assert.equal(state.width, MIN_WIDTH)
|
||||
assert.equal(state.height, MIN_HEIGHT)
|
||||
})
|
||||
|
||||
test('sanitizeWindowState drops a partial position but keeps the size', () => {
|
||||
assert.deepEqual(sanitizeWindowState({ x: 100, width: 1400, height: 900 }), {
|
||||
width: 1400,
|
||||
height: 900,
|
||||
isMaximized: false
|
||||
})
|
||||
})
|
||||
|
||||
test('sanitizeWindowState treats isMaximized strictly', () => {
|
||||
assert.equal(sanitizeWindowState({ width: 1400, height: 900, isMaximized: 'yes' }).isMaximized, false)
|
||||
})
|
||||
|
||||
// ─── onScreen ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('onScreen accepts a window on the primary or a secondary display', () => {
|
||||
const dual = [...PRIMARY, { workArea: { x: 1920, y: 0, width: 2560, height: 1400 } }]
|
||||
assert.equal(onScreen({ x: 100, y: 100, width: 1220, height: 800 }, PRIMARY), true)
|
||||
assert.equal(onScreen({ x: 2200, y: 200, width: 1220, height: 800 }, dual), true)
|
||||
})
|
||||
|
||||
test('onScreen rejects off-screen, slivers, and bad input', () => {
|
||||
assert.equal(onScreen({ x: 3000, y: 100, width: 1220, height: 800 }, PRIMARY), false) // past right edge
|
||||
assert.equal(onScreen({ x: 100, y: -900, width: 1220, height: 800 }, PRIMARY), false) // above top
|
||||
assert.equal(onScreen({ x: 1910, y: 100, width: 1220, height: 800 }, PRIMARY), false) // ~10px sliver
|
||||
assert.equal(onScreen({ x: 0, y: 0, width: 1220, height: 800 }, []), false)
|
||||
assert.equal(onScreen({ x: 0, y: 0, width: 1220, height: 800 }, null), false)
|
||||
})
|
||||
|
||||
// ─── computeWindowOptions ──────────────────────────────────────────────────
|
||||
|
||||
test('computeWindowOptions falls back to defaults with no saved state', () => {
|
||||
assert.deepEqual(computeWindowOptions(null, PRIMARY), { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT })
|
||||
})
|
||||
|
||||
test('computeWindowOptions restores an on-screen position', () => {
|
||||
const saved = sanitizeWindowState({ x: 200, y: 150, width: 1400, height: 900 })
|
||||
assert.deepEqual(computeWindowOptions(saved, PRIMARY), { width: 1400, height: 900, x: 200, y: 150 })
|
||||
})
|
||||
|
||||
test('computeWindowOptions keeps the size but drops an off-screen position', () => {
|
||||
const saved = sanitizeWindowState({ x: 5000, y: 150, width: 1400, height: 900 })
|
||||
assert.deepEqual(computeWindowOptions(saved, PRIMARY), { width: 1400, height: 900 })
|
||||
})
|
||||
|
||||
test('computeWindowOptions clamps a size larger than the only display', () => {
|
||||
const saved = sanitizeWindowState({ width: 2560, height: 1440 })
|
||||
assert.deepEqual(computeWindowOptions(saved, LAPTOP), { width: 1366, height: 728 })
|
||||
})
|
||||
|
||||
test('computeWindowOptions keeps the MIN floor on a sub-minimum display', () => {
|
||||
const tiny = [{ workArea: { x: 0, y: 0, width: 360, height: 480 } }]
|
||||
const saved = sanitizeWindowState({ width: 2000, height: 1500 })
|
||||
assert.deepEqual(computeWindowOptions(saved, tiny), { width: MIN_WIDTH, height: MIN_HEIGHT })
|
||||
})
|
||||
|
||||
test('computeWindowOptions does not clamp when displays are unknown', () => {
|
||||
const saved = sanitizeWindowState({ width: 2560, height: 1440 })
|
||||
assert.deepEqual(computeWindowOptions(saved, []), { width: 2560, height: 1440 })
|
||||
})
|
||||
|
||||
// ─── debounce ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('debounce coalesces a burst into one trailing run', t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
let calls = 0
|
||||
const d = debounce(() => { calls += 1 }, 250)
|
||||
|
||||
d(); d(); d()
|
||||
assert.equal(calls, 0)
|
||||
t.mock.timers.tick(249)
|
||||
assert.equal(calls, 0)
|
||||
t.mock.timers.tick(1)
|
||||
assert.equal(calls, 1)
|
||||
})
|
||||
|
||||
test('debounce.flush runs now and cancels the pending timer', t => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
let calls = 0
|
||||
const d = debounce(() => { calls += 1 }, 250)
|
||||
|
||||
d()
|
||||
d.flush()
|
||||
assert.equal(calls, 1)
|
||||
t.mock.timers.tick(1000)
|
||||
assert.equal(calls, 1)
|
||||
})
|
||||
@@ -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/ssh-connection.test.cjs electron/remote-lifecycle.test.cjs electron/ssh-config.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/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.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/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/window-state.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
||||
@@ -9,9 +9,9 @@ import { type Translations, useI18n } from '@/i18n'
|
||||
import { AlertCircle, CheckCircle2, Sparkles } from '@/lib/icons'
|
||||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $activeSessionId } from '@/store/session'
|
||||
import {
|
||||
$subagentsBySession,
|
||||
allSubagents,
|
||||
buildSubagentTree,
|
||||
type SubagentNode,
|
||||
type SubagentStatus,
|
||||
@@ -77,15 +77,12 @@ interface AgentsViewProps {
|
||||
|
||||
export function AgentsView({ onClose }: AgentsViewProps) {
|
||||
const { t } = useI18n()
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const subagentsBySession = useStore($subagentsBySession)
|
||||
|
||||
const activeSubagents = useMemo(
|
||||
() => (activeSessionId ? (subagentsBySession[activeSessionId] ?? []) : []),
|
||||
[activeSessionId, subagentsBySession]
|
||||
)
|
||||
|
||||
const tree = useMemo(() => buildSubagentTree(activeSubagents), [activeSubagents])
|
||||
// Aggregate every session, matching the status-bar indicator — a subagent
|
||||
// running in a background session must still be visible here, or the two
|
||||
// desync ("Agents N running" vs an empty tree).
|
||||
const tree = useMemo(() => buildSubagentTree(allSubagents(subagentsBySession)), [subagentsBySession])
|
||||
|
||||
return (
|
||||
<OverlayView
|
||||
|
||||
106
apps/desktop/src/app/chat/composer/composer-text-guard.test.tsx
Normal file
106
apps/desktop/src/app/chat/composer/composer-text-guard.test.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
// Regression repro for #49903: on desktop v0.17.0 the composer threw an
|
||||
// uncaught `Error: Composer is not available` at startup and the input went
|
||||
// unresponsive. The throw comes from @assistant-ui/core's composer-runtime —
|
||||
// every *mutator* (setText/send/…) does `if (!core) throw new Error("Composer
|
||||
// is not available")` when the thread's composer core isn't bound yet. Unlike
|
||||
// the read path (`s.composer.text`, which is null-safe: `runtime?.text ?? ""`),
|
||||
// the mutators have no graceful fallback. ChatBar's mount-time effects (draft
|
||||
// restore, clearDraft, external inserts) push text via `aui.composer().setText`
|
||||
// before the core binds, and the popout refactor (#49488) widened that window,
|
||||
// so the throw surfaced as an uncaught error that wedged the input.
|
||||
//
|
||||
// The fix wraps every `aui.composer().setText` call in a `setComposerText`
|
||||
// helper that swallows the unbound-core throw — the contentEditable DOM +
|
||||
// draftRef already hold the text and the draft⇄editor sync re-applies it once
|
||||
// the core attaches, so nothing is lost. This Harness mirrors that helper
|
||||
// faithfully (same try/catch shape) over a fake `aui` whose composer can be
|
||||
// toggled bound/unbound, the way the assistant-ui runtime behaves across mount.
|
||||
|
||||
interface FakeComposer {
|
||||
setText: (value: string) => void
|
||||
}
|
||||
|
||||
// Mirror of index.tsx's `useAui()` composer surface: composer() returns a
|
||||
// runtime whose setText throws exactly like @assistant-ui/core when unbound.
|
||||
function makeFakeAui(bound: { current: boolean }, applied: string[]) {
|
||||
const composer: FakeComposer = {
|
||||
setText(value: string) {
|
||||
if (!bound.current) {
|
||||
throw new Error('Composer is not available')
|
||||
}
|
||||
|
||||
applied.push(value)
|
||||
}
|
||||
}
|
||||
|
||||
return { composer: () => composer }
|
||||
}
|
||||
|
||||
function Harness({
|
||||
bound,
|
||||
applied,
|
||||
onError
|
||||
}: {
|
||||
applied: string[]
|
||||
bound: { current: boolean }
|
||||
onError: (err: unknown) => void
|
||||
}) {
|
||||
const aui = useRef(makeFakeAui(bound, applied)).current
|
||||
|
||||
// Verbatim mirror of the production `setComposerText` helper in index.tsx.
|
||||
const setComposerText = useCallback(
|
||||
(value: string) => {
|
||||
try {
|
||||
aui.composer().setText(value)
|
||||
} catch {
|
||||
// Composer core not bound yet — swallow so the input stays usable.
|
||||
}
|
||||
},
|
||||
[aui]
|
||||
)
|
||||
|
||||
// A draft-restore-on-mount that fires while the core may still be unbound,
|
||||
// exactly like loadIntoComposer/clearDraft do on startup.
|
||||
try {
|
||||
setComposerText('restored draft')
|
||||
} catch (err) {
|
||||
onError(err)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
describe('setComposerText guard (#49903)', () => {
|
||||
it('swallows the unbound-core throw at startup instead of crashing the renderer', () => {
|
||||
const applied: string[] = []
|
||||
const bound = { current: false }
|
||||
const onError = vi.fn()
|
||||
|
||||
expect(() => render(<Harness applied={applied} bound={bound} onError={onError} />)).not.toThrow()
|
||||
|
||||
// The guard absorbed the throw — nothing escaped to the renderer, and no
|
||||
// assistant-ui write landed (core was unbound).
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(applied).toEqual([])
|
||||
})
|
||||
|
||||
it('writes through to the composer once the core is bound', () => {
|
||||
const applied: string[] = []
|
||||
const bound = { current: true }
|
||||
const onError = vi.fn()
|
||||
|
||||
act(() => {
|
||||
render(<Harness applied={applied} bound={bound} onError={onError} />)
|
||||
})
|
||||
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(applied).toEqual(['restored draft'])
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -42,22 +43,23 @@ export function ContextMenu({
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
|
||||
)}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
title={state.tools.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="0.875rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<Tip label={state.tools.label} side="top">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
|
||||
)}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="0.875rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="start" className={cn('w-60', composerPanelCard)} side="top" sideOffset={6}>
|
||||
<DropdownMenuLabel className="px-2 pb-0.5 pt-0.5 text-[0.625rem] font-semibold uppercase tracking-wider text-(--ui-text-tertiary)">
|
||||
{c.attachLabel}
|
||||
|
||||
@@ -34,6 +34,7 @@ interface InsertRefsDetail {
|
||||
const FOCUS_EVENT = 'hermes:composer-focus'
|
||||
const INSERT_EVENT = 'hermes:composer-insert'
|
||||
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
|
||||
const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle'
|
||||
|
||||
let activeTarget: ComposerTarget = 'main'
|
||||
|
||||
@@ -105,6 +106,13 @@ export const requestComposerInsertRefs = (
|
||||
export const onComposerInsertRefsRequest = (handler: (detail: InsertRefsDetail) => void) =>
|
||||
subscribe<InsertRefsDetail>(INSERT_REFS_EVENT, handler)
|
||||
|
||||
/** Toggle the active composer's voice conversation — the `composer.voice`
|
||||
* hotkey (Ctrl+B) reaching into the composer that owns the voice state. */
|
||||
export const requestVoiceToggle = () => dispatch<{ at: number }>(VOICE_TOGGLE_EVENT, { at: Date.now() })
|
||||
|
||||
export const onComposerVoiceToggleRequest = (handler: () => void) =>
|
||||
subscribe<{ at: number }>(VOICE_TOGGLE_EVENT, () => handler())
|
||||
|
||||
/**
|
||||
* Focus a composer input across React commit + browser focus restore.
|
||||
*
|
||||
|
||||
@@ -79,7 +79,8 @@ import {
|
||||
markActiveComposer,
|
||||
onComposerFocusRequest,
|
||||
onComposerInsertRefsRequest,
|
||||
onComposerInsertRequest
|
||||
onComposerInsertRequest,
|
||||
onComposerVoiceToggleRequest
|
||||
} from './focus'
|
||||
import { HelpHint } from './help-hint'
|
||||
import { useAtCompletions } from './hooks/use-at-completions'
|
||||
@@ -193,6 +194,32 @@ export function ChatBar({
|
||||
}: ChatBarProps) {
|
||||
const aui = useAui()
|
||||
const draft = useAuiState(s => s.composer.text)
|
||||
|
||||
// assistant-ui's composer *mutators* (setText/send/…) throw "Composer is not
|
||||
// available" when the thread's composer core isn't bound yet — and unlike the
|
||||
// read path (`s.composer.text`, which is null-safe), there's no graceful
|
||||
// fallback. There's a startup/thread-swap window where this ChatBar's mount
|
||||
// effects (draft restore, clearDraft, external inserts) run before the core
|
||||
// binds; the popout refactor (#49488) widened it by moving the composer out
|
||||
// of the contain wrapper into a sibling of the thread, so the throw began
|
||||
// surfacing as an uncaught error that wedged the desktop input (#49903).
|
||||
//
|
||||
// Guard every mutation: if the core isn't ready, no-op the assistant-ui write.
|
||||
// The contentEditable DOM + draftRef already hold the text, and the
|
||||
// draft⇄editor sync reconciles composer state once the core attaches, so the
|
||||
// draft is never lost — only the (premature) state push is skipped.
|
||||
const setComposerText = useCallback(
|
||||
(value: string) => {
|
||||
try {
|
||||
aui.composer().setText(value)
|
||||
} catch {
|
||||
// Composer core not bound yet — DOM/draftRef carry the text; the sync
|
||||
// effect re-applies it after bind. Swallow so the input stays usable.
|
||||
}
|
||||
},
|
||||
[aui]
|
||||
)
|
||||
|
||||
const attachments = useStore($composerAttachments)
|
||||
const queuedPromptsBySession = useStore($queuedPromptsBySession)
|
||||
const statusItemsBySession = useStore($statusItemsBySession)
|
||||
@@ -370,7 +397,7 @@ export function ChatBar({
|
||||
const next = `${base}${sep}${value}`
|
||||
|
||||
draftRef.current = next
|
||||
aui.composer().setText(next)
|
||||
setComposerText(next)
|
||||
|
||||
const editor = editorRef.current
|
||||
|
||||
@@ -381,7 +408,7 @@ export function ChatBar({
|
||||
|
||||
setFocusRequestId(id => id + 1)
|
||||
},
|
||||
[aui]
|
||||
[setComposerText]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -591,7 +618,7 @@ export function ChatBar({
|
||||
const nextDraft = `${currentDraft}${sep}${text}`
|
||||
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
setComposerText(nextDraft)
|
||||
|
||||
// Push the new text into the contentEditable editor directly. Setting the
|
||||
// assistant-ui composer state alone is not enough: the draft→editor sync
|
||||
@@ -624,7 +651,7 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
setComposerText(nextDraft)
|
||||
requestMainFocus()
|
||||
|
||||
return true
|
||||
@@ -710,7 +737,7 @@ export function ChatBar({
|
||||
|
||||
if (nextDraft !== draftRef.current) {
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
setComposerText(nextDraft)
|
||||
}
|
||||
|
||||
window.setTimeout(refreshTrigger, 0)
|
||||
@@ -836,7 +863,7 @@ export function ChatBar({
|
||||
renderComposerContents(editor, prefix)
|
||||
placeCaretEnd(editor)
|
||||
draftRef.current = composerPlainText(editor)
|
||||
aui.composer().setText(draftRef.current)
|
||||
setComposerText(draftRef.current)
|
||||
closeTrigger()
|
||||
runAction()
|
||||
requestMainFocus()
|
||||
@@ -864,7 +891,7 @@ export function ChatBar({
|
||||
|
||||
const finish = () => {
|
||||
draftRef.current = composerPlainText(editor)
|
||||
aui.composer().setText(draftRef.current)
|
||||
setComposerText(draftRef.current)
|
||||
requestMainFocus()
|
||||
keepTriggerOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
|
||||
}
|
||||
@@ -1316,17 +1343,17 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
const clearDraft = useCallback(() => {
|
||||
aui.composer().setText('')
|
||||
setComposerText('')
|
||||
draftRef.current = ''
|
||||
|
||||
if (editorRef.current) {
|
||||
editorRef.current.replaceChildren()
|
||||
}
|
||||
}, [aui])
|
||||
}, [setComposerText])
|
||||
|
||||
const loadIntoComposer = (text: string, attachments: ComposerAttachment[]) => {
|
||||
draftRef.current = text
|
||||
aui.composer().setText(text)
|
||||
setComposerText(text)
|
||||
$composerAttachments.set(cloneAttachments(attachments))
|
||||
|
||||
const editor = editorRef.current
|
||||
@@ -1699,7 +1726,7 @@ export function ChatBar({
|
||||
|
||||
if (domText !== draftRef.current) {
|
||||
draftRef.current = domText
|
||||
aui.composer().setText(domText)
|
||||
setComposerText(domText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1818,6 +1845,24 @@ export function ChatBar({
|
||||
pendingResponse
|
||||
})
|
||||
|
||||
// The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting
|
||||
// with STT unconfigured lets the conversation surface its own "configure
|
||||
// speech-to-text" notice rather than silently no-opping.
|
||||
const toggleVoiceConversation = useCallback(() => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (voiceConversationActive) {
|
||||
setVoiceConversationActive(false)
|
||||
void conversation.end()
|
||||
} else {
|
||||
setVoiceConversationActive(true)
|
||||
}
|
||||
}, [conversation, disabled, voiceConversationActive])
|
||||
|
||||
useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation])
|
||||
|
||||
const contextMenu = (
|
||||
<ContextMenu
|
||||
onInsertText={insertText}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { ChevronDown } from '@/lib/icons'
|
||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||
@@ -74,34 +75,36 @@ export function ModelPill({
|
||||
|
||||
if (!model.modelMenuContent) {
|
||||
return (
|
||||
<Button
|
||||
aria-label={copy.openModelPicker}
|
||||
className={pillClass}
|
||||
disabled={disabled}
|
||||
onClick={() => setModelPickerOpen(true)}
|
||||
title={copy.openModelPicker}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Tip label={copy.openModelPicker} side="top">
|
||||
<Button
|
||||
aria-label={title}
|
||||
aria-label={copy.openModelPicker}
|
||||
className={pillClass}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
onClick={() => setModelPickerOpen(true)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<Tip label={title} side="top">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={title}
|
||||
className={pillClass}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</Tip>
|
||||
<DropdownMenuContent align="end" className="w-64 p-0" side="top" sideOffset={8}>
|
||||
<ModelMenuCloseContext.Provider value={() => setOpen(false)}>
|
||||
{model.modelMenuContent}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Clock,
|
||||
Cpu,
|
||||
Download,
|
||||
Egg,
|
||||
Globe,
|
||||
type IconComponent,
|
||||
Info,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
Moon,
|
||||
Package,
|
||||
Palette,
|
||||
PawPrint,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
@@ -40,8 +42,9 @@ import {
|
||||
Zap
|
||||
} from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||
import { $commandPaletteOpen, $commandPalettePage, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette'
|
||||
import { $bindings } from '@/store/keybinds'
|
||||
import { openPetGenerate } from '@/store/pet-generate'
|
||||
import { runGatewayRestart } from '@/store/system-actions'
|
||||
import { luminance } from '@/themes/color'
|
||||
import { type ThemeMode, useTheme } from '@/themes/context'
|
||||
@@ -64,6 +67,7 @@ import { fieldCopyForSchemaKey } from '../settings/field-copy'
|
||||
import { prettyName } from '../settings/helpers'
|
||||
|
||||
import { MarketplaceThemePage } from './marketplace-theme-page'
|
||||
import { PetInlineToggle, PetPalettePage } from './pet-palette-page'
|
||||
|
||||
interface PaletteItem {
|
||||
/** Keybind action id — its live combo renders as a hotkey hint. */
|
||||
@@ -207,6 +211,7 @@ function themeSupportsMode(name: string, target: 'light' | 'dark'): boolean {
|
||||
export function CommandPalette() {
|
||||
const { t } = useI18n()
|
||||
const open = useStore($commandPaletteOpen)
|
||||
const pendingPage = useStore($commandPalettePage)
|
||||
const bindings = useStore($bindings)
|
||||
const navigate = useNavigate()
|
||||
const { availableThemes, resolvedMode, setMode, setTheme, themeName } = useTheme()
|
||||
@@ -252,6 +257,14 @@ export function CommandPalette() {
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Deep-link into a nested page (e.g. `/pet list` → pets picker).
|
||||
useEffect(() => {
|
||||
if (open && pendingPage) {
|
||||
setPage(pendingPage)
|
||||
$commandPalettePage.set(null)
|
||||
}
|
||||
}, [open, pendingPage])
|
||||
|
||||
const go = useCallback((path: string) => () => navigate(path), [navigate])
|
||||
|
||||
// Step up one nested page (or back to the root list), clearing the filter so
|
||||
@@ -391,6 +404,20 @@ export function CommandPalette() {
|
||||
keywords: ['appearance', 'color mode', 'brightness', 'dark', 'light', 'system'],
|
||||
label: cc.changeColorMode,
|
||||
to: 'color-mode'
|
||||
},
|
||||
{
|
||||
icon: PawPrint,
|
||||
id: 'appearance-pets',
|
||||
keywords: ['pet', 'petdex', 'mascot', 'pets', '/pet', 'paw'],
|
||||
label: cc.pets.title,
|
||||
to: 'pets'
|
||||
},
|
||||
{
|
||||
icon: Egg,
|
||||
id: 'appearance-generate-pet',
|
||||
keywords: ['pet', 'generate', 'create', 'make', 'new pet', 'mascot', 'hatch', 'ai'],
|
||||
label: cc.generatePet.title,
|
||||
run: () => openPetGenerate()
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -559,6 +586,12 @@ export function CommandPalette() {
|
||||
}
|
||||
]
|
||||
},
|
||||
// Server-driven page: browse petdex gallery, adopt/switch, toggle off.
|
||||
pets: {
|
||||
title: t.commandCenter.pets.title,
|
||||
placeholder: t.commandCenter.pets.placeholder,
|
||||
groups: []
|
||||
},
|
||||
// Server-driven page: items come from the Marketplace, rendered by
|
||||
// <MarketplaceThemePage> (loader + live search + per-row install).
|
||||
'install-theme': {
|
||||
@@ -629,49 +662,57 @@ export function CommandPalette() {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
goBack()
|
||||
|
||||
return
|
||||
}
|
||||
}}
|
||||
onValueChange={setSearch}
|
||||
placeholder={placeholder}
|
||||
right={page === 'pets' ? <PetInlineToggle /> : undefined}
|
||||
value={search}
|
||||
/>
|
||||
<CommandList className="dt-portal-scrollbar max-h-[min(20rem,56vh)]">
|
||||
{page === 'install-theme' ? (
|
||||
{/* Server-driven pages render their own list; the rest show groups. */}
|
||||
{page === 'pets' ? (
|
||||
<PetPalettePage onGenerate={() => { closeCommandPalette(); openPetGenerate() }} search={search} />
|
||||
) : page === 'install-theme' ? (
|
||||
<MarketplaceThemePage onPickTheme={setTheme} search={search} />
|
||||
) : (
|
||||
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
|
||||
)}
|
||||
{visibleGroups.map((group, index) => (
|
||||
<CommandGroup
|
||||
className={HUD_HEADING}
|
||||
heading={group.heading}
|
||||
key={group.heading ?? `palette-group-${index}`}
|
||||
>
|
||||
{group.items.map(item => {
|
||||
const Icon = item.icon
|
||||
const combo = item.action ? bindings[item.action]?.[0] : undefined
|
||||
<>
|
||||
<CommandEmpty>{t.commandCenter.noResults}</CommandEmpty>
|
||||
{visibleGroups.map((group, index) => (
|
||||
<CommandGroup
|
||||
className={HUD_HEADING}
|
||||
heading={group.heading}
|
||||
key={group.heading ?? `palette-group-${index}`}
|
||||
>
|
||||
{group.items.map(item => {
|
||||
const Icon = item.icon
|
||||
const combo = item.action ? bindings[item.action]?.[0] : undefined
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
className={cn(HUD_ITEM, HUD_TEXT)}
|
||||
key={item.id}
|
||||
keywords={item.keywords}
|
||||
onSelect={() => handleSelect(item)}
|
||||
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
|
||||
{item.to && (
|
||||
<ChevronRight
|
||||
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
|
||||
/>
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
return (
|
||||
<CommandItem
|
||||
className={cn(HUD_ITEM, HUD_TEXT)}
|
||||
key={item.id}
|
||||
keywords={item.keywords}
|
||||
onSelect={() => handleSelect(item)}
|
||||
value={`${item.label} ${item.keywords?.join(' ') ?? ''} ${item.id}`}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{item.label}</span>
|
||||
{combo && <KbdCombo className="ml-auto opacity-55" combo={combo} size="sm" />}
|
||||
{item.to && (
|
||||
<ChevronRight
|
||||
className={cn('size-3.5 shrink-0 text-muted-foreground/70', !combo && 'ml-auto')}
|
||||
/>
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogPrimitive.Content>
|
||||
|
||||
212
apps/desktop/src/app/command-palette/pet-palette-page.tsx
Normal file
212
apps/desktop/src/app/command-palette/pet-palette-page.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Cmd-K "Pets…" page — browse the petdex gallery, adopt/switch, toggle off.
|
||||
*
|
||||
* A thin view over the `pet-gallery` store: it subscribes to the shared atoms
|
||||
* and calls the store's actions. The store owns fetching, caching, the thumb
|
||||
* cache, and optimistic mutations, so reopening this page is instant and a
|
||||
* toggle never re-pulls the network gallery.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
import { HUD_ITEM, HUD_TEXT } from '@/app/floating-hud'
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { PetThumb } from '@/components/pet/pet-thumb'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Egg, Loader2, PawPrint } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$petBusy,
|
||||
$petGallery,
|
||||
$petGalleryError,
|
||||
$petGalleryStatus,
|
||||
adoptPet,
|
||||
loadPetGallery,
|
||||
loadPetThumb,
|
||||
rankedGalleryPets,
|
||||
setPetEnabled
|
||||
} from '@/store/pet-gallery'
|
||||
|
||||
interface PetPalettePageProps {
|
||||
search: string
|
||||
/** Navigate to the "generate a pet" page (rendered as a header action). */
|
||||
onGenerate?: () => void
|
||||
}
|
||||
|
||||
export function PetPalettePage({ search, onGenerate }: PetPalettePageProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.pets
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
|
||||
const gallery = useStore($petGallery)
|
||||
const status = useStore($petGalleryStatus)
|
||||
const error = useStore($petGalleryError)
|
||||
const busy = useStore($petBusy)
|
||||
|
||||
useEffect(() => {
|
||||
void loadPetGallery(requestGateway)
|
||||
}, [requestGateway])
|
||||
|
||||
const enabled = gallery?.enabled ?? false
|
||||
const active = gallery?.active ?? ''
|
||||
|
||||
const shown = useMemo(() => rankedGalleryPets(gallery, search).slice(0, 50), [gallery, search])
|
||||
|
||||
const adopt = (slug: string) => {
|
||||
void adoptPet(requestGateway, slug, copy.adoptFailed).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
if (status === 'loading' && !gallery) {
|
||||
return <Status icon={<Loader2 className="size-3.5 animate-spin" />} text={copy.loading} />
|
||||
}
|
||||
|
||||
if (status === 'stale') {
|
||||
return <Status text={copy.staleBackend} tone="error" />
|
||||
}
|
||||
|
||||
if (!gallery?.pets.length && error) {
|
||||
return <Status text={error} tone="error" />
|
||||
}
|
||||
|
||||
const mutating = Boolean(busy)
|
||||
|
||||
return (
|
||||
<div role="listbox">
|
||||
{onGenerate && (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md text-left text-foreground transition-colors hover:bg-(--chrome-action-hover)',
|
||||
HUD_ITEM,
|
||||
HUD_TEXT
|
||||
)}
|
||||
onClick={onGenerate}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-(--chrome-action-hover)">
|
||||
<Egg className="size-4" />
|
||||
</span>
|
||||
<span className="font-medium">{t.commandCenter.generatePet.title}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{error}</p>}
|
||||
|
||||
{shown.length === 0 ? (
|
||||
<Status text={copy.empty} />
|
||||
) : (
|
||||
shown.map(pet => {
|
||||
const isActive = enabled && pet.slug === active
|
||||
const isBusy = busy === pet.slug
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-md text-left transition-colors hover:bg-(--chrome-action-hover) disabled:opacity-60',
|
||||
HUD_ITEM,
|
||||
HUD_TEXT,
|
||||
isActive && 'bg-(--chrome-action-hover)/70'
|
||||
)}
|
||||
disabled={mutating && !isBusy}
|
||||
key={pet.slug}
|
||||
onClick={() => adopt(pet.slug)}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<PetThumb
|
||||
alt={pet.displayName}
|
||||
load={(slug, url) => loadPetThumb(requestGateway, slug, url)}
|
||||
size={32}
|
||||
slug={pet.slug}
|
||||
url={pet.spritesheetUrl}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate font-medium">{pet.displayName}</span>
|
||||
{pet.generated && (
|
||||
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 py-px text-[0.625rem] font-medium text-primary">
|
||||
{copy.generatedTag}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-[0.6875rem] text-muted-foreground/80">
|
||||
{pet.slug}
|
||||
{pet.installed ? ` · ${copy.installed}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-auto flex shrink-0 items-center text-[0.6875rem] text-muted-foreground">
|
||||
{isBusy ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : isActive ? (
|
||||
<Check className="size-3.5 text-foreground" />
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Single on/off toggle, rendered inline on the palette's search row (see
|
||||
* `CommandInput`'s `right` slot). The paw lights up when pets are on. Reads the
|
||||
* same shared gallery atoms, so it stays in sync with the list below.
|
||||
*/
|
||||
export function PetInlineToggle() {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.pets
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const gallery = useStore($petGallery)
|
||||
const busy = useStore($petBusy)
|
||||
|
||||
if (!gallery) {
|
||||
return null
|
||||
}
|
||||
|
||||
const enabled = gallery.enabled
|
||||
|
||||
const toggle = () => {
|
||||
void setPetEnabled(requestGateway, !enabled, {
|
||||
noneAvailable: copy.noneAvailable,
|
||||
fallback: copy.toggleFailed
|
||||
}).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label={enabled ? copy.turnOff : copy.turnOn}
|
||||
aria-pressed={enabled}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-md p-1.5 transition-colors disabled:opacity-50',
|
||||
enabled ? 'bg-(--chrome-action-hover) text-foreground' : 'text-muted-foreground hover:bg-(--chrome-action-hover)/60'
|
||||
)}
|
||||
disabled={Boolean(busy)}
|
||||
onClick={toggle}
|
||||
// Don't steal focus from the search input on click.
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
title={enabled ? copy.turnOff : copy.turnOn}
|
||||
type="button"
|
||||
>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <PawPrint className="size-4" />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Status({ icon, text, tone }: { icon?: React.ReactNode; text: string; tone?: 'error' }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 px-2 py-6 text-xs',
|
||||
tone === 'error' ? 'text-(--ui-red)' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -41,6 +41,8 @@ import {
|
||||
unpinSession
|
||||
} from '../store/layout'
|
||||
import { respondToApprovalAction } from '../store/native-notifications'
|
||||
import { setPetActivity } from '../store/pet'
|
||||
import { setPetOverlayOpenAppHandler, setPetOverlaySubmitHandler } from '../store/pet-overlay'
|
||||
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
|
||||
import {
|
||||
$activeGatewayProfile,
|
||||
@@ -52,6 +54,7 @@ import {
|
||||
} from '../store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$attentionSessionIds,
|
||||
$currentCwd,
|
||||
$freshDraftReady,
|
||||
$gatewayState,
|
||||
@@ -105,6 +108,7 @@ import { useKeybinds } from './hooks/use-keybinds'
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
|
||||
import { ModelPickerOverlay } from './model-picker-overlay'
|
||||
import { ModelVisibilityOverlay } from './model-visibility-overlay'
|
||||
import { PetGenerateOverlay } from './pet-generate/pet-generate-overlay'
|
||||
import { RightSidebarPane } from './right-sidebar'
|
||||
import { $terminalTakeover } from './right-sidebar/store'
|
||||
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
|
||||
@@ -841,6 +845,53 @@ export function DesktopController() {
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
// The popped-out pet drives two actions back into the app: send a prompt, and
|
||||
// open the most recent thread. Both are registered ONCE through refs that track
|
||||
// the latest callbacks — re-registering on every `submitText`/`resumeSession`
|
||||
// identity change left a brief window where the handler was nulled (cleanup
|
||||
// before re-register), which could drop a submit fired from the overlay (e.g.
|
||||
// creating a session from the new-session screen). The ref form keeps a stable,
|
||||
// always-current handler. Primary window only — it owns the overlay.
|
||||
const submitTextRef = useRef(submitText)
|
||||
submitTextRef.current = submitText
|
||||
const resumeSessionRef = useRef(resumeSession)
|
||||
resumeSessionRef.current = resumeSession
|
||||
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
setPetOverlaySubmitHandler(text => void submitTextRef.current(text))
|
||||
// Mail icon: $sessions is ordered most-recent-first; the pet is global (not
|
||||
// per session) so "most recent" is the right target. main.cjs already raised
|
||||
// the window before forwarding this.
|
||||
setPetOverlayOpenAppHandler(() => {
|
||||
const recent = $sessions.get()[0]
|
||||
|
||||
if (recent?.id) {
|
||||
void resumeSessionRef.current(recent.id)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
setPetOverlaySubmitHandler(null)
|
||||
setPetOverlayOpenAppHandler(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Mirror "a session is blocked on the user" (clarify/approval) into the pet's
|
||||
// awaitingInput flag so it shows the `waiting` pose. Lives on $petActivity so
|
||||
// it rides the same atom the pop-out overlay mirrors — no session list needed
|
||||
// there. Every window keeps its own in-window pet in sync.
|
||||
useEffect(() => {
|
||||
const sync = () => setPetActivity({ awaitingInput: $attentionSessionIds.get().length > 0 })
|
||||
|
||||
sync()
|
||||
|
||||
return $attentionSessionIds.listen(sync)
|
||||
}, [])
|
||||
|
||||
useGatewayBoot({
|
||||
handleGatewayEvent: handleDesktopGatewayEvent,
|
||||
onConnectionReady: c => {
|
||||
@@ -978,6 +1029,7 @@ export function DesktopController() {
|
||||
<GatewayConnectingOverlay />
|
||||
<BootFailureOverlay />
|
||||
<CommandPalette />
|
||||
<PetGenerateOverlay />
|
||||
<SessionSwitcher />
|
||||
|
||||
{settingsOpen && (
|
||||
|
||||
@@ -40,6 +40,13 @@ import {
|
||||
} from '@/store/session'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
// After this many consecutive failed reconnects (≈45s with the 1→15s backoff)
|
||||
// raise a recoverable boot error. Otherwise a dropped remote gateway loops the
|
||||
// backoff forever behind the fullscreen CONNECTING overlay with no way to reach
|
||||
// Settings / sign in / switch to local — the "lost connection breaks the app"
|
||||
// dead end. The next successful reconnect clears it.
|
||||
const RECONNECT_ESCALATE_AFTER = 6
|
||||
|
||||
interface GatewayBootOptions {
|
||||
handleGatewayEvent: (event: RpcEvent) => void
|
||||
onConnectionReady: (
|
||||
@@ -105,6 +112,10 @@ export function useGatewayBoot({
|
||||
// tick — a stale OAuth ticket fails every attempt and would otherwise stack
|
||||
// identical error toasts (and their haptics). Reset on the next clean open.
|
||||
let reauthNotified = false
|
||||
// Raised once the reconnect loop crosses RECONNECT_ESCALATE_AFTER so the
|
||||
// recovery overlay replaces the dead-end CONNECTING screen. Reset on a clean
|
||||
// open or a manual/wake-driven reconnect.
|
||||
let escalated = false
|
||||
|
||||
// Wrap the live getter in a call so TS control-flow analysis doesn't narrow
|
||||
// `connectionState` to a constant across the early-return guards (the state
|
||||
@@ -171,6 +182,11 @@ export function useGatewayBoot({
|
||||
reconnecting = false
|
||||
|
||||
if (!cancelled && !gatewayOpen()) {
|
||||
if (reconnectAttempt >= RECONNECT_ESCALATE_AFTER && !escalated) {
|
||||
escalated = true
|
||||
failDesktopBoot(translateNow('boot.errors.gatewayConnectionLost'))
|
||||
}
|
||||
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
@@ -197,6 +213,7 @@ export function useGatewayBoot({
|
||||
|
||||
clearReconnectTimer()
|
||||
reconnectAttempt = 0
|
||||
escalated = false
|
||||
reconnectSecondaryGateways()
|
||||
|
||||
if (!gatewayOpen()) {
|
||||
@@ -230,6 +247,7 @@ export function useGatewayBoot({
|
||||
if (st === 'open') {
|
||||
reconnectAttempt = 0
|
||||
reauthNotified = false
|
||||
escalated = false
|
||||
clearReconnectTimer()
|
||||
|
||||
// A revalidate-driven reconnect can rebuild the backend in place when the
|
||||
|
||||
@@ -94,7 +94,7 @@ export function useGatewayRequest() {
|
||||
}, [])
|
||||
|
||||
const requestGateway = useCallback(
|
||||
async <T>(method: string, params: Record<string, unknown> = {}) => {
|
||||
async <T>(method: string, params: Record<string, unknown> = {}, timeoutMs?: number, signal?: AbortSignal) => {
|
||||
const gateway = gatewayRef.current
|
||||
|
||||
if (!gateway) {
|
||||
@@ -102,7 +102,7 @@ export function useGatewayRequest() {
|
||||
}
|
||||
|
||||
try {
|
||||
return await gateway.request<T>(method, params)
|
||||
return await gateway.request<T>(method, params, timeoutMs, signal)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
@@ -128,7 +128,7 @@ export function useGatewayRequest() {
|
||||
throw error
|
||||
}
|
||||
|
||||
return recovered.request<T>(method, params)
|
||||
return recovered.request<T>(method, params, timeoutMs, signal)
|
||||
}
|
||||
},
|
||||
[ensureGatewayOpen]
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
import { openNewSessionInNewWindow } from '@/store/windows'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { requestComposerFocus } from '../chat/composer/focus'
|
||||
import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus'
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
||||
import {
|
||||
AGENTS_ROUTE,
|
||||
@@ -114,6 +114,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
|
||||
'composer.focus': () => requestComposerFocus('main'),
|
||||
'composer.modelPicker': () => setModelPickerOpen(true),
|
||||
'composer.voice': requestVoiceToggle,
|
||||
|
||||
'nav.commandPalette': toggleCommandPalette,
|
||||
'nav.commandCenter': deps.toggleCommandCenter,
|
||||
|
||||
19
apps/desktop/src/app/hooks/use-route-overlay-active.ts
Normal file
19
apps/desktop/src/app/hooks/use-route-overlay-active.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { appViewForPath, isOverlayView } from '@/app/routes'
|
||||
|
||||
/**
|
||||
* True while a full-screen route overlay (settings, agents, command-center, …)
|
||||
* is showing.
|
||||
*
|
||||
* A portaled Radix modal sits above the app shell, so it would cover such a
|
||||
* route. Any modal that sends the user to one (e.g. "set up image generation" →
|
||||
* `/settings`) can `if (useRouteOverlayActive()) return null` to *yield* the
|
||||
* screen — its open state lives in a store, so it stays open — and reappear,
|
||||
* re-running its mount effects (a free refresh), when the route overlay closes.
|
||||
*/
|
||||
export function useRouteOverlayActive(): boolean {
|
||||
const { pathname } = useLocation()
|
||||
|
||||
return isOverlayView(appViewForPath(pathname))
|
||||
}
|
||||
125
apps/desktop/src/app/pet-generate/components/draft-grid.tsx
Normal file
125
apps/desktop/src/app/pet-generate/components/draft-grid.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { PawPrint } from '@/lib/icons'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const VARIANT_COUNT = 4
|
||||
|
||||
interface DraftGridProps {
|
||||
drafts: { index: number; dataUri: string }[]
|
||||
generating: boolean
|
||||
hasDrafts: boolean
|
||||
onCancel: () => void
|
||||
onHatch: () => void
|
||||
onRemix: (draft: { index: number; dataUri: string }) => void
|
||||
onSelect: (index: number) => void
|
||||
selected: number | null
|
||||
}
|
||||
|
||||
export function DraftGrid({
|
||||
drafts,
|
||||
generating,
|
||||
hasDrafts,
|
||||
onCancel,
|
||||
onHatch,
|
||||
onRemix,
|
||||
onSelect,
|
||||
selected
|
||||
}: DraftGridProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
|
||||
const slots = generating
|
||||
? Array.from({ length: VARIANT_COUNT }, (_, i) => drafts.find(draft => draft.index === i) ?? null)
|
||||
: drafts
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<span className={cn(generating && 'shimmer shimmer-color-primary opacity-40', !generating && 'invisible')}>
|
||||
{copy.generating}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{Math.min(drafts.length, VARIANT_COUNT)}/{VARIANT_COUNT}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{slots.map((draft, i) => {
|
||||
// A streamed draft is selectable immediately — even mid-generation —
|
||||
// so the user can commit to one without waiting for the rest.
|
||||
const isSelected = draft != null && selected === draft.index
|
||||
|
||||
return (
|
||||
<div className="group relative aspect-[192/208]" key={draft ? `draft-${draft.index}` : `slot-${i}`}>
|
||||
<button
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center overflow-hidden',
|
||||
selectableCardClass({ active: isSelected, prominent: true })
|
||||
)}
|
||||
disabled={draft == null}
|
||||
onClick={() => draft != null && onSelect(draft.index)}
|
||||
type="button"
|
||||
>
|
||||
{draft != null ? (
|
||||
// Hatches into place as each draft streams back.
|
||||
<img
|
||||
alt=""
|
||||
className="pet-reveal size-full object-contain p-1.5"
|
||||
draggable={false}
|
||||
src={draft.dataUri}
|
||||
/>
|
||||
) : (
|
||||
// Incubating: a creme egg bouncing on its contact shadow.
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<PixelEggSprite index={i} mode="bounce" size={48} />
|
||||
<span className="pet-egg-shadow pet-egg-shadow--sm" style={{ marginTop: '-0.3rem' }} />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Remix: branch a new round off this look. Revealed on hover/focus. */}
|
||||
{draft != null && !generating && (
|
||||
<Tip label={copy.remix}>
|
||||
<Button
|
||||
aria-label={copy.remix}
|
||||
className={cn(
|
||||
'absolute right-1 top-1 z-20',
|
||||
'text-(--ui-text-tertiary) opacity-10 transition',
|
||||
'hover:bg-transparent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onRemix(draft)
|
||||
}}
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="git-branch" size={12} />
|
||||
</Button>
|
||||
</Tip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Same abort/go-back text link in both states (sits right under the grid);
|
||||
once drafts land, the full-width Hatch drops in below it. */}
|
||||
<Button className="self-center" onClick={onCancel} size="xs" variant="text">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
{hasDrafts && (
|
||||
<Button className="w-full" disabled={selected === null} onClick={onHatch}>
|
||||
<PawPrint />
|
||||
{copy.hatch}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
apps/desktop/src/app/pet-generate/components/empty-hint.tsx
Normal file
27
apps/desktop/src/app/pet-generate/components/empty-hint.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface EmptyHintProps {
|
||||
onExample: (prompt: string) => void
|
||||
}
|
||||
|
||||
// Creative seed prompts — specifics make better pets (petdex's own advice).
|
||||
// Short chips that wrap into a tight, centered cluster (capped width → 2 rows).
|
||||
const EXAMPLE_PROMPTS = ['bubble-tea otter', 'sock elf', 'pixel dragon', 'office cat', 'neon axolotl', 'moss golem']
|
||||
|
||||
export function EmptyHint({ onExample }: EmptyHintProps) {
|
||||
return (
|
||||
<div className="flex max-w-[300px] flex-wrap place-content-center place-items-center gap-2">
|
||||
{EXAMPLE_PROMPTS.map(example => (
|
||||
<Button
|
||||
className="h-auto w-fit rounded-full font-normal"
|
||||
key={example}
|
||||
onClick={() => onExample(`a ${example}`)}
|
||||
size="xs"
|
||||
variant="outline"
|
||||
>
|
||||
{example}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ExternalLink } from '@/lib/external-link'
|
||||
import { PawPrint, Settings2 } from '@/lib/icons'
|
||||
|
||||
interface GenerateUnavailableProps {
|
||||
onSetup: () => void
|
||||
}
|
||||
|
||||
// Shown when no reference-capable image backend is configured: generation is
|
||||
// impossible, so we replace the prompt entirely with a friendly path to set one
|
||||
// up (in-app) plus where to grab a key.
|
||||
export function GenerateUnavailable({ onSetup }: GenerateUnavailableProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<span className="grid size-11 place-items-center rounded-full bg-primary/10 text-primary">
|
||||
<PawPrint className="size-5" />
|
||||
</span>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[length:var(--conversation-text-font-size)] font-semibold">Add an image backend to generate</p>
|
||||
<p className="mx-auto max-w-[19rem] text-[length:var(--conversation-caption-font-size)] leading-relaxed text-(--ui-text-tertiary)">
|
||||
Hatching a custom pet needs a provider that can ground on a reference image.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onSetup} size="sm">
|
||||
<Settings2 className="size-4" />
|
||||
Set up image generation
|
||||
</Button>
|
||||
<p className="flex flex-wrap items-center justify-center gap-x-1.5 text-[0.6875rem] text-(--ui-text-tertiary)">
|
||||
<span>Grab a key from</span>
|
||||
<ExternalLink href="https://portal.nousresearch.com" showExternalIcon={false}>
|
||||
Nous Portal
|
||||
</ExternalLink>
|
||||
<span>·</span>
|
||||
<ExternalLink
|
||||
className="opacity-40 transition-opacity hover:opacity-100"
|
||||
href="https://openrouter.ai/keys"
|
||||
showExternalIcon={false}
|
||||
>
|
||||
OpenRouter
|
||||
</ExternalLink>
|
||||
<span>·</span>
|
||||
<ExternalLink
|
||||
className="opacity-40 transition-opacity hover:opacity-100"
|
||||
href="https://platform.openai.com/api-keys"
|
||||
showExternalIcon={false}
|
||||
>
|
||||
OpenAI
|
||||
</ExternalLink>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
137
apps/desktop/src/app/pet-generate/components/hatch-preview.tsx
Normal file
137
apps/desktop/src/app/pet-generate/components/hatch-preview.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { PetSprite } from '@/components/pet/pet-sprite'
|
||||
import { PetStarShower } from '@/components/pet/pet-star-shower'
|
||||
import { PixelEggSprite } from '@/components/pet/pixel-egg-sprite'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Loader2, PawPrint, RefreshCw } from '@/lib/icons'
|
||||
import { type PetInfo } from '@/store/pet'
|
||||
|
||||
import { frameCountForRow } from '../lib/frame-count'
|
||||
|
||||
const PREVIEW_SCALE = 0.7
|
||||
const PREVIEW_STATE_MS = 1400
|
||||
|
||||
const PREVIEW_ROWS = ['idle', 'waving', 'running-right', 'running-left', 'running', 'review', 'jumping', 'failed', 'waiting']
|
||||
|
||||
interface HatchPreviewProps {
|
||||
pet: PetInfo
|
||||
adopting: boolean
|
||||
error: string | null
|
||||
onAdopt: (name: string) => void
|
||||
onDiscard: () => void
|
||||
}
|
||||
|
||||
export function HatchPreview({ pet, adopting, error, onAdopt, onDiscard }: HatchPreviewProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
// Empty so the "Name your pet" placeholder shows; blank adopt keeps the
|
||||
// provisional name from the prompt.
|
||||
const [name, setName] = useState('')
|
||||
// Play the egg's crack/hatch frames once before swapping in the live pet.
|
||||
const [revealed, setRevealed] = useState(false)
|
||||
// Right after the egg cracks the pet plays its "yay" jump a couple times, then
|
||||
// hands off to the normal state-cycling preview.
|
||||
const [celebrating, setCelebrating] = useState(false)
|
||||
const [stateIndex, setStateIndex] = useState(0)
|
||||
const previewRows = (pet.stateRows?.length ? pet.stateRows : PREVIEW_ROWS).filter(row => frameCountForRow(pet, row) > 0)
|
||||
const rows = previewRows.length > 0 ? previewRows : ['idle']
|
||||
const activeRow = rows[stateIndex % rows.length] ?? 'idle'
|
||||
const canJump = frameCountForRow(pet, 'jumping') > 0
|
||||
const rowOverride = celebrating && canJump ? 'jumping' : activeRow
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setStateIndex(i => (i + 1) % rows.length), PREVIEW_STATE_MS)
|
||||
|
||||
return () => clearInterval(id)
|
||||
}, [rows.length])
|
||||
|
||||
// On reveal: celebrate (jump) ~2 loops, then drop into the cycling preview.
|
||||
useEffect(() => {
|
||||
if (!revealed) {
|
||||
return
|
||||
}
|
||||
|
||||
setCelebrating(true)
|
||||
|
||||
const id = setTimeout(() => {
|
||||
setCelebrating(false)
|
||||
setStateIndex(0)
|
||||
}, 2 * (pet.loopMs ?? 1100))
|
||||
|
||||
return () => clearTimeout(id)
|
||||
}, [revealed, pet.loopMs])
|
||||
|
||||
useEffect(() => {
|
||||
setStateIndex(0)
|
||||
setName('')
|
||||
setRevealed(false)
|
||||
setCelebrating(false)
|
||||
}, [pet.slug])
|
||||
|
||||
const previewInfo: PetInfo = { ...pet, scale: PREVIEW_SCALE }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{/* Fills the (now narrow) dialog so the pet frame is the screen width. */}
|
||||
<div className="relative flex aspect-[192/208] w-full items-center justify-center overflow-hidden rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary)">
|
||||
{revealed ? (
|
||||
<>
|
||||
<div className="relative inline-block">
|
||||
<span aria-hidden className="pet-contact-shadow" />
|
||||
<div className="pet-reveal relative z-10">
|
||||
<PetSprite info={previewInfo} rowOverride={rowOverride} />
|
||||
</div>
|
||||
</div>
|
||||
<PetStarShower />
|
||||
</>
|
||||
) : (
|
||||
// The egg cracks open, then we swap in the live pet.
|
||||
<PixelEggSprite
|
||||
mode="hatch"
|
||||
onDone={() => {
|
||||
setRevealed(true)
|
||||
triggerHaptic('crisp')
|
||||
}}
|
||||
size={150}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Input
|
||||
autoFocus
|
||||
className="w-full"
|
||||
onChange={event => setName(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
onAdopt(name)
|
||||
}
|
||||
}}
|
||||
placeholder={copy.namePlaceholder}
|
||||
value={name}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
<Button disabled={adopting} onClick={onDiscard} variant="ghost">
|
||||
<RefreshCw />
|
||||
{copy.startOver}
|
||||
</Button>
|
||||
<Button className="flex-1" disabled={adopting} onClick={() => onAdopt(name)}>
|
||||
{adopting ? <Loader2 className="animate-spin" /> : <PawPrint />}
|
||||
{copy.adopt}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { PetEggHatch } from '@/components/pet/pet-egg-hatch'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cancelHatch, type PetHatchStage } from '@/store/pet-generate'
|
||||
|
||||
interface HatchingViewProps {
|
||||
stage: PetHatchStage | null
|
||||
}
|
||||
|
||||
// The hatch progress screen — a beating egg with a phase-tracking subtitle
|
||||
// (per-row → composing → saving).
|
||||
export function HatchingView({ stage }: HatchingViewProps) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
|
||||
const subtitle = stage
|
||||
? stage.phase === 'row'
|
||||
? copy.hatchRow(stage.state ?? '', stage.done ?? 0, stage.total ?? 0)
|
||||
: stage.phase === 'compose'
|
||||
? copy.hatchComposing
|
||||
: copy.hatchSaving
|
||||
: copy.hatchingSub
|
||||
|
||||
return <PetEggHatch cancelLabel={t.common.cancel} onCancel={cancelHatch} subtitle={subtitle} />
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { Check, ChevronDown } from '@/lib/icons'
|
||||
import { $petGenProvider, $petGenProviders, setPetGenProvider } from '@/store/pet-generate'
|
||||
|
||||
// Image-backend picker for pet generation — the composer's model-pill pattern:
|
||||
// a quiet trigger + a dropdown of options. No per-option notes: every backend
|
||||
// resolves to the same faithful OpenAI image model, so there's no tradeoff to
|
||||
// describe. Hidden unless there are 2+ reference-capable backends (nothing to pick).
|
||||
export function ProviderPicker() {
|
||||
const providers = useStore($petGenProviders)
|
||||
const picked = useStore($petGenProvider)
|
||||
|
||||
if (providers.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fallback = providers.find(p => p.default) ?? providers[0]
|
||||
const current = providers.find(p => p.name === picked) ?? fallback
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{/* Plain text affordance (matches "Add a reference"), not a padded pill. */}
|
||||
<button
|
||||
className="flex h-6 items-center gap-1 text-[0.6875rem] text-(--ui-text-tertiary) transition hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
{current?.label}
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
{/* The picker lives inside the pet-gen Dialog (z-130) and portals to body,
|
||||
so lift its menu above the dialog or it opens behind it. */}
|
||||
<DropdownMenuContent align="start" className="z-[140]">
|
||||
{providers.map(provider => (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-1.5"
|
||||
key={provider.name}
|
||||
// Picking the default clears the override (no need to pin it).
|
||||
onSelect={() => setPetGenProvider(provider.default ? '' : provider.name)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-medium text-foreground">{provider.label}</span>
|
||||
{provider.name === current?.name && <Check className="size-3.5 text-primary" />}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { ImageLightbox } from '@/components/chat/zoomable-image'
|
||||
import { useImageDownload } from '@/hooks/use-image-download'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { X } from '@/lib/icons'
|
||||
|
||||
interface ReferenceChipProps {
|
||||
name: string
|
||||
onRemove: () => void
|
||||
src: string
|
||||
}
|
||||
|
||||
// The reference photo as an attachment chip: filename + thumbnail that opens
|
||||
// the shared image viewer (lightbox), with a remove affordance.
|
||||
export function ReferenceChip({ name, onRemove, src }: ReferenceChipProps) {
|
||||
const { t } = useI18n()
|
||||
const { download, saving } = useImageDownload(src)
|
||||
const [viewing, setViewing] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex h-6 items-center gap-2 self-start rounded-lg border border-border/60 bg-background/50 pl-1 pr-2">
|
||||
<button className="shrink-0" onClick={() => setViewing(true)} title={t.desktop.openImage} type="button">
|
||||
<img alt={name} className="size-4 rounded-md object-cover" src={src} />
|
||||
</button>
|
||||
|
||||
<span className="max-w-40 truncate text-[0.64rem] font-medium text-foreground/50">{name || 'Reference'}</span>
|
||||
<button
|
||||
aria-label="Remove reference"
|
||||
className="text-(--ui-text-tertiary) transition not-hover:opacity-50"
|
||||
onClick={onRemove}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
|
||||
<ImageLightbox
|
||||
alt={name}
|
||||
copy={t.desktop}
|
||||
onClick={download}
|
||||
onOpenChange={setViewing}
|
||||
open={viewing}
|
||||
saving={saving}
|
||||
src={src}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
26
apps/desktop/src/app/pet-generate/lib/frame-count.ts
Normal file
26
apps/desktop/src/app/pet-generate/lib/frame-count.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { type PetInfo } from '@/store/pet'
|
||||
|
||||
// Sprite row → the PetInfo frame-count key it resolves to (directional walks and
|
||||
// aliases collapse onto their base state).
|
||||
const ROW_TO_FRAME_KEY: Record<string, string> = {
|
||||
idle: 'idle',
|
||||
wave: 'wave',
|
||||
waving: 'wave',
|
||||
jump: 'jump',
|
||||
jumping: 'jump',
|
||||
run: 'run',
|
||||
running: 'run',
|
||||
'running-right': 'run',
|
||||
'running-left': 'run',
|
||||
failed: 'failed',
|
||||
review: 'review',
|
||||
waiting: 'waiting'
|
||||
}
|
||||
|
||||
// Real frame count for a row, preferring the concrete per-row count, then the
|
||||
// per-state count, then the mapped base state, then the sheet-wide default.
|
||||
export function frameCountForRow(pet: PetInfo, row: string): number {
|
||||
const mapped = ROW_TO_FRAME_KEY[row]
|
||||
|
||||
return pet.framesByRow?.[row] ?? pet.framesByState?.[row] ?? (mapped ? pet.framesByState?.[mapped] : undefined) ?? pet.framesPerState ?? 0
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
const DEFAULT_MAX_INPUT_BYTES = 16 * 1024 * 1024
|
||||
|
||||
function loadImage(url: string): Promise<HTMLImageElement> {
|
||||
const img = new Image()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = () => reject(new Error('unreadable image'))
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
// Read an image file as a downscaled PNG data URL. We decode from an object URL
|
||||
// (not readAsDataURL) so large files don't inflate into giant base64 strings
|
||||
// before we scale them down for generation.
|
||||
export async function readReferenceImage(
|
||||
file: File,
|
||||
max = 1024,
|
||||
maxInputBytes = DEFAULT_MAX_INPUT_BYTES
|
||||
): Promise<string> {
|
||||
if (file.size > maxInputBytes) {
|
||||
throw new Error('reference image too large')
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
|
||||
try {
|
||||
const img = await loadImage(objectUrl)
|
||||
const scale = Math.min(1, max / Math.max(img.width, img.height))
|
||||
const width = Math.max(1, Math.round(img.width * scale))
|
||||
const height = Math.max(1, Math.round(img.height * scale))
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('could not create canvas context')
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
return canvas.toDataURL('image/png')
|
||||
} finally {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}
|
||||
336
apps/desktop/src/app/pet-generate/pet-generate-content.tsx
Normal file
336
apps/desktop/src/app/pet-generate/pet-generate-content.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { SETTINGS_ROUTE } from '@/app/routes'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { GenerateButton } from '@/components/ui/generate-button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Egg, ImageIcon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$petGenAvailable,
|
||||
$petGenDrafts,
|
||||
$petGenError,
|
||||
$petGenInput,
|
||||
$petGenPreview,
|
||||
$petGenRefImage,
|
||||
$petGenRefName,
|
||||
$petGenRemixConfirmed,
|
||||
$petGenSelected,
|
||||
$petGenStage,
|
||||
$petGenStatus,
|
||||
adoptHatched,
|
||||
cancelGenerate,
|
||||
checkPetGenAvailable,
|
||||
cleanPetName,
|
||||
closePetGenerate,
|
||||
discardDrafts,
|
||||
discardHatched,
|
||||
generateDrafts,
|
||||
hatchSelected,
|
||||
markRemixConfirmed
|
||||
} from '@/store/pet-generate'
|
||||
|
||||
import { DraftGrid } from './components/draft-grid'
|
||||
import { EmptyHint } from './components/empty-hint'
|
||||
import { GenerateUnavailable } from './components/generate-unavailable'
|
||||
import { HatchPreview } from './components/hatch-preview'
|
||||
import { HatchingView } from './components/hatching-view'
|
||||
import { ProviderPicker } from './components/provider-picker'
|
||||
import { ReferenceChip } from './components/reference-chip'
|
||||
import { readReferenceImage } from './lib/read-reference-image'
|
||||
|
||||
// The generate → hatch → adopt controller. A thin view over the `pet-generate`
|
||||
// store; the store owns the steps and persists inputs across close/reopen.
|
||||
export function PetGenerateContent() {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.generatePet
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const status = useStore($petGenStatus)
|
||||
const error = useStore($petGenError)
|
||||
const available = useStore($petGenAvailable)
|
||||
// `null` = not yet probed → stay optimistic (show the prompt); only the
|
||||
// confirmed-no-backend case swaps in the setup card.
|
||||
const unavailable = available === false
|
||||
const drafts = useStore($petGenDrafts)
|
||||
const selected = useStore($petGenSelected)
|
||||
const preview = useStore($petGenPreview)
|
||||
const stage = useStore($petGenStage)
|
||||
|
||||
// Inputs live in atoms so they survive a close/reopen (and background runs).
|
||||
const prompt = useStore($petGenInput)
|
||||
const refImage = useStore($petGenRefImage)
|
||||
const refName = useStore($petGenRefName)
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// The draft awaiting the one-time "remix regenerates" confirmation.
|
||||
const [remixPending, setRemixPending] = useState<{ dataUri: string } | null>(null)
|
||||
|
||||
// Probe backend availability on open — and again whenever the content
|
||||
// remounts (e.g. after returning from the providers settings), so adding a
|
||||
// key flips the setup card to the prompt with no manual refresh.
|
||||
useEffect(() => {
|
||||
void checkPetGenAvailable(requestGateway)
|
||||
}, [requestGateway])
|
||||
|
||||
const busy = status === 'generating' || status === 'hatching'
|
||||
const hasDrafts = drafts.length > 0
|
||||
const generating = status === 'generating'
|
||||
|
||||
// The idle "describe a pet" state — egg + suggestions get generous, equidistant
|
||||
// breathing room (gap-4) from the prompt; the working states stay compact.
|
||||
const isEmptyState =
|
||||
!hasDrafts &&
|
||||
!generating &&
|
||||
status !== 'hatching' &&
|
||||
status !== 'preview' &&
|
||||
status !== 'adopting' &&
|
||||
status !== 'stale'
|
||||
|
||||
const generate = () => {
|
||||
if ((prompt.trim() || refImage) && !busy) {
|
||||
void generateDrafts(requestGateway, { prompt: prompt.trim(), referenceImage: refImage ?? undefined })
|
||||
}
|
||||
}
|
||||
|
||||
const clearReference = () => {
|
||||
$petGenRefImage.set(null)
|
||||
$petGenRefName.set('')
|
||||
}
|
||||
|
||||
const pickReference = (file: File | undefined) => {
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
const mapReferenceError = (reason: unknown): string => {
|
||||
const message = reason instanceof Error ? reason.message.toLowerCase() : ''
|
||||
|
||||
return message.includes('too large') ? copy.referenceImageTooLarge : copy.referenceImageInvalid
|
||||
}
|
||||
|
||||
void readReferenceImage(file)
|
||||
.then(dataUrl => {
|
||||
$petGenRefImage.set(dataUrl)
|
||||
$petGenRefName.set(file.name)
|
||||
// Clear picker-only errors once the reference is valid again.
|
||||
|
||||
if ($petGenStatus.get() === 'error' && $petGenDrafts.get().length === 0) {
|
||||
$petGenStatus.set('idle')
|
||||
$petGenError.set(null)
|
||||
}
|
||||
})
|
||||
.catch(reason => {
|
||||
$petGenRefImage.set(null)
|
||||
$petGenRefName.set('')
|
||||
$petGenError.set(mapReferenceError(reason))
|
||||
|
||||
if (!busy) {
|
||||
$petGenStatus.set('error')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// One-click an example prompt straight into a draft round.
|
||||
const runExample = (example: string) => {
|
||||
$petGenInput.set(example)
|
||||
void generateDrafts(requestGateway, { prompt: example })
|
||||
}
|
||||
|
||||
// A remix re-runs generation grounded on an existing draft — same prompt, stay
|
||||
// on step 2 — so the user explores variations without starting over.
|
||||
const runRemix = (draft: { dataUri: string }) => {
|
||||
void generateDrafts(requestGateway, { prompt: prompt.trim(), referenceImage: draft.dataUri })
|
||||
}
|
||||
|
||||
// Slow, and it replaces the current drafts — so confirm once, then remember it.
|
||||
const remixDraft = (draft: { dataUri: string }) => {
|
||||
if (busy) {
|
||||
return
|
||||
}
|
||||
|
||||
if ($petGenRemixConfirmed.get()) {
|
||||
runRemix(draft)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setRemixPending(draft)
|
||||
}
|
||||
|
||||
// Hatch the selected draft. The user can pick one before the rest stream in —
|
||||
// if so, abort the remaining generations first (keeping the drafts we have).
|
||||
// The prompt is grounding text, not a label; the user names it on reveal.
|
||||
const hatch = () => {
|
||||
if (selected === null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (generating) {
|
||||
cancelGenerate()
|
||||
}
|
||||
|
||||
void hatchSelected(requestGateway, { name: cleanPetName(prompt), prompt: prompt.trim() })
|
||||
}
|
||||
|
||||
const adopt = (finalName: string) => {
|
||||
void adoptHatched(requestGateway, finalName).then(out => {
|
||||
if (out.ok) {
|
||||
triggerHaptic('crisp')
|
||||
closePetGenerate()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The header title tracks the phase instead of sticking on "Generate a pet".
|
||||
const headerTitle =
|
||||
status === 'hatching' ? copy.spawning : status === 'preview' || status === 'adopting' ? copy.hatched : copy.title
|
||||
|
||||
// Send the user to set up a key without closing — the overlay yields to the
|
||||
// settings route (useRouteOverlayActive) and reappears + re-checks on return.
|
||||
const setupImageGen = () => navigate(`${SETTINGS_ROUTE}?tab=providers`)
|
||||
|
||||
// Prompt input only belongs on the describe/draft screens (and never when
|
||||
// there's no backend to generate with).
|
||||
const showPrompt = !unavailable && status !== 'hatching' && status !== 'preview' && status !== 'adopting'
|
||||
|
||||
return (
|
||||
<>
|
||||
{unavailable ? (
|
||||
<DialogTitle className="sr-only">{copy.title}</DialogTitle>
|
||||
) : (
|
||||
<DialogHeader>
|
||||
<DialogTitle icon={Egg}>{headerTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className={cn('flex min-h-0 flex-1 flex-col', isEmptyState ? 'gap-4' : 'gap-2.5')}>
|
||||
{/* Concept prompt with the inline sparkle generate/stop affordance (the
|
||||
same primitive as the commit-message + project-idea fields). */}
|
||||
{showPrompt && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="relative">
|
||||
<Input
|
||||
autoFocus
|
||||
className="pr-9"
|
||||
onChange={event => $petGenInput.set(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
generate()
|
||||
}
|
||||
}}
|
||||
placeholder={copy.placeholder}
|
||||
value={prompt}
|
||||
/>
|
||||
<GenerateButton
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
disabled={!prompt.trim() && !refImage}
|
||||
generating={generating}
|
||||
generatingLabel={t.common.cancel}
|
||||
label={copy.generate}
|
||||
// Inline cancel should match step-2 cancel semantics: abort and
|
||||
// return to step 1 (prompt retained for quick tweaks).
|
||||
onCancel={discardDrafts}
|
||||
onGenerate={generate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderPicker />
|
||||
{refImage ? (
|
||||
<ReferenceChip name={refName} onRemove={clearReference} src={refImage} />
|
||||
) : (
|
||||
<button
|
||||
className="ml-auto flex h-6 items-center gap-1.5 text-[0.6875rem] text-(--ui-text-tertiary) transition hover:text-foreground"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
type="button"
|
||||
>
|
||||
<ImageIcon className="size-3" />
|
||||
Add a reference
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Optional reference photo — make a pet from the user's own image.
|
||||
Styled like the chat composer's attachment pill. */}
|
||||
<Input
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={event => {
|
||||
pickReference(event.target.files?.[0])
|
||||
event.target.value = ''
|
||||
}}
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hatch failed but the drafts are still here — show why above the grid so
|
||||
the user can re-pick and retry without losing their options. */}
|
||||
{status === 'error' && hasDrafts && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error || copy.genericError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{unavailable ? (
|
||||
<GenerateUnavailable onSetup={setupImageGen} />
|
||||
) : status === 'stale' ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{copy.staleBackend}</AlertDescription>
|
||||
</Alert>
|
||||
) : status === 'hatching' ? (
|
||||
<HatchingView stage={stage} />
|
||||
) : (status === 'preview' || status === 'adopting') && preview ? (
|
||||
<HatchPreview
|
||||
adopting={status === 'adopting'}
|
||||
error={error}
|
||||
onAdopt={adopt}
|
||||
onDiscard={() => void discardHatched(requestGateway)}
|
||||
pet={preview}
|
||||
/>
|
||||
) : !hasDrafts && !generating ? (
|
||||
// Doubles as the error-empty state — the failure reason rides the
|
||||
// dialog's footer banner, so here we just offer the retry sparks.
|
||||
<EmptyHint onExample={runExample} />
|
||||
) : (
|
||||
<DraftGrid
|
||||
drafts={drafts}
|
||||
generating={generating}
|
||||
hasDrafts={hasDrafts}
|
||||
onCancel={discardDrafts}
|
||||
onHatch={hatch}
|
||||
onRemix={remixDraft}
|
||||
onSelect={index => $petGenSelected.set(index)}
|
||||
selected={selected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
confirmLabel={copy.remix}
|
||||
description={copy.remixConfirmBody}
|
||||
onClose={() => setRemixPending(null)}
|
||||
onConfirm={() => {
|
||||
markRemixConfirmed()
|
||||
|
||||
if (remixPending) {
|
||||
runRemix(remixPending)
|
||||
}
|
||||
}}
|
||||
open={remixPending !== null}
|
||||
title={copy.remixConfirmTitle}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
86
apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx
Normal file
86
apps/desktop/src/app/pet-generate/pet-generate-overlay.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* "Hatch a Pet" — a dedicated, Pokédex-style overlay for pet generation.
|
||||
*
|
||||
* Previously generation lived as a cramped nested page inside the Cmd-K command
|
||||
* palette (~34rem popover). This is its own full Radix dialog with room to
|
||||
* breathe: a device-framed header, its own concept prompt, a roomy draft grid
|
||||
* that streams in live, and the egg-hatch + reveal flow. It's a thin view over
|
||||
* the `pet-generate` store; the store owns the generate → hatch → adopt steps.
|
||||
*
|
||||
* This file is just the dialog shell + sizing; the flow lives in
|
||||
* `PetGenerateContent`, and each screen is its own atomic component under
|
||||
* `./components`.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$petGenDrafts,
|
||||
$petGenerateOpen,
|
||||
$petGenError,
|
||||
$petGenStatus,
|
||||
cleanupPetGenOnClose,
|
||||
closePetGenerate
|
||||
} from '@/store/pet-generate'
|
||||
|
||||
import { PetGenerateContent } from './pet-generate-content'
|
||||
|
||||
export function PetGenerateOverlay() {
|
||||
const { t } = useI18n()
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const open = useStore($petGenerateOpen)
|
||||
const status = useStore($petGenStatus)
|
||||
const error = useStore($petGenError)
|
||||
const drafts = useStore($petGenDrafts)
|
||||
|
||||
// Yield the screen to a full-screen route overlay (e.g. /settings while the
|
||||
// user adds an image-gen key) without tearing down — the store keeps us open,
|
||||
// and we reappear + re-check on return.
|
||||
if (useRouteOverlayActive()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) {
|
||||
cleanupPetGenOnClose(requestGateway)
|
||||
// Never interrupt in-flight work. Generating/hatching continues in the
|
||||
// background; only an unadopted finished preview is discarded on close.
|
||||
closePetGenerate()
|
||||
}
|
||||
}
|
||||
|
||||
// The draft screen needs room for the 2×2 grid; the single-pet screens
|
||||
// (hatch egg, reveal) shrink to the pet's frame so it isn't lost in a wide box.
|
||||
// `fitContent` lets the dialog size to content; the `min-w` floors each phase.
|
||||
const single = status === 'hatching' || status === 'preview' || status === 'adopting'
|
||||
const copy = t.commandCenter.generatePet
|
||||
|
||||
// The footer banner narrates the dialog's async state: the failure reason on a
|
||||
// dead-end error, else the "you can close this, we'll notify you" reassurance
|
||||
// while a generate/hatch runs in the background. On step 1, show a neutral ETA.
|
||||
const working = status === 'generating' || status === 'hatching'
|
||||
const errored = status === 'error' && drafts.length === 0
|
||||
const stepOne = status === 'idle' || status === 'ready'
|
||||
const banner = errored ? error || copy.genericError : working ? copy.backgroundHint : stepOne ? copy.slowProviderHint : undefined
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
banner={banner}
|
||||
bannerTone={errored ? 'error' : 'info'}
|
||||
// Cap the width so a long banner (e.g. a provider refusal) wraps instead
|
||||
// of stretching the dialog out; the min-w floors each phase.
|
||||
className={cn('gap-4 text-center', single ? 'min-w-[17rem] max-w-[20rem]' : 'min-w-[19rem] max-w-[22rem]')}
|
||||
fitContent
|
||||
>
|
||||
{open && <PetGenerateContent />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
38
apps/desktop/src/app/pet-overlay/overlay-root.tsx
Normal file
38
apps/desktop/src/app/pet-overlay/overlay-root.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import { ErrorBoundary } from '@/components/error-boundary'
|
||||
import { ThemeProvider } from '@/themes/context'
|
||||
|
||||
import { PetOverlayApp } from './pet-overlay-app'
|
||||
|
||||
/**
|
||||
* Boot the pet-overlay window. Loaded by the same bundle as the main app but
|
||||
* via `?win=overlay`, so it shares CSS/atoms while mounting a minimal, transparent
|
||||
* surface (no app shell, no gateway, no I18n — the bubble strings are inline).
|
||||
*
|
||||
* The index.html boot script paints an OPAQUE themed background to avoid a flash
|
||||
* in normal windows; the overlay must be see-through, so we force every host
|
||||
* layer transparent with a late, high-specificity style tag.
|
||||
*/
|
||||
export function mountPetOverlay(): void {
|
||||
const style = document.createElement('style')
|
||||
style.textContent = 'html,body,#root{background:transparent !important;}'
|
||||
document.head.appendChild(style)
|
||||
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (!root) {
|
||||
return
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary label="pet-overlay">
|
||||
<ThemeProvider>
|
||||
<PetOverlayApp />
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>
|
||||
)
|
||||
}
|
||||
345
apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx
Normal file
345
apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { PetBubble } from '@/components/pet/pet-bubble'
|
||||
import { PetSprite } from '@/components/pet/pet-sprite'
|
||||
import { Mail } from '@/lib/icons'
|
||||
import { $petActivity, $petInfo, setPetInfo } from '@/store/pet'
|
||||
import { setAwaitingResponse, setBusy } from '@/store/session'
|
||||
|
||||
/**
|
||||
* The pop-out overlay's only view: a transparent, draggable mascot with a mini
|
||||
* composer.
|
||||
*
|
||||
* This runs in a separate, gateway-less BrowserWindow (`?win=overlay`). It is a
|
||||
* pure puppet — the main renderer pushes the live pet state over IPC and we
|
||||
* mirror it into the same atoms the in-window pet reads, so `PetSprite` /
|
||||
* `PetBubble` render identically with zero extra logic.
|
||||
*
|
||||
* The window is a full rectangle but mostly transparent; we toggle OS-level
|
||||
* mouse click-through so only the sprite (or the open composer) is interactive
|
||||
* and the empty margins pass clicks through to whatever is behind.
|
||||
*
|
||||
* Gestures on the pet: drag to move it anywhere on screen (even outside the
|
||||
* app), shift-click to pop it back into the window, single-click to open a small
|
||||
* composer, double-click to toggle the app window (minimize ↔ restore). A mail
|
||||
* icon (shown only when a turn finished while you were away) raises the app on
|
||||
* the most recent thread.
|
||||
*/
|
||||
|
||||
// Below this much pointer travel, a press counts as a click, not a drag.
|
||||
const CLICK_SLOP_PX = 3
|
||||
// A second click within this window is a double-click (raise app) and cancels
|
||||
// the deferred single-click (open composer), so a double never flashes it open.
|
||||
const DOUBLE_CLICK_MS = 250
|
||||
|
||||
interface DragState {
|
||||
startX: number
|
||||
startY: number
|
||||
offX: number
|
||||
offY: number
|
||||
width: number
|
||||
height: number
|
||||
moved: boolean
|
||||
}
|
||||
|
||||
export function PetOverlayApp() {
|
||||
const info = useStore($petInfo)
|
||||
const [composerOpen, setComposerOpen] = useState(false)
|
||||
const [draft, setDraft] = useState('')
|
||||
// Mirrored from the main renderer: a finish landed while you were away.
|
||||
const [unread, setUnread] = useState(false)
|
||||
|
||||
const dragRef = useRef<DragState | null>(null)
|
||||
const petRef = useRef<HTMLDivElement | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
const ignoreRef = useRef(true)
|
||||
const composerOpenRef = useRef(false)
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
||||
const setIgnore = (ignore: boolean) => {
|
||||
if (ignoreRef.current !== ignore) {
|
||||
ignoreRef.current = ignore
|
||||
window.hermesDesktop?.petOverlay?.setIgnoreMouse(ignore)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror pushed state into the shared atoms so PetSprite/PetBubble just work.
|
||||
useEffect(() => {
|
||||
const off = window.hermesDesktop?.petOverlay?.onState(payload => {
|
||||
setPetInfo(payload.info)
|
||||
$petActivity.set(payload.activity ?? {})
|
||||
setBusy(Boolean(payload.busy))
|
||||
setAwaitingResponse(Boolean(payload.awaiting))
|
||||
setUnread(Boolean(payload.unread))
|
||||
})
|
||||
|
||||
// Tell the main renderer we're mounted so it pushes the current frame (the
|
||||
// subscribe-time pushes during open() can land before this view exists).
|
||||
window.hermesDesktop?.petOverlay?.control({ type: 'ready' })
|
||||
|
||||
return off
|
||||
}, [])
|
||||
|
||||
// Click-through: make only the sprite (or an open composer) interactive. With
|
||||
// ignore+forward, the renderer still receives mousemove so we can re-enable
|
||||
// hit-testing the moment the cursor returns to the pet.
|
||||
useEffect(() => {
|
||||
setIgnore(true)
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (dragRef.current || composerOpenRef.current) {
|
||||
setIgnore(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const el = petRef.current
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
const r = el.getBoundingClientRect()
|
||||
const over = ev.clientX >= r.left && ev.clientX <= r.right && ev.clientY >= r.top && ev.clientY <= r.bottom
|
||||
setIgnore(!over)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMove)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
clearTimeout(clickTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// The whole window must stay interactive while the composer is open (so the
|
||||
// input keeps focus); focus it on open. The overlay is a non-activating panel
|
||||
// (so it never steals the app's cmd/alt-tab anchor) — flip it focusable while
|
||||
// the composer needs the keyboard, then back to non-activating when it closes.
|
||||
useEffect(() => {
|
||||
composerOpenRef.current = composerOpen
|
||||
|
||||
window.hermesDesktop?.petOverlay?.setFocusable(composerOpen)
|
||||
|
||||
if (composerOpen) {
|
||||
setIgnore(false)
|
||||
// The OS window has to become key first (setFocusable + focus happen in
|
||||
// the main process), so focus the input on the next frame.
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}
|
||||
}, [composerOpen])
|
||||
|
||||
const onPetPointerDown = (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
;(e.target as Element).setPointerCapture?.(e.pointerId)
|
||||
dragRef.current = {
|
||||
height: window.outerHeight,
|
||||
moved: false,
|
||||
offX: e.screenX - window.screenX,
|
||||
offY: e.screenY - window.screenY,
|
||||
startX: e.screenX,
|
||||
startY: e.screenY,
|
||||
width: window.outerWidth
|
||||
}
|
||||
}
|
||||
|
||||
const onPetPointerMove = (e: React.PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
|
||||
if (!drag) {
|
||||
return
|
||||
}
|
||||
|
||||
if (Math.hypot(e.screenX - drag.startX, e.screenY - drag.startY) > CLICK_SLOP_PX) {
|
||||
drag.moved = true
|
||||
}
|
||||
|
||||
window.hermesDesktop?.petOverlay?.setBounds({
|
||||
height: drag.height,
|
||||
width: drag.width,
|
||||
x: e.screenX - drag.offX,
|
||||
y: e.screenY - drag.offY
|
||||
})
|
||||
}
|
||||
|
||||
const onPetPointerUp = (e: React.PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
dragRef.current = null
|
||||
;(e.target as Element).releasePointerCapture?.(e.pointerId)
|
||||
|
||||
if (!drag) {
|
||||
return
|
||||
}
|
||||
|
||||
if (drag.moved) {
|
||||
// A drag cancels any deferred single-click so the composer can't pop open
|
||||
// after you reposition the pet.
|
||||
clearTimeout(clickTimerRef.current)
|
||||
clickTimerRef.current = undefined
|
||||
|
||||
// Remember the spot on the desktop (screen coords) so the pet reopens here
|
||||
// next time / after a restart.
|
||||
window.hermesDesktop?.petOverlay?.control({
|
||||
bounds: { height: drag.height, width: drag.width, x: e.screenX - drag.offX, y: e.screenY - drag.offY },
|
||||
type: 'bounds'
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Shift-click always pops the pet back in (no double-click ambiguity).
|
||||
if (e.shiftKey) {
|
||||
window.hermesDesktop?.petOverlay?.control({ type: 'pop-in' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Double-click toggles the app window (minimize ↔ restore); defer the
|
||||
// single-click composer toggle so a double never flashes the composer open.
|
||||
if (clickTimerRef.current) {
|
||||
clearTimeout(clickTimerRef.current)
|
||||
clickTimerRef.current = undefined
|
||||
window.hermesDesktop?.petOverlay?.control({ type: 'toggle-app' })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
clickTimerRef.current = setTimeout(() => {
|
||||
clickTimerRef.current = undefined
|
||||
setComposerOpen(open => !open)
|
||||
}, DOUBLE_CLICK_MS)
|
||||
}
|
||||
|
||||
const send = () => {
|
||||
const text = draft.trim()
|
||||
|
||||
if (text) {
|
||||
window.hermesDesktop?.petOverlay?.control({ text, type: 'submit' })
|
||||
}
|
||||
|
||||
setDraft('')
|
||||
setComposerOpen(false)
|
||||
}
|
||||
|
||||
const openApp = () => {
|
||||
// Hide the icon immediately; the main renderer also clears the source flag.
|
||||
setUnread(false)
|
||||
window.hermesDesktop?.petOverlay?.control({ type: 'open-app' })
|
||||
}
|
||||
|
||||
if (!info.enabled || !info.spritesheetBase64) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onPointerDown={e => {
|
||||
// Click on the transparent backdrop (not the pet/composer) dismisses
|
||||
// the composer.
|
||||
if (composerOpen && e.target === e.currentTarget) {
|
||||
setComposerOpen(false)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100vh',
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: 24,
|
||||
userSelect: 'none',
|
||||
width: '100vw'
|
||||
}}
|
||||
>
|
||||
{composerOpen && (
|
||||
<input
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
send()
|
||||
} else if (e.key === 'Escape') {
|
||||
setComposerOpen(false)
|
||||
}
|
||||
}}
|
||||
placeholder="Message…"
|
||||
ref={inputRef}
|
||||
style={{
|
||||
background: 'var(--ui-bg-elevated)',
|
||||
border: '1px solid var(--ui-stroke-secondary)',
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 6px 18px rgba(0,0,0,0.28)',
|
||||
color: 'var(--foreground)',
|
||||
fontSize: 12,
|
||||
marginBottom: 8,
|
||||
outline: 'none',
|
||||
padding: '4px 8px',
|
||||
width: 184
|
||||
}}
|
||||
value={draft}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
onPointerDown={onPetPointerDown}
|
||||
onPointerMove={onPetPointerMove}
|
||||
onPointerUp={onPetPointerUp}
|
||||
ref={petRef}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
cursor: 'grab',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
touchAction: 'none'
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<PetBubble />
|
||||
</div>
|
||||
<div style={{ lineHeight: 0, position: 'relative' }}>
|
||||
<PetSprite info={info} />
|
||||
|
||||
{/* Mail icon: only when a finish landed while you were away. Jumps to
|
||||
the app's most recent thread. Anchored to the sprite (kept inside
|
||||
its box so the overlay's click-through hit-test still catches it);
|
||||
stopPropagation keeps a click from starting a window drag. */}
|
||||
{unread && (
|
||||
<button
|
||||
aria-label="Open in Hermes"
|
||||
onClick={openApp}
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
onPointerUp={e => e.stopPropagation()}
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
background: 'var(--ui-bg-elevated)',
|
||||
border: '1px solid var(--ui-stroke-secondary)',
|
||||
borderRadius: 999,
|
||||
boxShadow: '0 4px 14px rgba(0,0,0,0.22)',
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
height: 24,
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 24
|
||||
}}
|
||||
title="Open in Hermes"
|
||||
type="button"
|
||||
>
|
||||
<Mail style={{ height: 13, width: 13 }} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ErrorBoundary } from '@/components/error-boundary'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Loader } from '@/components/ui/loader'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { selectDesktopPaths } from '@/lib/desktop-fs'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
@@ -167,38 +168,41 @@ function FilesystemTab({
|
||||
<SidebarPanelLabel>{cwdName}</SidebarPanelLabel>
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={r.refreshTree}
|
||||
className={HEADER_ACTION_LABEL_REVEAL}
|
||||
disabled={!hasCwd || loading}
|
||||
onClick={onRefresh}
|
||||
size="icon-xs"
|
||||
title={r.refreshTree}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={r.openFolder}
|
||||
className={HEADER_ACTION_CLASS}
|
||||
onClick={() => void onChangeFolder()}
|
||||
size="icon-xs"
|
||||
title={r.openFolder}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="folder-opened" size="0.8125rem" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={r.collapseAll}
|
||||
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
|
||||
disabled={!hasCwd || !canCollapse}
|
||||
onClick={onCollapseAll}
|
||||
size="icon-xs"
|
||||
title={r.collapseAll}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="collapse-all" size="0.8125rem" />
|
||||
</Button>
|
||||
<Tip label={r.refreshTree} side="left">
|
||||
<Button
|
||||
aria-label={r.refreshTree}
|
||||
className={HEADER_ACTION_LABEL_REVEAL}
|
||||
disabled={!hasCwd || loading}
|
||||
onClick={onRefresh}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="refresh" size="0.8125rem" spinning={loading} />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={r.openFolder} side="left">
|
||||
<Button
|
||||
aria-label={r.openFolder}
|
||||
className={HEADER_ACTION_CLASS}
|
||||
onClick={() => void onChangeFolder()}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="folder-opened" size="0.8125rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
<Tip label={r.collapseAll} side="left">
|
||||
<Button
|
||||
aria-label={r.collapseAll}
|
||||
className={cn(HEADER_ACTION_CLASS, !canCollapse && 'pointer-events-none opacity-0')}
|
||||
disabled={!hasCwd || !canCollapse}
|
||||
onClick={onCollapseAll}
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="collapse-all" size="0.8125rem" />
|
||||
</Button>
|
||||
</Tip>
|
||||
</RightSidebarSectionHeader>
|
||||
<FileTreeBody
|
||||
collapseNonce={collapseNonce}
|
||||
|
||||
@@ -34,6 +34,7 @@ import { $gateway } from '@/store/gateway'
|
||||
import { dispatchNativeNotification } from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
|
||||
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
|
||||
import {
|
||||
setCurrentBranch,
|
||||
@@ -870,10 +871,18 @@ export function useMessageStream({
|
||||
if (sessionId) {
|
||||
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text))
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: true })
|
||||
}
|
||||
} else if (event.type === 'reasoning.available') {
|
||||
if (sessionId) {
|
||||
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text), true)
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: true })
|
||||
}
|
||||
} else if (event.type === 'message.complete') {
|
||||
if (!sessionId) {
|
||||
return
|
||||
@@ -895,6 +904,20 @@ export function useMessageStream({
|
||||
|
||||
if (isActiveEvent) {
|
||||
setTurnStartedAt(null)
|
||||
|
||||
// Pet beat: a finished turn always celebrates — go straight to the
|
||||
// jump, never linger on the run/reason pose. One atom update (clears
|
||||
// toolRunning/reasoning AND sets celebrate together) so no stray "run"
|
||||
// frame leaks to the sprite — including the popped-out overlay, which
|
||||
// mirrors each activity change. The jump runs ~2 loops, then settles.
|
||||
flashPetActivity({ celebrate: true, reasoning: false, toolRunning: false }, 2200)
|
||||
|
||||
// Light up the pet's mail icon if the user wasn't looking when the turn
|
||||
// finished — a glanceable "new message" hint on the popped-out overlay.
|
||||
// Cleared when they open the app via the mail icon or refocus the window.
|
||||
if (typeof document !== 'undefined' && !document.hasFocus()) {
|
||||
markPetUnread()
|
||||
}
|
||||
}
|
||||
|
||||
if (payload?.usage) {
|
||||
@@ -907,10 +930,19 @@ export function useMessageStream({
|
||||
|
||||
flushQueuedDeltas(sessionId)
|
||||
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'running', event.type)
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: false, toolRunning: true })
|
||||
}
|
||||
} else if (event.type === 'tool.complete') {
|
||||
if (sessionId) {
|
||||
flushQueuedDeltas(sessionId)
|
||||
upsertToolCall(sessionId, toTodoPayload(payload) ?? payload, 'complete', event.type)
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ toolRunning: false })
|
||||
}
|
||||
|
||||
// A pending clarify blocks the turn, so the first tool.complete after
|
||||
// one is the clarify resolving — drop the "needs input" flag here so
|
||||
// the sidebar indicator clears as soon as it's answered, not only at
|
||||
@@ -1120,6 +1152,11 @@ export function useMessageStream({
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setPetActivity({ reasoning: false, toolRunning: false })
|
||||
flashPetActivity({ error: true })
|
||||
}
|
||||
|
||||
dispatchNativeNotification({
|
||||
body: errorMessage,
|
||||
kind: 'turnError',
|
||||
|
||||
@@ -27,6 +27,7 @@ import { triggerHaptic } from '@/lib/haptics'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { openCommandPalettePage } from '@/store/command-palette'
|
||||
import {
|
||||
$composerAttachments,
|
||||
clearComposerAttachments,
|
||||
@@ -40,6 +41,8 @@ import { resetSessionBackground } from '@/store/composer-status'
|
||||
import { clearPreviewArtifacts } from '@/store/preview-status'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { setPetScale } from '@/store/pet-gallery'
|
||||
import { $petGenInput, openPetGenerate } from '@/store/pet-generate'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import {
|
||||
$busy,
|
||||
@@ -59,8 +62,8 @@ import { clearSessionSubagents } from '@/store/subagents'
|
||||
import { clearSessionTodos } from '@/store/todos'
|
||||
|
||||
import type {
|
||||
ClientSessionState,
|
||||
BrowserManageResponse,
|
||||
ClientSessionState,
|
||||
FileAttachResponse,
|
||||
HandoffFailResponse,
|
||||
HandoffRequestResponse,
|
||||
@@ -552,7 +555,14 @@ export function usePromptActions({
|
||||
async (rawText: string, options?: SubmitTextOptions) => {
|
||||
const visibleText = rawText.trim()
|
||||
const usingComposerAttachments = !options?.attachments
|
||||
const attachments = options?.attachments ?? $composerAttachments.get()
|
||||
// Drop undefined/null holes a session switch or draft restore can leave in
|
||||
// the attachments array (same bug class as AttachmentList #49624). Without
|
||||
// this, the sibling iterations below (a.kind / a.label / a.refText, and the
|
||||
// sync step) throw "Cannot read properties of undefined (reading 'refText')"
|
||||
// and break the chat surface.
|
||||
const attachments = (options?.attachments ?? $composerAttachments.get()).filter(
|
||||
(a): a is ComposerAttachment => Boolean(a)
|
||||
)
|
||||
|
||||
const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n')
|
||||
const hasImage = attachments.some(a => a.kind === 'image')
|
||||
@@ -565,14 +575,17 @@ export function usePromptActions({
|
||||
let attachmentRefs = attachments.map(optimisticAttachmentRef).filter((r): r is string => Boolean(r))
|
||||
|
||||
const buildContextText = (atts: ComposerAttachment[]): string => {
|
||||
const contextRefs = atts
|
||||
// atts may be the post-sync array, which can reintroduce holes; filter
|
||||
// before touching a.refText / a.kind.
|
||||
const present = atts.filter((a): a is ComposerAttachment => Boolean(a))
|
||||
const contextRefs = present
|
||||
.map(a => a.refText)
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
return (
|
||||
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
|
||||
(atts.some(a => a.kind === 'image') ? 'What do you see in this image?' : '')
|
||||
(present.some(a => a.kind === 'image') ? 'What do you see in this image?' : '')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1176,6 +1189,47 @@ export function usePromptActions({
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
},
|
||||
// /hatch opens the pet generator overlay (the desktop's rich, multi-step
|
||||
// generate→pick→hatch→adopt flow). A typed description seeds the prompt
|
||||
// so `/hatch a cyber fox` lands on the composer step prefilled.
|
||||
hatch: async ({ arg }) => {
|
||||
const concept = arg.trim()
|
||||
|
||||
if (concept) {
|
||||
$petGenInput.set(concept)
|
||||
}
|
||||
|
||||
openPetGenerate()
|
||||
},
|
||||
pet: async ctx => {
|
||||
const [sub = '', rawValue = ''] = ctx.arg.trim().split(/\s+/)
|
||||
const lower = sub.toLowerCase()
|
||||
|
||||
if (lower === 'list' || lower === 'gallery' || lower === 'browse' || lower === 'all') {
|
||||
openCommandPalettePage('pets')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// `/pet scale <n>` resizes the floating pet locally (instant) and
|
||||
// persists via the store — no round-trip to the slash worker.
|
||||
if (lower === 'scale') {
|
||||
const value = Number(rawValue)
|
||||
|
||||
if (!rawValue || Number.isNaN(value)) {
|
||||
const resolved = await withSlashOutput(ctx)
|
||||
resolved?.render('usage: /pet scale <factor> (e.g. /pet scale 0.5)')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setPetScale(requestGateway, value)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await runExec(ctx)
|
||||
},
|
||||
// /browser connect|disconnect|status manages the live CDP connection on
|
||||
// the gateway host, mirroring the TUI's browser.manage RPC. It mutates
|
||||
// BROWSER_CDP_URL (and may launch Chrome) in the gateway process — only
|
||||
@@ -1392,6 +1446,7 @@ export function usePromptActions({
|
||||
|
||||
const cancelRun = useCallback(async () => {
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
|
||||
const releaseBusy = () => {
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
|
||||
@@ -256,4 +256,29 @@ describe('resumeSession failure recovery', () => {
|
||||
|
||||
expect($resumeFailedSessionId.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('resumes via the gateway default (deferred build) — not lazy, no eager opt-out', async () => {
|
||||
// The switch-latency fix lives backend-side: a normal cold resume gets the
|
||||
// gateway's default DEFERRED build (transcript returns immediately, agent
|
||||
// pre-warms in the background). The client must NOT force the synchronous
|
||||
// path (eager_build) and is only `lazy` for subagent watch windows.
|
||||
let resumeParams: Record<string, unknown> | undefined
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
if (method === 'session.resume') {
|
||||
resumeParams = params
|
||||
|
||||
return { session_id: 'runtime-1', resumed: params?.session_id, messages: [], info: {} } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [] } as never)
|
||||
|
||||
await runResume(requestGateway)
|
||||
|
||||
expect(resumeParams).not.toHaveProperty('lazy')
|
||||
expect(resumeParams).not.toHaveProperty('eager_build')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -706,6 +706,11 @@ export function useSessionActions({
|
||||
const resumePromise = requestGateway<SessionResumeResponse>('session.resume', {
|
||||
session_id: storedSessionId,
|
||||
cols: 96,
|
||||
// Watch windows attach lazily (live mirror). Every other cold resume
|
||||
// gets the gateway's default deferred build: the RPC returns the
|
||||
// transcript immediately instead of blocking the switch on _make_agent
|
||||
// (MCP discovery / prompt build), and the agent pre-warms in the
|
||||
// background while the prefetch above paints the transcript.
|
||||
...(watchWindow ? { lazy: true } : {}),
|
||||
...(sessionProfile ? { profile: sessionProfile } : {})
|
||||
})
|
||||
@@ -754,7 +759,13 @@ export function useSessionActions({
|
||||
return chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages
|
||||
})()
|
||||
|
||||
const messagesForView = preserveLocalAssistantErrors(preferredMessages, currentMessages)
|
||||
// Prefetch-hit fast path: `preferredMessages` IS the live `$messages`
|
||||
// array (already error-merged when `localSnapshot` was built), so reuse
|
||||
// the ref instead of rebuilding a throwaway transcript+Map every switch.
|
||||
const messagesForView =
|
||||
preferredMessages === currentMessages
|
||||
? currentMessages
|
||||
: preserveLocalAssistantErrors(preferredMessages, currentMessages)
|
||||
|
||||
setActiveSessionId(resumed.session_id)
|
||||
activeSessionIdRef.current = resumed.session_id
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
$busy,
|
||||
$messages,
|
||||
noteSessionActivity,
|
||||
onSessionWatchdogClear,
|
||||
setCurrentFastMode,
|
||||
setCurrentModel,
|
||||
setCurrentPersonality,
|
||||
@@ -276,6 +277,31 @@ export function useSessionStateCache({
|
||||
[ensureSessionState, syncSessionStateToView]
|
||||
)
|
||||
|
||||
// When the store watchdog force-clears a stuck session (8 min of stream
|
||||
// silence — a hung or looping turn that never delivered its terminal event),
|
||||
// also drop that session's busy/awaiting flags here. Clearing the sidebar dot
|
||||
// alone leaves the composer wedged on "Thinking"/Stop; updateSessionState
|
||||
// re-syncs `$busy` when the healed session is the one on screen.
|
||||
useEffect(
|
||||
() =>
|
||||
onSessionWatchdogClear(storedSessionId => {
|
||||
const runtimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
const state = runtimeId ? sessionStateByRuntimeIdRef.current.get(runtimeId) : undefined
|
||||
|
||||
if (!runtimeId || !state?.busy) {
|
||||
return
|
||||
}
|
||||
|
||||
updateSessionState(runtimeId, current => ({
|
||||
...current,
|
||||
awaitingResponse: false,
|
||||
busy: false,
|
||||
needsInput: false
|
||||
}))
|
||||
}),
|
||||
[updateSessionState]
|
||||
)
|
||||
|
||||
return {
|
||||
activeSessionIdRef,
|
||||
ensureSessionState,
|
||||
|
||||
@@ -1,30 +1,31 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { LanguageSwitcher } from '@/components/language-switcher'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import type { DesktopMarketplaceSearchItem } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Download, Loader2, Palette, Trash2 } from '@/lib/icons'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import { $translucency, setTranslucency } from '@/store/translucency'
|
||||
import { useTheme } from '@/themes/context'
|
||||
import { getBaseColors, useTheme } from '@/themes/context'
|
||||
import { installVscodeThemeFromMarketplace } from '@/themes/install'
|
||||
import { isUserTheme, removeUserTheme, resolveTheme } from '@/themes/user-themes'
|
||||
import { isUserTheme, removeUserTheme } from '@/themes/user-themes'
|
||||
|
||||
import { MODE_OPTIONS } from './constants'
|
||||
import { PetSettings } from './pet-settings'
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
|
||||
function ThemePreview({ name }: { name: string }) {
|
||||
const t = resolveTheme(name)
|
||||
|
||||
if (!t) {
|
||||
return null
|
||||
}
|
||||
|
||||
const c = t.colors
|
||||
function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) {
|
||||
// Preview in the *current* mode: the dark palette in Dark, and the light
|
||||
// palette in Light — synthesizing one for dark-only themes — so every card
|
||||
// tracks the Light/Dark toggle, exactly like the app itself does.
|
||||
const c = getBaseColors(name, mode)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -57,90 +58,200 @@ function ThemePreview({ name }: { name: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function VscodeThemeInstaller() {
|
||||
function useDebounced<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setDebounced(value), delayMs)
|
||||
|
||||
return () => clearTimeout(handle)
|
||||
}, [value, delayMs])
|
||||
|
||||
return debounced
|
||||
}
|
||||
|
||||
const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })
|
||||
|
||||
/**
|
||||
* Live VS Code Marketplace theme search (the same backend as the Cmd-K "Install
|
||||
* theme…" page). Renders below the local grid when there's a query: each row
|
||||
* downloads + converts + installs via `installVscodeThemeFromMarketplace` and
|
||||
* activates it. Extensions already imported locally are marked installed.
|
||||
*/
|
||||
function MarketplaceThemeResults({
|
||||
query,
|
||||
installedExtIds,
|
||||
onInstalled
|
||||
}: {
|
||||
query: string
|
||||
installedExtIds: Set<string>
|
||||
onInstalled: (name: string) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const { setTheme } = useTheme()
|
||||
const a = t.settings.appearance
|
||||
const [id, setId] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [status, setStatus] = useState<{ kind: 'error' | 'success'; text: string } | null>(null)
|
||||
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 install = async () => {
|
||||
const trimmed = id.trim()
|
||||
const search = useQuery({
|
||||
enabled: debounced.length > 0,
|
||||
queryFn: () => window.hermesDesktop?.themes?.searchMarketplace(debounced) ?? Promise.resolve([]),
|
||||
queryKey: ['marketplace-themes-settings', debounced],
|
||||
staleTime: 5 * 60 * 1000
|
||||
})
|
||||
|
||||
if (!trimmed || busy) {
|
||||
const install = async (item: DesktopMarketplaceSearchItem) => {
|
||||
if (installingId) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
setStatus(null)
|
||||
setInstallingId(item.extensionId)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const theme = await installVscodeThemeFromMarketplace(trimmed)
|
||||
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
|
||||
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
setStatus({ kind: 'success', text: a.installed(theme.label) })
|
||||
setId('')
|
||||
} catch (error) {
|
||||
setStatus({ kind: 'error', text: error instanceof Error ? error.message : a.installError })
|
||||
setInstalledHere(prev => ({ ...prev, [item.extensionId]: true }))
|
||||
onInstalled(theme.name)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : copy.error)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setInstallingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 font-mono text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
|
||||
disabled={busy}
|
||||
onChange={event => {
|
||||
setId(event.target.value)
|
||||
setStatus(null)
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
void install()
|
||||
}
|
||||
}}
|
||||
placeholder={a.installPlaceholder}
|
||||
spellCheck={false}
|
||||
value={id}
|
||||
/>
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] font-medium transition hover:bg-(--chrome-action-hover) disabled:opacity-50"
|
||||
disabled={busy || !id.trim()}
|
||||
onClick={() => void install()}
|
||||
type="button"
|
||||
>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
|
||||
{busy ? a.installing : a.installButton}
|
||||
</button>
|
||||
</div>
|
||||
{status && (
|
||||
<p
|
||||
className={cn(
|
||||
'mt-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height)',
|
||||
status.kind === 'error' ? 'text-(--ui-red)' : 'text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
{status.text}
|
||||
if (!debounced) {
|
||||
return null
|
||||
}
|
||||
|
||||
const header = (
|
||||
<p className="mb-2 mt-4 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-tertiary)">
|
||||
From the VS Code Marketplace
|
||||
</p>
|
||||
)
|
||||
|
||||
if (search.isLoading) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<p className="flex items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{copy.loading}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (search.isError) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-red)">{copy.error}</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const results = search.data ?? []
|
||||
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{copy.empty}</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
{error && <p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-red)">{error}</p>}
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{results.map(item => {
|
||||
const busy = installingId === item.extensionId
|
||||
const done = installedHere[item.extensionId] || installedExtIds.has(item.extensionId)
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 text-left disabled:opacity-60',
|
||||
selectableCardClass({ prominent: done })
|
||||
)}
|
||||
disabled={Boolean(installingId) && !busy}
|
||||
key={item.extensionId}
|
||||
onClick={() => void install(item)}
|
||||
type="button"
|
||||
>
|
||||
<Palette className="size-4 shrink-0 text-(--ui-text-tertiary)" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{item.displayName}
|
||||
</span>
|
||||
<span className="block truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{item.publisher}
|
||||
{item.installs > 0 ? ` · ${copy.installs(compactNumber.format(item.installs))}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-(--ui-text-tertiary)">
|
||||
{busy ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : done ? (
|
||||
<Check className="size-4 text-(--ui-green)" />
|
||||
) : (
|
||||
<Download className="size-4" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function AppearanceSettings() {
|
||||
const { t, isSavingLocale } = useI18n()
|
||||
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const translucency = useStore($translucency)
|
||||
const profiles = useStore($profiles)
|
||||
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
|
||||
const a = t.settings.appearance
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
// One box does double duty: filter installed themes live (below), and run a
|
||||
// name search against the VS Code Marketplace (the Cmd-K "Install theme…"
|
||||
// backend) for anything not already installed.
|
||||
const needle = query.trim().toLowerCase()
|
||||
|
||||
const filteredThemes = availableThemes
|
||||
.filter(
|
||||
theme =>
|
||||
!needle ||
|
||||
theme.label.toLowerCase().includes(needle) ||
|
||||
theme.name.toLowerCase().includes(needle) ||
|
||||
theme.description.toLowerCase().includes(needle)
|
||||
)
|
||||
// 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
|
||||
@@ -163,7 +274,7 @@ export function AppearanceSettings() {
|
||||
{a.intro}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 divide-y divide-(--ui-stroke-tertiary)">
|
||||
<div className="mt-2">
|
||||
<ListRow
|
||||
action={<LanguageSwitcher />}
|
||||
description={isSavingLocale ? t.language.saving : t.language.description}
|
||||
@@ -171,18 +282,107 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('crisp')
|
||||
setMode(id)
|
||||
}}
|
||||
options={modeOptions}
|
||||
value={mode}
|
||||
/>
|
||||
below={
|
||||
<>
|
||||
{/* One search box: filters your installed themes (the grid)
|
||||
and live-searches the VS Code Marketplace below. */}
|
||||
<div className="mt-3">
|
||||
<input
|
||||
className="w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
placeholder="Search your themes or the VS Code Marketplace…"
|
||||
spellCheck={false}
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Fixed-height scroll area so the (growing) theme list never
|
||||
runs the page long; the grid scrolls inside it. */}
|
||||
<div className="mt-3 max-h-96 overflow-y-auto pr-1">
|
||||
{filteredThemes.length === 0 ? (
|
||||
needle ? (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
No installed themes match "{query.trim()}".
|
||||
</p>
|
||||
) : null
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{filteredThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
const removable = isUserTheme(theme.name)
|
||||
|
||||
return (
|
||||
<div className="group relative" key={theme.name}>
|
||||
<button
|
||||
className={cn('w-full p-2 text-left', selectableCardClass({ active, prominent: true }))}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview mode={resolvedMode} name={theme.name} />
|
||||
<div className="mt-3 px-1">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{removable && (
|
||||
<button
|
||||
aria-label={a.removeTheme}
|
||||
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
removeUserTheme(theme.name)
|
||||
|
||||
// Re-normalize off the now-missing skin → default.
|
||||
if (active) {
|
||||
setTheme(theme.name)
|
||||
}
|
||||
}}
|
||||
title={a.removeTheme}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<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)">
|
||||
{a.themeProfileNote(activeProfileName)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
description={a.colorModeDesc}
|
||||
title={a.colorMode}
|
||||
description={a.themeDesc}
|
||||
title={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{a.themeTitle}</span>
|
||||
<SegmentedControl
|
||||
onChange={id => {
|
||||
triggerHaptic('crisp')
|
||||
setMode(id)
|
||||
}}
|
||||
options={modeOptions}
|
||||
value={mode}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
wide
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
@@ -211,80 +411,6 @@ export function AppearanceSettings() {
|
||||
title={a.translucencyTitle}
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
below={
|
||||
<>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{availableThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
const removable = isUserTheme(theme.name)
|
||||
|
||||
return (
|
||||
<div className="group relative" key={theme.name}>
|
||||
<button
|
||||
className={cn(
|
||||
'w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{removable && (
|
||||
<button
|
||||
aria-label={a.removeTheme}
|
||||
className="absolute right-1.5 top-1.5 grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) opacity-0 backdrop-blur-sm transition hover:text-(--ui-red) focus-visible:opacity-100 group-hover:opacity-100"
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
removeUserTheme(theme.name)
|
||||
|
||||
// Re-normalize off the now-missing skin → default.
|
||||
if (active) {
|
||||
setTheme(theme.name)
|
||||
}
|
||||
}}
|
||||
title={a.removeTheme}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<VscodeThemeInstaller />
|
||||
{showProfileNote && (
|
||||
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{a.themeProfileNote(activeProfileName)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
description={a.themeDesc}
|
||||
title={a.themeTitle}
|
||||
wide
|
||||
/>
|
||||
|
||||
<ListRow
|
||||
action={
|
||||
<SegmentedControl
|
||||
@@ -301,6 +427,10 @@ export function AppearanceSettings() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<PetSettings />
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,26 @@ import { ModelSettings } from './model-settings'
|
||||
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
|
||||
import { ProviderConfigPanel } from './provider-config-panel'
|
||||
|
||||
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
|
||||
// provider — otherwise every provider's options render at once (the "totally
|
||||
// crazy" wall of ~30 fields). Top-level keys (tts.provider, stt.enabled,
|
||||
// voice.*) always show; STT provider fields hide entirely when STT is off.
|
||||
export function voiceFieldVisible(key: string, config: HermesConfigRecord): boolean {
|
||||
const match = /^(tts|stt)\.([^.]+)\./.exec(key)
|
||||
|
||||
if (!match) {
|
||||
return true
|
||||
}
|
||||
|
||||
const [, domain, provider] = match
|
||||
|
||||
if (domain === 'stt' && !getNested(config, 'stt.enabled')) {
|
||||
return false
|
||||
}
|
||||
|
||||
return provider === String(getNested(config, `${domain}.provider`) ?? '')
|
||||
}
|
||||
|
||||
function ConfigField({
|
||||
schemaKey,
|
||||
schema,
|
||||
@@ -356,6 +376,9 @@ export function ConfigSettings({
|
||||
return <LoadingState label={c.loading} />
|
||||
}
|
||||
|
||||
const visibleFields =
|
||||
activeSectionId === 'voice' ? fields.filter(([key]) => voiceFieldVisible(key, config)) : fields
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
{activeSectionId === 'model' && (
|
||||
@@ -363,11 +386,11 @@ export function ConfigSettings({
|
||||
<ModelSettings onMainModelChanged={onMainModelChanged} />
|
||||
</div>
|
||||
)}
|
||||
{fields.length === 0 ? (
|
||||
{visibleFields.length === 0 ? (
|
||||
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
{fields.map(([key, field]) => (
|
||||
{visibleFields.map(([key, field]) => (
|
||||
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
|
||||
<ConfigField
|
||||
descriptionExtra={
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor, Network } from '@/lib/icons'
|
||||
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $profiles, refreshActiveProfile } from '@/store/profile'
|
||||
@@ -13,10 +13,9 @@ import { $profiles, refreshActiveProfile } from '@/store/profile'
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
|
||||
type Mode = 'local' | 'remote' | 'ssh'
|
||||
type Mode = 'local' | 'remote'
|
||||
type AuthMode = 'oauth' | 'token'
|
||||
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
|
||||
type SshTestStatus = 'idle' | 'testing' | 'ok' | 'error'
|
||||
|
||||
interface GatewaySettingsState {
|
||||
envOverride: boolean
|
||||
@@ -26,11 +25,6 @@ interface GatewaySettingsState {
|
||||
remoteTokenPreview: string | null
|
||||
remoteTokenSet: boolean
|
||||
remoteUrl: string
|
||||
sshHost: string
|
||||
sshUser: string
|
||||
sshPort: number | null
|
||||
sshKeyPath: string
|
||||
sshRemoteHermesPath: string
|
||||
}
|
||||
|
||||
const EMPTY_STATE: GatewaySettingsState = {
|
||||
@@ -40,12 +34,7 @@ const EMPTY_STATE: GatewaySettingsState = {
|
||||
remoteOauthConnected: false,
|
||||
remoteTokenPreview: null,
|
||||
remoteTokenSet: false,
|
||||
remoteUrl: '',
|
||||
sshHost: '',
|
||||
sshUser: '',
|
||||
sshPort: null,
|
||||
sshKeyPath: '',
|
||||
sshRemoteHermesPath: ''
|
||||
remoteUrl: ''
|
||||
}
|
||||
|
||||
function ModeCard({
|
||||
@@ -116,12 +105,6 @@ export function GatewaySettings() {
|
||||
const [remoteToken, setRemoteToken] = useState('')
|
||||
const [lastTest, setLastTest] = useState<null | string>(null)
|
||||
|
||||
// SSH-mode local UI state: the connection test result, ~/.ssh/config host
|
||||
// suggestions, and the `ssh -G` resolution of the entered host.
|
||||
const [sshTestStatus, setSshTestStatus] = useState<SshTestStatus>('idle')
|
||||
const [sshTestMessage, setSshTestMessage] = useState<null | string>(null)
|
||||
const [sshHostSuggestions, setSshHostSuggestions] = useState<string[]>([])
|
||||
|
||||
// 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.
|
||||
@@ -282,23 +265,6 @@ export function GatewaySettings() {
|
||||
// per-profile scopes are the named, non-default profiles.
|
||||
const namedProfiles = useMemo(() => profiles.filter(profile => profile.name !== 'default'), [profiles])
|
||||
|
||||
// Load ~/.ssh/config host suggestions once SSH mode is active (read-only).
|
||||
useEffect(() => {
|
||||
if (state.mode !== 'ssh') return
|
||||
const desktop = window.hermesDesktop
|
||||
if (!desktop?.sshConfigHosts) return
|
||||
let cancelled = false
|
||||
desktop
|
||||
.sshConfigHosts()
|
||||
.then(result => {
|
||||
if (!cancelled) setSshHostSuggestions(result.hosts || [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSshHostSuggestions([])
|
||||
})
|
||||
return () => void (cancelled = true)
|
||||
}, [state.mode])
|
||||
|
||||
const oauthConnected = state.remoteOauthConnected
|
||||
|
||||
const canUseRemote = useMemo(() => {
|
||||
@@ -441,7 +407,7 @@ export function GatewaySettings() {
|
||||
remoteUrl: trimmedUrl
|
||||
})
|
||||
|
||||
const message = g.connectedTo(result.baseUrl ?? trimmedUrl, result.version ?? undefined)
|
||||
const message = g.connectedTo(result.baseUrl, result.version ?? undefined)
|
||||
setLastTest(message)
|
||||
notify({ kind: 'success', title: g.reachableTitle, message })
|
||||
} catch (err) {
|
||||
@@ -451,108 +417,6 @@ export function GatewaySettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- SSH mode -------------------------------------------------------------
|
||||
|
||||
const canUseSsh = Boolean(state.sshHost.trim())
|
||||
|
||||
const sshPayload = () => ({
|
||||
mode: 'ssh' as const,
|
||||
profile: scope ?? undefined,
|
||||
sshHost: state.sshHost.trim(),
|
||||
sshUser: state.sshUser.trim() || undefined,
|
||||
sshPort: state.sshPort ?? undefined,
|
||||
sshKeyPath: state.sshKeyPath.trim() || undefined,
|
||||
sshRemoteHermesPath: state.sshRemoteHermesPath.trim() || undefined
|
||||
})
|
||||
|
||||
// Map an SSH test error kind to actionable copy.
|
||||
const sshErrorMessage = (kind: string | null | undefined, raw: string | null | undefined): string => {
|
||||
switch (kind) {
|
||||
case 'auth-failed':
|
||||
return g.sshErrAuth
|
||||
case 'unreachable':
|
||||
return g.sshErrUnreachable
|
||||
case 'host-key-changed':
|
||||
return g.sshErrHostKey
|
||||
case 'hermes-not-found':
|
||||
return g.sshErrNotInstalled
|
||||
case 'unsupported-platform':
|
||||
return g.sshErrPlatform
|
||||
case 'timeout':
|
||||
return g.sshErrTimeout
|
||||
default:
|
||||
return raw || g.sshErrUnknown
|
||||
}
|
||||
}
|
||||
|
||||
const sshTest = async () => {
|
||||
if (!canUseSsh) {
|
||||
notify({ kind: 'warning', title: g.incompleteTitle, message: g.sshIncompleteHost })
|
||||
return
|
||||
}
|
||||
setSshTestStatus('testing')
|
||||
setSshTestMessage(null)
|
||||
try {
|
||||
const result = await window.hermesDesktop.testConnectionConfig(sshPayload())
|
||||
if (result.reachable) {
|
||||
const message = g.sshReachable(result.host ?? state.sshHost, result.remotePlatform ?? '?')
|
||||
setSshTestStatus('ok')
|
||||
setSshTestMessage(message)
|
||||
notify({ kind: 'success', title: g.reachableTitle, message })
|
||||
} else {
|
||||
const message = sshErrorMessage(result.sshError, result.error)
|
||||
setSshTestStatus('error')
|
||||
setSshTestMessage(message)
|
||||
notify({ kind: 'warning', title: g.testFailed, message })
|
||||
}
|
||||
} catch (err) {
|
||||
setSshTestStatus('error')
|
||||
setSshTestMessage(err instanceof Error ? err.message : String(err))
|
||||
notifyError(err, g.testFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the entered host via `ssh -G` and fill in any blank user/port the
|
||||
// alias expands to (so the saved config matches what ssh will actually use).
|
||||
const sshResolve = async () => {
|
||||
const host = state.sshHost.trim()
|
||||
if (!host || !window.hermesDesktop?.sshResolveHost) return
|
||||
try {
|
||||
const resolved = await window.hermesDesktop.sshResolveHost(host)
|
||||
setState(current => ({
|
||||
...current,
|
||||
sshUser: current.sshUser.trim() || resolved.user || '',
|
||||
sshPort: current.sshPort ?? (resolved.port && resolved.port !== 22 ? resolved.port : null),
|
||||
sshKeyPath: current.sshKeyPath.trim() || resolved.identityFile || ''
|
||||
}))
|
||||
} catch {
|
||||
// best-effort enrichment; leave the fields as entered
|
||||
}
|
||||
}
|
||||
|
||||
const sshSave = async (apply: boolean) => {
|
||||
if (!canUseSsh) {
|
||||
notify({ kind: 'warning', title: g.incompleteTitle, message: g.sshIncompleteHost })
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const next = apply
|
||||
? await window.hermesDesktop.applyConnectionConfig(sshPayload())
|
||||
: await window.hermesDesktop.saveConnectionConfig(sshPayload())
|
||||
setState(next)
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: apply ? g.restartingTitle : g.savedTitle,
|
||||
message: apply ? g.restartingMessage : g.savedMessage
|
||||
})
|
||||
} catch (err) {
|
||||
notifyError(err, apply ? g.applyFailed : g.saveFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingState label={g.loading} />
|
||||
}
|
||||
@@ -613,7 +477,7 @@ export function GatewaySettings() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<ModeCard
|
||||
active={state.mode === 'local'}
|
||||
description={g.localDesc}
|
||||
@@ -630,32 +494,22 @@ export function GatewaySettings() {
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
|
||||
title={g.remoteTitle}
|
||||
/>
|
||||
<ModeCard
|
||||
active={state.mode === 'ssh'}
|
||||
description={g.sshDesc}
|
||||
disabled={state.envOverride}
|
||||
icon={Network}
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'ssh' }))}
|
||||
title={g.sshTitle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-1">
|
||||
{state.mode === 'remote' ? (
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))}
|
||||
placeholder="https://gateway.example.com/hermes"
|
||||
value={state.remoteUrl}
|
||||
/>
|
||||
}
|
||||
description={g.remoteUrlDesc}
|
||||
title={g.remoteUrlTitle}
|
||||
/>
|
||||
) : null}
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setState(current => ({ ...current, remoteUrl: event.target.value }))}
|
||||
placeholder="https://gateway.example.com/hermes"
|
||||
value={state.remoteUrl}
|
||||
/>
|
||||
}
|
||||
description={g.remoteUrlDesc}
|
||||
title={g.remoteUrlTitle}
|
||||
/>
|
||||
|
||||
{state.mode === 'remote' && probeStatus === 'probing' ? (
|
||||
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
@@ -725,159 +579,28 @@ export function GatewaySettings() {
|
||||
title={g.tokenTitle}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* SSH mode: connect via the box's SSH access; no token to copy. */}
|
||||
{state.mode === 'ssh' ? (
|
||||
<>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
list="hermes-ssh-host-suggestions"
|
||||
onBlur={() => void sshResolve()}
|
||||
onChange={event => setState(current => ({ ...current, sshHost: event.target.value }))}
|
||||
placeholder="user@mac-mini.local or mac-mini"
|
||||
value={state.sshHost}
|
||||
/>
|
||||
}
|
||||
description={g.sshHostDesc}
|
||||
title={g.sshHostTitle}
|
||||
/>
|
||||
{sshHostSuggestions.length > 0 ? (
|
||||
<datalist id="hermes-ssh-host-suggestions">
|
||||
{sshHostSuggestions.map(host => (
|
||||
<option key={host} value={host} />
|
||||
))}
|
||||
</datalist>
|
||||
) : null}
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setState(current => ({ ...current, sshUser: event.target.value }))}
|
||||
placeholder={g.sshUserPlaceholder}
|
||||
value={state.sshUser}
|
||||
/>
|
||||
}
|
||||
description={g.sshUserDesc}
|
||||
title={g.sshUserTitle}
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event =>
|
||||
setState(current => ({
|
||||
...current,
|
||||
sshPort: event.target.value.trim() ? Number.parseInt(event.target.value, 10) || null : null
|
||||
}))
|
||||
}
|
||||
placeholder="22"
|
||||
value={state.sshPort != null ? String(state.sshPort) : ''}
|
||||
/>
|
||||
}
|
||||
description={g.sshPortDesc}
|
||||
title={g.sshPortTitle}
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setState(current => ({ ...current, sshKeyPath: event.target.value }))}
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
value={state.sshKeyPath}
|
||||
/>
|
||||
}
|
||||
description={g.sshKeyDesc}
|
||||
title={g.sshKeyTitle}
|
||||
/>
|
||||
<ListRow
|
||||
action={
|
||||
<Input
|
||||
className={cn('h-8', CONTROL_TEXT)}
|
||||
disabled={state.envOverride}
|
||||
onChange={event => setState(current => ({ ...current, sshRemoteHermesPath: event.target.value }))}
|
||||
placeholder={g.sshHermesPathPlaceholder}
|
||||
value={state.sshRemoteHermesPath}
|
||||
/>
|
||||
}
|
||||
description={g.sshHermesPathDesc}
|
||||
title={g.sshHermesPathTitle}
|
||||
/>
|
||||
{sshTestStatus !== 'idle' && sshTestMessage ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)]',
|
||||
sshTestStatus === 'ok' ? 'text-primary' : 'text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
{sshTestStatus === 'testing' ? (
|
||||
<Loader2 className="mt-0.5 size-4 shrink-0 animate-spin" />
|
||||
) : sshTestStatus === 'ok' ? (
|
||||
<Check className="mt-0.5 size-4 shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
)}
|
||||
<span>{sshTestMessage}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{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">
|
||||
{state.mode === 'ssh' ? (
|
||||
<>
|
||||
<Button
|
||||
className="mr-auto"
|
||||
disabled={state.envOverride || sshTestStatus === 'testing' || !canUseSsh}
|
||||
onClick={() => void sshTest()}
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{sshTestStatus === 'testing' ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.sshTestConnection}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={state.envOverride || saving}
|
||||
onClick={() => void sshSave(false)}
|
||||
size="sm"
|
||||
variant="textStrong"
|
||||
>
|
||||
{g.saveForRestart}
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving || !canUseSsh} onClick={() => void sshSave(true)} size="sm">
|
||||
{saving ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.sshConnect}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<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>
|
||||
|
||||
<div className="mt-6 grid gap-1">
|
||||
|
||||
359
apps/desktop/src/app/settings/pet-settings.tsx
Normal file
359
apps/desktop/src/app/settings/pet-settings.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { PetThumb } from '@/components/pet/pet-thumb'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Download, Loader2, PawPrint, Pencil, Trash2 } from '@/lib/icons'
|
||||
import { selectableCardClass } from '@/lib/selectable-card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $petInfo } from '@/store/pet'
|
||||
import {
|
||||
$petBusy,
|
||||
$petGallery,
|
||||
$petGalleryError,
|
||||
$petGalleryStatus,
|
||||
adoptPet,
|
||||
exportPet as exportPetAction,
|
||||
loadPetGallery,
|
||||
loadPetThumb,
|
||||
PET_SCALE_DEFAULT,
|
||||
PET_SCALE_MAX,
|
||||
PET_SCALE_MIN,
|
||||
type GalleryPet,
|
||||
rankedGalleryPets,
|
||||
removePet as removePetAction,
|
||||
renamePet as renamePetAction,
|
||||
setPetEnabled,
|
||||
setPetScale
|
||||
} from '@/store/pet-gallery'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
|
||||
import { ListRow, SectionHeading } from './primitives'
|
||||
|
||||
/**
|
||||
* Appearance opt-in for the floating petdex mascot. A thin view over the shared
|
||||
* `pet-gallery` store — it subscribes to the atoms and calls the store actions,
|
||||
* so the gallery is fetched once + cached and adopt/toggle/remove patch local
|
||||
* state instead of re-pulling the network gallery. The floating mascot polls
|
||||
* `pet.info`, so picking a pet here lights it up within a couple seconds.
|
||||
*/
|
||||
export function PetSettings() {
|
||||
const { t } = useI18n()
|
||||
const copy = t.settings.appearance.pet
|
||||
const { requestGateway } = useGatewayRequest()
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const gallery = useStore($petGallery)
|
||||
const status = useStore($petGalleryStatus)
|
||||
const error = useStore($petGalleryError)
|
||||
const busySlug = useStore($petBusy)
|
||||
const petInfo = useStore($petInfo)
|
||||
const [query, setQuery] = useState('')
|
||||
const [confirmDelete, setConfirmDelete] = useState<GalleryPet | null>(null)
|
||||
const [renameTarget, setRenameTarget] = useState<GalleryPet | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const scale = petInfo.scale ?? PET_SCALE_DEFAULT
|
||||
|
||||
useEffect(() => {
|
||||
if (gatewayState !== 'open') {
|
||||
return
|
||||
}
|
||||
|
||||
void loadPetGallery(requestGateway)
|
||||
}, [gatewayState, requestGateway])
|
||||
|
||||
const enabled = gallery?.enabled ?? false
|
||||
const active = gallery?.active ?? ''
|
||||
const pets = gallery?.pets ?? []
|
||||
const staleBackend = status === 'stale'
|
||||
|
||||
const selectPet = (slug: string) => {
|
||||
void adoptPet(requestGateway, slug, copy.adoptFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
const removePet = (slug: string) => {
|
||||
void removePetAction(requestGateway, slug, copy.uninstallFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
const exportPet = (slug: string) => {
|
||||
void exportPetAction(requestGateway, slug, copy.exportFailed(slug)).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
const saveRename = () => {
|
||||
if (!renameTarget || !renameValue.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Optimistic: the rename paints instantly, so close now and let the RPC
|
||||
// settle in the background (it rolls back + surfaces an error on failure).
|
||||
const { slug } = renameTarget
|
||||
setRenameTarget(null)
|
||||
triggerHaptic('crisp')
|
||||
void renamePetAction(requestGateway, slug, renameValue, copy.renameFailed(slug))
|
||||
}
|
||||
|
||||
const toggle = (on: boolean) => {
|
||||
void setPetEnabled(requestGateway, on, {
|
||||
noneAvailable: copy.noneAvailable,
|
||||
fallback: on ? copy.turnOnFailed : copy.turnOffFailed
|
||||
}).then(ok => ok && triggerHaptic('crisp'))
|
||||
}
|
||||
|
||||
// The petdex catalog is thousands of entries, so rank + cap how many render.
|
||||
const RENDER_CAP = 60
|
||||
const sorted = rankedGalleryPets(gallery, query)
|
||||
const shown = sorted.slice(0, RENDER_CAP)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SectionHeading icon={PawPrint} title={copy.title} />
|
||||
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{copy.intro}
|
||||
</p>
|
||||
|
||||
{staleBackend && (
|
||||
<p className="mt-2 rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{copy.restartHint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2">
|
||||
<ListRow
|
||||
below={
|
||||
<>
|
||||
<input
|
||||
className="mt-3 w-full rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-3 py-1.5 text-[length:var(--conversation-caption-font-size)] outline-none placeholder:text-(--ui-text-tertiary) focus:border-(--ui-stroke-secondary)"
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
placeholder={copy.searchPlaceholder}
|
||||
spellCheck={false}
|
||||
value={query}
|
||||
/>
|
||||
{/* Fixed-height scroll area so filtering never grows/shrinks the
|
||||
page (no layout thrash); the grid scrolls inside it. */}
|
||||
<div className="mt-3 h-72 overflow-y-auto pr-1">
|
||||
{pets.length === 0 ? (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{copy.unreachable}
|
||||
</p>
|
||||
) : shown.length === 0 ? (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{copy.noMatch(query)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{shown.map(pet => {
|
||||
const isActive = enabled && active === pet.slug
|
||||
const isBusy = busySlug === pet.slug
|
||||
|
||||
return (
|
||||
<div className="group relative" key={pet.slug}>
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2.5 px-2.5 py-2 text-left disabled:opacity-50',
|
||||
selectableCardClass({ active: isActive, prominent: pet.installed })
|
||||
)}
|
||||
disabled={isBusy}
|
||||
onClick={() => void selectPet(pet.slug)}
|
||||
type="button"
|
||||
>
|
||||
<PetThumb
|
||||
alt={pet.displayName}
|
||||
load={(slug, url) => loadPetThumb(requestGateway, slug, url)}
|
||||
slug={pet.slug}
|
||||
url={pet.spritesheetUrl}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{pet.displayName}
|
||||
</span>
|
||||
{pet.generated && (
|
||||
<span className="shrink-0 rounded-full bg-primary/15 px-1.5 py-px text-[0.625rem] font-medium text-primary">
|
||||
{copy.generatedTag}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block truncate text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{pet.slug}
|
||||
{pet.installed ? ` · ${copy.installedTag}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
{isBusy && <Loader2 className="size-4 shrink-0 animate-spin text-(--ui-text-tertiary)" />}
|
||||
</button>
|
||||
{!isBusy && (pet.installed || pet.generated) && (
|
||||
<div className="absolute right-1.5 top-1.5 flex gap-1 opacity-0 transition focus-within:opacity-100 group-hover:opacity-100">
|
||||
{pet.generated && (
|
||||
<PetAction
|
||||
icon={<Pencil className="size-3.5" />}
|
||||
label={copy.rename(pet.displayName)}
|
||||
onClick={() => {
|
||||
setRenameValue(pet.displayName)
|
||||
setRenameTarget(pet)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{pet.generated && (
|
||||
<PetAction
|
||||
icon={<Download className="size-3.5" />}
|
||||
label={copy.exportPet(pet.displayName)}
|
||||
onClick={() => exportPet(pet.slug)}
|
||||
/>
|
||||
)}
|
||||
{pet.installed && (
|
||||
// Generated pets have no remote source — deletion is
|
||||
// permanent, so confirm; petdex pets just uninstall.
|
||||
<PetAction
|
||||
danger
|
||||
icon={<Trash2 className="size-3.5" />}
|
||||
label={pet.generated ? copy.delete(pet.displayName) : copy.uninstall(pet.displayName)}
|
||||
onClick={() => (pet.generated ? setConfirmDelete(pet) : removePet(pet.slug))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Always-present status line so its appearance never shifts layout. */}
|
||||
<p className="mt-2 min-h-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{error ? (
|
||||
<span className="text-(--ui-red)">{error}</span>
|
||||
) : sorted.length > RENDER_CAP ? (
|
||||
copy.countCapped(RENDER_CAP, sorted.length)
|
||||
) : (
|
||||
copy.count(sorted.length)
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
description={copy.chooseDesc}
|
||||
title={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{copy.chooseTitle}</span>
|
||||
<SegmentedControl
|
||||
onChange={id => void toggle(id === 'on')}
|
||||
options={[
|
||||
{ id: 'off', label: copy.off },
|
||||
{ id: 'on', label: copy.on }
|
||||
]}
|
||||
value={enabled ? 'on' : 'off'}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
wide
|
||||
/>
|
||||
|
||||
{enabled && (
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
aria-label={copy.scaleTitle}
|
||||
className="h-1 w-40 cursor-pointer appearance-none rounded-full bg-(--ui-stroke-tertiary)"
|
||||
max={PET_SCALE_MAX}
|
||||
min={PET_SCALE_MIN}
|
||||
onChange={event => {
|
||||
triggerHaptic('selection')
|
||||
setPetScale(requestGateway, Number(event.target.value))
|
||||
}}
|
||||
step={0.05}
|
||||
style={{ accentColor: 'var(--dt-primary)' }}
|
||||
type="range"
|
||||
value={scale}
|
||||
/>
|
||||
<span className="w-9 text-right text-[length:var(--conversation-caption-font-size)] tabular-nums text-(--ui-text-tertiary)">
|
||||
{`${Math.round(scale * 100)}%`}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
description={copy.scaleDesc}
|
||||
title={copy.scaleTitle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
confirmLabel={copy.deleteConfirm}
|
||||
description={copy.deleteBody}
|
||||
destructive
|
||||
onClose={() => setConfirmDelete(null)}
|
||||
onConfirm={async () => {
|
||||
if (confirmDelete) {
|
||||
const ok = await removePetAction(requestGateway, confirmDelete.slug, copy.uninstallFailed(confirmDelete.slug))
|
||||
if (!ok) {
|
||||
throw new Error(copy.uninstallFailed(confirmDelete.slug))
|
||||
}
|
||||
triggerHaptic('crisp')
|
||||
}
|
||||
}}
|
||||
open={confirmDelete !== null}
|
||||
title={confirmDelete ? copy.deleteTitle(confirmDelete.displayName) : ''}
|
||||
/>
|
||||
|
||||
<Dialog onOpenChange={open => !open && setRenameTarget(null)} open={renameTarget !== null}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.renameTitle}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
autoFocus
|
||||
onChange={event => setRenameValue(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
saveRename()
|
||||
}
|
||||
}}
|
||||
placeholder={copy.renamePlaceholder}
|
||||
value={renameValue}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setRenameTarget(null)} type="button" variant="ghost">
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button disabled={!renameValue.trim()} onClick={saveRename}>
|
||||
{copy.renameSave}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A single hover-revealed icon action on a pet card (rename / export / delete). */
|
||||
function PetAction({
|
||||
danger,
|
||||
icon,
|
||||
label,
|
||||
onClick
|
||||
}: {
|
||||
danger?: boolean
|
||||
icon: ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'grid size-6 place-items-center rounded-md bg-(--ui-bg-elevated)/80 text-(--ui-text-tertiary) backdrop-blur-sm transition',
|
||||
danger ? 'hover:text-(--ui-red)' : 'hover:text-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
48
apps/desktop/src/app/settings/voice-field-visible.test.ts
Normal file
48
apps/desktop/src/app/settings/voice-field-visible.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
import { voiceFieldVisible } from './config-settings'
|
||||
|
||||
const cfg = (over: Record<string, unknown> = {}): HermesConfigRecord =>
|
||||
({
|
||||
tts: { provider: 'edge', edge: {}, openai: {} },
|
||||
stt: { enabled: true, provider: 'local', local: {}, groq: {} },
|
||||
...over
|
||||
}) as unknown as HermesConfigRecord
|
||||
|
||||
describe('voiceFieldVisible', () => {
|
||||
it('always shows top-level + non-provider keys', () => {
|
||||
const config = cfg()
|
||||
|
||||
for (const key of ['tts.provider', 'stt.enabled', 'stt.provider', 'voice.auto_tts', 'voice.record_key']) {
|
||||
expect(voiceFieldVisible(key, config)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('shows only the selected TTS provider sub-fields', () => {
|
||||
const config = cfg()
|
||||
expect(voiceFieldVisible('tts.edge.voice', config)).toBe(true)
|
||||
expect(voiceFieldVisible('tts.openai.voice', config)).toBe(false)
|
||||
expect(voiceFieldVisible('tts.elevenlabs.voice_id', config)).toBe(false)
|
||||
})
|
||||
|
||||
it('shows only the selected STT provider sub-fields', () => {
|
||||
const config = cfg()
|
||||
expect(voiceFieldVisible('stt.local.model', config)).toBe(true)
|
||||
expect(voiceFieldVisible('stt.groq.model', config)).toBe(false)
|
||||
})
|
||||
|
||||
it('hides every STT provider sub-field when STT is disabled', () => {
|
||||
const config = cfg({ stt: { enabled: false, provider: 'local', local: {} } })
|
||||
expect(voiceFieldVisible('stt.local.model', config)).toBe(false)
|
||||
// ...but the enable/provider toggles themselves stay visible.
|
||||
expect(voiceFieldVisible('stt.enabled', config)).toBe(true)
|
||||
expect(voiceFieldVisible('stt.provider', config)).toBe(true)
|
||||
})
|
||||
|
||||
it('tracks a provider switch', () => {
|
||||
expect(voiceFieldVisible('tts.openai.voice', cfg({ tts: { provider: 'openai', openai: {} } }))).toBe(true)
|
||||
expect(voiceFieldVisible('tts.edge.voice', cfg({ tts: { provider: 'openai', openai: {} } }))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { useSyncExternalStore } from 'react'
|
||||
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { PaneShell } from '@/components/pane-shell'
|
||||
import { FloatingPet } from '@/components/pet/floating-pet'
|
||||
import { SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { useMediaQuery } from '@/hooks/use-media-query'
|
||||
import {
|
||||
@@ -202,6 +203,10 @@ export function AppShell({
|
||||
{/* Mounted at the shell root (after overlays) so success/error toasts
|
||||
surface above every route and overlay — not just the chat view. */}
|
||||
<NotificationStack />
|
||||
|
||||
{/* Petdex floating mascot — in-window, always-on-top, reactive to agent
|
||||
activity. Renders nothing unless a pet is installed + enabled. */}
|
||||
<FloatingPet />
|
||||
</SidebarProvider>
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user