Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a348fc1ccc | ||
|
|
677680034a | ||
|
|
222126db1d | ||
|
|
418ceaf8c1 | ||
|
|
c7e23690e0 | ||
|
|
4d8bfa103b | ||
|
|
a68ac0c49a | ||
|
|
9d05f3721d | ||
|
|
16fc717091 | ||
|
|
925b0d1ab5 | ||
|
|
e65d74bc6f | ||
|
|
4858942c55 | ||
|
|
23cc009879 | ||
|
|
4d470b3dbb | ||
|
|
2483200963 | ||
|
|
1ac76a9472 | ||
|
|
9a59ad73dd | ||
|
|
6373aba80f | ||
|
|
fc956b9db6 | ||
|
|
98ae28657f | ||
|
|
4cf9d80fba | ||
|
|
20b1f4f3fb | ||
|
|
a6364bfa08 | ||
|
|
5b3fa26366 | ||
|
|
c6b0eb4de0 | ||
|
|
0441b7f19f | ||
|
|
7cd71de1f4 | ||
|
|
b1d6a57883 | ||
|
|
0b6b29a30c | ||
|
|
55cb4103be | ||
|
|
67233d1c2a | ||
|
|
0f75e9904a | ||
|
|
98c294126b | ||
|
|
0a8f3e21b8 | ||
|
|
2dbc3bd937 | ||
|
|
9d2ec8d35a | ||
|
|
423d24780b | ||
|
|
37d717054e | ||
|
|
1cb75b7971 | ||
|
|
5bfed0fe07 | ||
|
|
5a0e0d35b9 | ||
|
|
d2b34e89b0 | ||
|
|
6dde7d4657 | ||
|
|
c7513df4f9 | ||
|
|
062c17d34f | ||
|
|
e0492aa2dc | ||
|
|
cffd6e3c8d | ||
|
|
c66ecf0bc3 | ||
|
|
368fcf1ff0 | ||
|
|
39f479cba8 | ||
|
|
ed20f5ed06 | ||
|
|
2a08b8c86f | ||
|
|
b2a4766463 | ||
|
|
60cc42e38b | ||
|
|
9df1a1a8de | ||
|
|
c33e0457d7 | ||
|
|
f7c1cbe66f | ||
|
|
c23a2eec15 | ||
|
|
f3b32e9f52 | ||
|
|
5f6be7f31b | ||
|
|
0bbf325a8f | ||
|
|
0bbff1fc7e | ||
|
|
ae433634db | ||
|
|
9eb0bcd60f | ||
|
|
45e2f4fdcd | ||
|
|
30377e108c | ||
|
|
f02484feba | ||
|
|
eae3836eb6 | ||
|
|
3e7e9b24d4 | ||
|
|
ead38107a2 | ||
|
|
5035fa9029 | ||
|
|
5b2604df99 | ||
|
|
2f2e3616b4 | ||
|
|
bee13817f0 | ||
|
|
fbabf438a1 | ||
|
|
49e743985a | ||
|
|
ba3883cd18 | ||
|
|
be7c919bf9 | ||
|
|
733472952a | ||
|
|
e5b4cf7bea | ||
|
|
29c6985590 | ||
|
|
92a456f711 | ||
|
|
975b9f0a54 | ||
|
|
0d82060c74 | ||
|
|
ea49a79633 | ||
|
|
c17469cb19 | ||
|
|
febdddb41a | ||
|
|
aab2e99bae | ||
|
|
ad58dd51ac | ||
|
|
a688d2a1bd | ||
|
|
40699c3292 | ||
|
|
c1a70a5439 | ||
|
|
2cddc9c895 | ||
|
|
f79b109f4f | ||
|
|
ec05d2bc3e | ||
|
|
a376ca0081 | ||
|
|
8844e091c1 | ||
|
|
1227007aed | ||
|
|
497352bc4e | ||
|
|
f1d6f04362 | ||
|
|
dcc3216955 | ||
|
|
aca11c227e | ||
|
|
946d3eaf95 | ||
|
|
bf45aa3a45 | ||
|
|
16e408f3f0 | ||
|
|
4108fe6014 | ||
|
|
b0fb2b8b05 | ||
|
|
6cb88a0874 | ||
|
|
8fce54499f | ||
|
|
b0c99c12dd | ||
|
|
ddf7c7af81 | ||
|
|
d6a8d9dcab | ||
|
|
95715dcb03 | ||
|
|
80f8ffc74c | ||
|
|
c2b7669ad3 | ||
|
|
b770967263 | ||
|
|
61ee2dbfdb | ||
|
|
f795513782 | ||
|
|
8fe334b056 | ||
|
|
40d7c264f0 | ||
|
|
4eb0ff639b | ||
|
|
1ddf7a1021 | ||
|
|
1db8f7ea80 | ||
|
|
98205da008 | ||
|
|
a4ee1f223d | ||
|
|
8f4a718f95 | ||
|
|
5e851bc6bc | ||
|
|
a70f7f3b7b | ||
|
|
6e3c393ef9 | ||
|
|
c7e5215b50 | ||
|
|
25686feebf | ||
|
|
8e3b320eb8 | ||
|
|
f5823277dc | ||
|
|
33924c074c | ||
|
|
21c64f90aa | ||
|
|
8e853e3ff8 | ||
|
|
76eab10b14 | ||
|
|
5c5a1fec4b | ||
|
|
7016fa4902 | ||
|
|
74cb03423e | ||
|
|
5988e21ed7 | ||
|
|
965226fd52 | ||
|
|
353a8c1c8f | ||
|
|
5747d9a2d8 | ||
|
|
e01b04de46 | ||
|
|
ef9232a2f7 | ||
|
|
5268027e6b | ||
|
|
b6598017c8 | ||
|
|
5af3a81490 | ||
|
|
7b7ab279f2 | ||
|
|
01669f2f12 | ||
|
|
338b5275be | ||
|
|
ef94562125 | ||
|
|
3616b813ec | ||
|
|
e1067dbbe5 | ||
|
|
ab37440ce6 | ||
|
|
cf3002664b | ||
|
|
fc8d5f203a | ||
|
|
c5806b9ad9 | ||
|
|
b145607029 | ||
|
|
4a3b755162 | ||
|
|
16dbcbe85d | ||
|
|
c04aaecb51 | ||
|
|
eaa069e322 | ||
|
|
2d7616121b | ||
|
|
1544813bfe | ||
|
|
2708c33c75 | ||
|
|
23a7458acf | ||
|
|
8afb7bc570 | ||
|
|
94765e48ff | ||
|
|
31916539af | ||
|
|
79d1b58afe | ||
|
|
99feb03607 | ||
|
|
b091b4eaeb | ||
|
|
d7dfeed6dc | ||
|
|
7e01a96e53 | ||
|
|
bb5cb32838 | ||
|
|
31e0adc681 | ||
|
|
8445995321 | ||
|
|
abba43eb63 | ||
|
|
4a0991c1d2 | ||
|
|
364b93a4b9 | ||
|
|
85546bb9e2 | ||
|
|
ba3fe7027c | ||
|
|
5999cd2848 | ||
|
|
639a9cb9a7 | ||
|
|
a09fa9df42 | ||
|
|
4e69fdb3be | ||
|
|
b3efafcc73 | ||
|
|
036e863e4a | ||
|
|
e3cdedbf0f | ||
|
|
38eb9bb19a | ||
|
|
0bb58b65ec | ||
|
|
fb30ff218d | ||
|
|
e1edbb0e89 | ||
|
|
72118b049f | ||
|
|
c007d08419 | ||
|
|
73b261b94f | ||
|
|
f4bb617f62 | ||
|
|
4c630d3e7b | ||
|
|
bc71c57ba9 | ||
|
|
ab5d422835 | ||
|
|
72ee55ed53 | ||
|
|
df4bdc9d58 | ||
|
|
eaad47a6f6 | ||
|
|
8c3060342f | ||
|
|
c9c6cfc0ee | ||
|
|
6438acec60 | ||
|
|
76a8bba15f | ||
|
|
ad220b9d93 | ||
|
|
ca791f4000 | ||
|
|
0dafcdd9e3 | ||
|
|
6e62489d9e | ||
|
|
076aebc7e6 | ||
|
|
b6dc49200d | ||
|
|
0a5b0780f5 | ||
|
|
c4348480f3 | ||
|
|
f76df0688c | ||
|
|
84cbf5c1f3 | ||
|
|
0f92a3cf63 | ||
|
|
60cbc4c68b | ||
|
|
ae11a636dc | ||
|
|
25567919ea | ||
|
|
dcd8ba2a0d | ||
|
|
f205dc2a3b | ||
|
|
c40d3172ac | ||
|
|
52533bea09 | ||
|
|
9eb36fd697 | ||
|
|
f8f4b3044a | ||
|
|
0dc257d610 | ||
|
|
20865a2653 | ||
|
|
da07e67efd | ||
|
|
b36001940a | ||
|
|
f4d944c49c | ||
|
|
e4652b99e2 | ||
|
|
6a73b09d15 | ||
|
|
af82979d43 | ||
|
|
3d87abcf1c | ||
|
|
4cb9aa6664 | ||
|
|
bc79644f16 | ||
|
|
e36b2d1519 | ||
|
|
fe15a9bb00 | ||
|
|
af577a4c5a | ||
|
|
9e81be7228 | ||
|
|
28a2f95631 | ||
|
|
07fcb3282c | ||
|
|
41a5bbf3e8 | ||
|
|
84b77f68e5 | ||
|
|
c29402d731 | ||
|
|
76e9271dce | ||
|
|
60c5a82c85 | ||
|
|
eb4821127c | ||
|
|
028bd89959 | ||
|
|
0f53d67ee4 | ||
|
|
aa5489e804 | ||
|
|
2a86f039ea | ||
|
|
79c6896153 | ||
|
|
46293f618c | ||
|
|
080440bd9c | ||
|
|
bd3c253420 | ||
|
|
82e13ed949 | ||
|
|
fb04e85a14 | ||
|
|
06762a0f5e | ||
|
|
92f35fab19 | ||
|
|
cd11ed7a04 | ||
|
|
4407fee49f | ||
|
|
48d0c70f61 | ||
|
|
4061e635d3 | ||
|
|
741a4c23ca | ||
|
|
c6e72a8454 | ||
|
|
180fe665cb | ||
|
|
6a24249e7c | ||
|
|
ee211d087b | ||
|
|
aec752faa3 | ||
|
|
c9540570ae | ||
|
|
6e3915fbc1 | ||
|
|
c3d2d87a74 | ||
|
|
f423aebb80 | ||
|
|
37b74f4df3 | ||
|
|
247604cdde | ||
|
|
2bb61a7d09 | ||
|
|
1ecec7a9bc | ||
|
|
325350d192 | ||
|
|
1be5bd92fa | ||
|
|
93793b6af5 | ||
|
|
26f6929eb4 | ||
|
|
808ef152e5 | ||
|
|
e7d7e0157f | ||
|
|
edc4164704 | ||
|
|
7412cd5c78 | ||
|
|
3f54152191 | ||
|
|
3fe7709b86 | ||
|
|
abce50e34d | ||
|
|
c704d384f4 | ||
|
|
4f2bb7e52f | ||
|
|
1bc376921f | ||
|
|
6cefb7c5b5 | ||
|
|
59fbc05031 | ||
|
|
4b81ded58b | ||
|
|
927c902785 | ||
|
|
d3943fe37d | ||
|
|
2bd9c9b881 |
@@ -1,12 +1,11 @@
|
||||
name: Contributor Attribution Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
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]
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -18,13 +18,12 @@ on:
|
||||
- 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]
|
||||
paths:
|
||||
- Dockerfile
|
||||
- docker/**
|
||||
- .hadolint.yaml
|
||||
- .github/workflows/docker-lint.yml
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -11,16 +11,13 @@ on:
|
||||
- 'docker/**'
|
||||
- '.github/workflows/docker-publish.yml'
|
||||
- '.github/actions/hermes-smoke-test/**'
|
||||
|
||||
# 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]
|
||||
paths:
|
||||
- '**/*.py'
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- 'Dockerfile'
|
||||
- 'docker/**'
|
||||
- '.github/workflows/docker-publish.yml'
|
||||
- '.github/actions/hermes-smoke-test/**'
|
||||
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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:
|
||||
paths:
|
||||
- 'website/**'
|
||||
- '.github/workflows/docs-site-checks.yml'
|
||||
branches: [main]
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -14,9 +16,9 @@ jobs:
|
||||
docs-site-checks:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
@@ -26,9 +28,9 @@ jobs:
|
||||
run: npm ci
|
||||
working-directory: website
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install ascii-guard
|
||||
run: python -m pip install ascii-guard==2.3.0 pyyaml==6.0.3
|
||||
|
||||
@@ -14,6 +14,9 @@ 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]
|
||||
|
||||
@@ -24,9 +27,9 @@ jobs:
|
||||
check-common-ancestor:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # full history both sides for merge-base
|
||||
fetch-depth: 0 # full history both sides for merge-base
|
||||
|
||||
- name: Reject PRs with no common ancestor on main
|
||||
run: |
|
||||
|
||||
@@ -15,12 +15,12 @@ on:
|
||||
- "**/*.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]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- "website/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -154,7 +154,6 @@ jobs:
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
ruff-blocking:
|
||||
# Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently
|
||||
# PLW1514 (unspecified-encoding) — catches bare ``open()`` /
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
name: Nix Lockfile Fix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'package-lock.json'
|
||||
- 'package.json'
|
||||
- 'ui-tui/package.json'
|
||||
- 'apps/desktop/package.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to fix (leave empty to run on the selected branch)'
|
||||
required: false
|
||||
type: string
|
||||
issue_comment:
|
||||
types: [edited]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: nix-lockfile-fix-${{ github.event.issue.number || github.event.inputs.pr_number || github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# ── Auto-fix on main ───────────────────────────────────────────────
|
||||
# Fires when a push to main touches package.json or package-lock.json.
|
||||
# Runs fix-lockfiles and pushes the hash update commit directly to main
|
||||
# so Nix builds never stay broken.
|
||||
#
|
||||
# Safety invariants:
|
||||
# 1. The fix commit only touches nix/*.nix files, which are NOT in
|
||||
# the paths filter above, so this cannot re-trigger itself.
|
||||
# 2. An explicit file-whitelist check before commit aborts if
|
||||
# fix-lockfiles ever modifies unexpected files.
|
||||
# 3. Job-level concurrency with cancel-in-progress: true ensures
|
||||
# back-to-back pushes collapse to the newest; ref: main checkout
|
||||
# always operates on the latest branch state.
|
||||
# 4. Uses a GitHub App token (not GITHUB_TOKEN) so the fix commit
|
||||
# triggers downstream nix.yml verification.
|
||||
auto-fix-main:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
concurrency:
|
||||
group: auto-fix-main
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@7bfa3a4717ef143a604ee0a99d859b8886a96d00 # v1.9.3
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Apply lockfile hashes
|
||||
id: apply
|
||||
run: nix run .#fix-lockfiles -- --apply
|
||||
|
||||
- name: Commit & push
|
||||
if: steps.apply.outputs.changed == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Ensure only nix/lib.nix (home of the single npmDepsHash) was
|
||||
# modified — prevents accidental self-triggering if fix-lockfiles
|
||||
# ever touches package files.
|
||||
unexpected="$(git diff --name-only | grep -Ev '^nix/lib\.nix$' || true)"
|
||||
if [ -n "$unexpected" ]; then
|
||||
echo "::error::Unexpected modified files: $unexpected"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Record the base SHA before committing — used to detect package
|
||||
# file changes if we need to rebase after a non-fast-forward push.
|
||||
BASE_SHA="$(git rev-parse HEAD)"
|
||||
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git add nix/lib.nix
|
||||
git commit -m "fix(nix): auto-refresh npm lockfile hashes" \
|
||||
-m "Source: $GITHUB_SHA" \
|
||||
-m "Run: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
|
||||
|
||||
# Retry push with rebase in case main advanced with an unrelated
|
||||
# commit during the nix build. Without this, a non-fast-forward
|
||||
# rejection silently loses the fix. If package files changed during
|
||||
# the rebase, abort — a fresh auto-fix run will handle the new state.
|
||||
for attempt in 1 2 3; do
|
||||
if git push origin HEAD:main; then
|
||||
exit 0
|
||||
fi
|
||||
echo "::warning::Push attempt $attempt failed (non-fast-forward?), rebasing…"
|
||||
git fetch origin main
|
||||
|
||||
# If package files changed between our base and the new main,
|
||||
# our computed hashes are stale. Abort and let the next triggered
|
||||
# run recompute from the correct package-lock state.
|
||||
pkg_changed="$(git diff --name-only "$BASE_SHA"..origin/main -- \
|
||||
'package-lock.json' 'package.json' \
|
||||
'ui-tui/package.json' 'apps/desktop/package.json' || true)"
|
||||
if [ -n "$pkg_changed" ]; then
|
||||
echo "::warning::Package files changed since hash computation — aborting; a fresh run will recompute"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git rebase origin/main
|
||||
done
|
||||
echo "::error::Failed to push after 3 rebase attempts"
|
||||
exit 1
|
||||
|
||||
# ── PR fix (manual / checkbox) ─────────────────────────────────────
|
||||
# Existing behavior: run on manual dispatch OR when a task-list
|
||||
# checkbox in the sticky lockfile-check comment flips from [ ] to [x].
|
||||
fix:
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'issue_comment'
|
||||
&& github.event.issue.pull_request != null
|
||||
&& contains(github.event.comment.body, '[x] **Apply lockfile fix**')
|
||||
&& !contains(github.event.changes.body.from, '[x] **Apply lockfile fix**'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Authorize & resolve PR
|
||||
id: resolve
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
// 1. Verify the actor has write access — applies to both checkbox
|
||||
// clicks and manual dispatch.
|
||||
const { data: perm } =
|
||||
await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: context.actor,
|
||||
});
|
||||
if (!['admin', 'write', 'maintain'].includes(perm.permission)) {
|
||||
core.setFailed(
|
||||
`${context.actor} lacks write access (has: ${perm.permission})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Resolve which ref to check out.
|
||||
let prNumber = '';
|
||||
if (context.eventName === 'issue_comment') {
|
||||
prNumber = String(context.payload.issue.number);
|
||||
} else if (context.eventName === 'workflow_dispatch') {
|
||||
prNumber = context.payload.inputs.pr_number || '';
|
||||
}
|
||||
|
||||
if (!prNumber) {
|
||||
core.setOutput('ref', context.ref.replace(/^refs\/heads\//, ''));
|
||||
core.setOutput('repo', context.repo.repo);
|
||||
core.setOutput('owner', context.repo.owner);
|
||||
core.setOutput('pr', '');
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: Number(prNumber),
|
||||
});
|
||||
core.setOutput('ref', pr.head.ref);
|
||||
core.setOutput('repo', pr.head.repo.name);
|
||||
core.setOutput('owner', pr.head.repo.owner.login);
|
||||
core.setOutput('pr', String(pr.number));
|
||||
|
||||
# Wipe the sticky lockfile-check comment to a "running" state as soon
|
||||
# as the job is authorized, so the user sees their click was picked up
|
||||
# before the ~minute of nix build work.
|
||||
- name: Mark sticky as running
|
||||
if: steps.resolve.outputs.pr != ''
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
number: ${{ steps.resolve.outputs.pr }}
|
||||
message: |
|
||||
### 🔄 Applying lockfile fix…
|
||||
|
||||
Triggered by @${{ github.actor }} — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: ${{ steps.resolve.outputs.owner }}/${{ steps.resolve.outputs.repo }}
|
||||
ref: ${{ steps.resolve.outputs.ref }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Apply lockfile hashes
|
||||
id: apply
|
||||
run: nix run .#fix-lockfiles
|
||||
|
||||
- name: Commit & push
|
||||
if: steps.apply.outputs.changed == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git add nix/lib.nix
|
||||
git commit -m "fix(nix): refresh npm lockfile hashes"
|
||||
git push
|
||||
|
||||
- name: Update sticky (applied)
|
||||
if: steps.apply.outputs.changed == 'true' && steps.resolve.outputs.pr != ''
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
number: ${{ steps.resolve.outputs.pr }}
|
||||
message: |
|
||||
### ✅ Lockfile fix applied
|
||||
|
||||
Pushed a commit refreshing the npm lockfile hashes — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
|
||||
- name: Update sticky (already current)
|
||||
if: steps.apply.outputs.changed == 'false' && steps.resolve.outputs.pr != ''
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
number: ${{ steps.resolve.outputs.pr }}
|
||||
message: |
|
||||
### ✅ Lockfile hashes already current
|
||||
|
||||
Nothing to commit — [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).
|
||||
|
||||
- name: Update sticky (failed)
|
||||
if: failure() && steps.resolve.outputs.pr != ''
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
number: ${{ steps.resolve.outputs.pr }}
|
||||
message: |
|
||||
### ❌ Lockfile fix failed
|
||||
|
||||
See the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for logs.
|
||||
@@ -1,105 +0,0 @@
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: ./.github/actions/nix-setup
|
||||
with:
|
||||
cachix-auth-token: ${{ secrets.CACHIX_AUTH_TOKEN }}
|
||||
|
||||
- name: Resolve head SHA
|
||||
if: github.event_name == 'pull_request'
|
||||
id: sha
|
||||
shell: bash
|
||||
run: |
|
||||
FULL="${{ github.event.pull_request.head.sha || github.sha }}"
|
||||
echo "full=$FULL" >> "$GITHUB_OUTPUT"
|
||||
echo "short=${FULL:0:7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check flake
|
||||
id: flake
|
||||
continue-on-error: true
|
||||
run: nix flake check --print-build-logs
|
||||
|
||||
# When the flake check fails, run a targeted diagnostic to see if
|
||||
# the failure is specifically a stale npm lockfile hash in one of the
|
||||
# known npm subpackages (tui / web). This avoids surfacing a generic
|
||||
# "build failed" message when the fix is a single known command.
|
||||
- name: Diagnose npm lockfile hashes
|
||||
id: hash_check
|
||||
if: steps.flake.outcome == 'failure' && runner.os == 'Linux'
|
||||
continue-on-error: true
|
||||
env:
|
||||
LINK_SHA: ${{ steps.sha.outputs.full }}
|
||||
run: nix run .#fix-lockfiles -- --check
|
||||
|
||||
# If fix-lockfiles itself crashes (infrastructure blip, cache throttle,
|
||||
# etc.) it won't set stale=true/false. Treat that as a distinct failure
|
||||
# mode rather than silently ignoring it.
|
||||
- name: Fail if hash check crashed without reporting
|
||||
if: steps.hash_check.outcome == 'failure' && steps.hash_check.outputs.stale != 'true' && steps.hash_check.outputs.stale != 'false'
|
||||
run: |
|
||||
echo "::error::fix-lockfiles exited without reporting stale status — likely an infrastructure or script failure"
|
||||
exit 1
|
||||
|
||||
- name: Post sticky PR comment (stale hashes)
|
||||
if: steps.hash_check.outputs.stale == 'true' && github.event_name == 'pull_request'
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
message: |
|
||||
### ⚠️ npm lockfile hash out of date
|
||||
|
||||
Checked against commit [`${{ steps.sha.outputs.short }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ steps.sha.outputs.full }}) (PR head at check time).
|
||||
|
||||
The `hash = "sha256-..."` line in these nix files no longer matches the committed `package-lock.json`:
|
||||
|
||||
${{ steps.hash_check.outputs.report }}
|
||||
|
||||
#### Apply the fix
|
||||
|
||||
- [ ] **Apply lockfile fix** — tick to push a commit with the correct hashes to this PR branch
|
||||
- Or [run the Nix Lockfile Fix workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/nix-lockfile-fix.yml) manually (pass PR `#${{ github.event.pull_request.number }}`)
|
||||
- Or locally: `nix run .#fix-lockfiles` and commit the diff
|
||||
|
||||
# Clear the sticky comment when either the flake check passed outright (no
|
||||
# hash check needed) or the hash check explicitly returned stale=false
|
||||
# (check failed for a non-hash reason).
|
||||
- name: Clear sticky PR comment (resolved)
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
(steps.hash_check.outputs.stale == 'false' ||
|
||||
steps.flake.outcome == 'success')
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
with:
|
||||
header: nix-lockfile-check
|
||||
delete: true
|
||||
|
||||
- name: Final fail if flake check failed
|
||||
if: steps.flake.outcome == 'failure'
|
||||
run: |
|
||||
if [ "${{ steps.hash_check.outputs.stale }}" == "true" ]; then
|
||||
echo "::error::Nix build failed due to stale npm lockfile hash. Run: nix run .#fix-lockfiles"
|
||||
else
|
||||
echo "::error::Nix flake check failed. See logs above."
|
||||
fi
|
||||
exit 1
|
||||
@@ -20,29 +20,23 @@ 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]
|
||||
paths:
|
||||
- 'uv.lock'
|
||||
- 'pyproject.toml'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'ui-tui/package.json'
|
||||
- 'website/package.json'
|
||||
- 'website/package-lock.json'
|
||||
- '.github/workflows/osv-scanner.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'uv.lock'
|
||||
- 'pyproject.toml'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'website/package-lock.json'
|
||||
- "uv.lock"
|
||||
- "pyproject.toml"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "website/package-lock.json"
|
||||
schedule:
|
||||
# Weekly scan against main — catches CVEs published after merge for
|
||||
# deps that haven't changed since.
|
||||
- cron: '0 9 * * 1'
|
||||
- cron: "0 9 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -54,7 +48,7 @@ permissions:
|
||||
jobs:
|
||||
scan:
|
||||
name: Scan lockfiles
|
||||
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
|
||||
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
|
||||
with:
|
||||
# Scan explicit lockfiles rather than recursing, so we only look at
|
||||
# the three sources of truth and skip vendored / test / worktree dirs.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
name: Supply Chain Audit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
# 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
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
# 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
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Check for relevant file changes
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -286,7 +286,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ on:
|
||||
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]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -219,4 +219,4 @@ jobs:
|
||||
env:
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
@@ -4,6 +4,9 @@ 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]
|
||||
|
||||
@@ -23,3 +26,20 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run --prefix ${{ matrix.package }} typecheck
|
||||
|
||||
# Production build of the desktop renderer. `typecheck` runs `tsc` only,
|
||||
# which does NOT exercise Vite/Rolldown module resolution — so an
|
||||
# unresolvable package export (e.g. a transitive @assistant-ui/tap that no
|
||||
# longer exports "./react-shim") slips past typecheck and only explodes when
|
||||
# users build apps/desktop from source on install/update. Run the real
|
||||
# `vite build` here so that class of break fails in CI instead.
|
||||
desktop-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run --prefix apps/desktop build
|
||||
|
||||
@@ -47,15 +47,15 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- '.github/workflows/uv-lockfile-check.yml'
|
||||
- "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]
|
||||
paths:
|
||||
- 'pyproject.toml'
|
||||
- 'uv.lock'
|
||||
- '.github/workflows/uv-lockfile-check.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -71,10 +71,10 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
|
||||
# `uv lock --check` re-resolves the project from pyproject.toml and
|
||||
# compares the result to uv.lock, exiting non-zero if they disagree.
|
||||
|
||||
+44
-6
@@ -78,7 +78,41 @@ This isn't a quality bar — it's a coupling-and-maintenance decision. Memory pr
|
||||
| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) |
|
||||
| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) |
|
||||
|
||||
### Clone and install
|
||||
### Install with the standard installer
|
||||
|
||||
For most contributors, the best development bootstrap is the same path users
|
||||
take: run the standard installer, then work inside the repository it cloned.
|
||||
The installer creates the Hermes venv, wires the `hermes` command, stamps the
|
||||
install method for `hermes update`, and clones the full git project into
|
||||
`$HERMES_HOME/hermes-agent` (usually `~/.hermes/hermes-agent`). That keeps your
|
||||
development environment on the same layout the CLI, updater, lazy dependency
|
||||
installer, gateway, and docs assume.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
|
||||
|
||||
# Add dev/test extras on top of the standard install.
|
||||
uv pip install -e ".[all,dev]"
|
||||
|
||||
# Optional: browser tools / docs site dependencies.
|
||||
npm install
|
||||
```
|
||||
|
||||
After that, create branches and run tests from that checkout:
|
||||
|
||||
```bash
|
||||
git checkout -b fix/description
|
||||
scripts/run_tests.sh
|
||||
```
|
||||
|
||||
### Manual clone fallback
|
||||
|
||||
Use this only if you intentionally do not want Hermes' managed install layout
|
||||
(for example, a throwaway clone inside a container or CI job). If you install
|
||||
this way, make sure you run the `hermes` entrypoint from this venv; running the
|
||||
system `python3 -m hermes_cli.main` can pick up unrelated system Python
|
||||
packages.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
@@ -109,15 +143,19 @@ echo "OPENROUTER_API_KEY=***" >> ~/.hermes/.env
|
||||
### Run
|
||||
|
||||
```bash
|
||||
# Symlink for global access
|
||||
mkdir -p ~/.local/bin
|
||||
ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
|
||||
|
||||
# Verify
|
||||
# The standard installer already put `hermes` on PATH.
|
||||
hermes doctor
|
||||
hermes chat -q "Hello"
|
||||
```
|
||||
|
||||
If you used the manual clone fallback, run `./hermes` from the checkout or
|
||||
symlink this clone's venv explicitly:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.local/bin
|
||||
ln -sf "$(pwd)/venv/bin/hermes" ~/.local/bin/hermes
|
||||
```
|
||||
|
||||
### Run tests
|
||||
|
||||
```bash
|
||||
|
||||
+21
-11
@@ -1,12 +1,14 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.11.6-python3.13-trixie@sha256:b3c543b6c4f23a5f2df22866bd7857e5d304b67a564f4feab6ac22044dde719b AS uv_source
|
||||
# Node 22 LTS source stage. Debian trixie's bundled nodejs is pinned to 20.x
|
||||
# which reached EOL in April 2026 — we copy node + npm + corepack from the
|
||||
# upstream node:22 image instead so we can stay on a supported LTS without
|
||||
# waiting for Debian 14 (forky, ~mid-2027). Bookworm-based slim image used
|
||||
# so the produced binary links against glibc 2.36, which runs cleanly on
|
||||
# our Debian 13 (trixie, glibc 2.41) runtime. Bumping to a new Node major
|
||||
# is a one-line ARG change; see #4977.
|
||||
FROM node:22-bookworm-slim@sha256:7af03b14a13c8cdd38e45058fd957bf00a72bbe17feac43b1c15a689c029c732 AS node_source
|
||||
# Node 26 source stage. Debian trixie's bundled nodejs is pinned to 20.x
|
||||
# (EOL April 2026), so we copy node + npm + corepack from the upstream node:26
|
||||
# image instead. Node 26 (Current; LTS promotion ~Oct 2026) is REQUIRED by the
|
||||
# native OpenTUI TUI engine, which loads its renderer via the experimental
|
||||
# `node:ffi` API that only exists on Node 26.3+ (the Ink engine + web build run
|
||||
# on it too). Bookworm-based slim image used so the produced binary links
|
||||
# against glibc 2.36, which runs cleanly on our Debian 13 (trixie, glibc 2.41)
|
||||
# runtime. The pinned tag ships v26.3.0. Bumping Node is a one-line change here.
|
||||
# NOTE: verify the full image build + Ink/web/Playwright on Node 26 in CI.
|
||||
FROM node:26-bookworm-slim@sha256:79723b41edbedf595f62e943a9f8b0ba9af5b1e61045c5f8f59c2c02c1212a16 AS node_source
|
||||
FROM debian:13.4
|
||||
|
||||
# Disable Python stdout buffering to ensure logs are printed immediately
|
||||
@@ -90,7 +92,7 @@ RUN useradd -u 10000 -m -d /opt/data hermes
|
||||
|
||||
COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/
|
||||
|
||||
# Node 22 LTS: copy the node binary plus the bundled npm + corepack JS
|
||||
# Node 26: copy the node binary plus the bundled npm + corepack JS
|
||||
# installs from the upstream image. npm and npx are recreated as symlinks
|
||||
# because they're symlinks in the source image (and need to live on PATH).
|
||||
# See node_source stage at the top of the file for the version-bump
|
||||
@@ -119,7 +121,7 @@ COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/
|
||||
|
||||
# `npm_config_install_links=false` forces npm to install `file:` deps as
|
||||
# symlinks instead of copies. This is the default since npm 10+, which is
|
||||
# what the image ships now (via the node:22 source stage). We set it
|
||||
# what the image ships now (via the node:26 source stage). We set it
|
||||
# explicitly anyway as defense-in-depth: the previous Debian-bundled npm
|
||||
# 9.x defaulted to install-as-copy, which produced a hidden
|
||||
# node_modules/.package-lock.json that permanently disagreed with the root
|
||||
@@ -181,8 +183,16 @@ RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra
|
||||
# invalidate the (relatively slow) web + ui-tui build layer.
|
||||
COPY web/ web/
|
||||
COPY ui-tui/ ui-tui/
|
||||
COPY ui-opentui/ ui-opentui/
|
||||
# ui-opentui is the opt-in native OpenTUI engine (HERMES_TUI_ENGINE=opentui;
|
||||
# default stays Ink). .dockerignore strips its node_modules/dist, so install +
|
||||
# esbuild-build it here -> dist/main.js, then prune devDeps (esbuild/babel/
|
||||
# vitest); the runtime only needs the prod deps (the external @opentui/core +
|
||||
# its native blob -- the bundle inlines solid/effect). Build needs Node 26.3
|
||||
# (node:ffi floor), which this image ships.
|
||||
RUN cd web && npm run build && \
|
||||
cd ../ui-tui && npm run build
|
||||
cd ../ui-tui && npm run build && \
|
||||
cd ../ui-opentui && npm install --no-audit --no-fund && npm run build && npm prune --omit=dev
|
||||
|
||||
# ---------- Source code ----------
|
||||
# .dockerignore excludes node_modules, so the installs above survive.
|
||||
|
||||
@@ -107,6 +107,8 @@ You can still bring your own keys per-tool whenever you want — the gateway is
|
||||
|
||||
Hermes has two entry points: start the terminal UI with `hermes`, or run the gateway and talk to it from Telegram, Discord, Slack, WhatsApp, Signal, or Email. Once you're in a conversation, many slash commands are shared across both interfaces.
|
||||
|
||||
> **TUI engine:** On supported hosts (Linux/macOS with Node 26.3+), the terminal UI defaults to the native **OpenTUI** engine, which the installer provisions for you. The legacy **Ink** engine remains the fallback — it's used automatically on Windows, Termux, or when the native engine can't run, and you can select it explicitly with `HERMES_TUI_ENGINE=ink hermes`. Ink is not going away; it's the kept fallback.
|
||||
|
||||
| Action | CLI | Messaging platforms |
|
||||
| ------------------------------ | --------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| Start chatting | `hermes` | Run `hermes gateway setup` + `hermes gateway start`, then send the bot a message |
|
||||
@@ -181,16 +183,20 @@ See `hermes claw migrate --help` for all options, or use the `openclaw-migration
|
||||
|
||||
We welcome contributions! See the [Contributing Guide](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) for development setup, code style, and PR process.
|
||||
|
||||
Quick start for contributors — clone and go with `setup-hermes.sh`:
|
||||
Quick start for contributors — use the standard installer, then work from the
|
||||
full git checkout it creates at `$HERMES_HOME/hermes-agent` (usually
|
||||
`~/.hermes/hermes-agent`). This matches the layout used by `hermes update`, the
|
||||
managed venv, lazy dependencies, gateway, and docs tooling.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
cd hermes-agent
|
||||
./setup-hermes.sh # installs uv, creates venv, installs .[all], symlinks ~/.local/bin/hermes
|
||||
./hermes # auto-detects the venv, no need to `source` first
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
|
||||
uv pip install -e ".[all,dev]"
|
||||
scripts/run_tests.sh
|
||||
```
|
||||
|
||||
Manual path (equivalent to the above):
|
||||
Manual clone fallback (for throwaway clones/CI where you intentionally do not
|
||||
want the managed install layout):
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
+8
-6
@@ -164,16 +164,18 @@ hermes claw migrate --overwrite # 覆盖已有冲突
|
||||
|
||||
欢迎贡献!请参阅 [贡献指南](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) 了解开发设置、代码风格和 PR 流程。
|
||||
|
||||
贡献者快速开始——克隆并使用 `setup-hermes.sh`:
|
||||
贡献者快速开始——使用标准安装器,然后在它创建的完整 git checkout 中开发:
|
||||
`$HERMES_HOME/hermes-agent`(通常是 `~/.hermes/hermes-agent`)。这会匹配
|
||||
`hermes update`、托管 venv、lazy dependencies、gateway 和 docs tooling 使用的布局。
|
||||
|
||||
```bash
|
||||
git clone https://github.com/NousResearch/hermes-agent.git
|
||||
cd hermes-agent
|
||||
./setup-hermes.sh # 安装 uv、创建 venv、安装 .[all]、创建符号链接 ~/.local/bin/hermes
|
||||
./hermes # 自动检测 venv,无需先 source
|
||||
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
cd "${HERMES_HOME:-$HOME/.hermes}/hermes-agent"
|
||||
uv pip install -e ".[all,dev]"
|
||||
scripts/run_tests.sh
|
||||
```
|
||||
|
||||
手动安装(等效于上述命令):
|
||||
手动克隆备用路径(用于一次性 clone / CI,或你明确不想使用 managed install layout 时):
|
||||
|
||||
```bash
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
@@ -299,6 +299,7 @@ def init_agent(
|
||||
# would mangle the escape sequences. None = use builtins.print.
|
||||
agent._print_fn = None
|
||||
agent.background_review_callback = None # Optional sync callback for gateway delivery
|
||||
agent.memory_notifications = "on" # Memory update notifications: "off", "on", "verbose"
|
||||
agent.skip_context_files = skip_context_files
|
||||
agent.load_soul_identity = load_soul_identity
|
||||
agent.pass_session_id = pass_session_id
|
||||
|
||||
@@ -1217,12 +1217,23 @@ def dump_api_request_debug(
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
dump_file = agent.logs_dir / f"request_dump_{agent.session_id}_{timestamp}.json"
|
||||
atomic_json_write(dump_file, dump_payload, default=str)
|
||||
|
||||
# Redact secrets before persisting/printing. This dump captures the
|
||||
# full request body (system prompt, tool defs, context-embedded
|
||||
# values), and this path fires unconditionally on API errors — so it
|
||||
# otherwise lands any context-embedded secret in cleartext on disk.
|
||||
# Run the serialized dump through the same scrubber used for logs/tool
|
||||
# output, then hand the resulting payload back to the shared atomic
|
||||
# JSON writer so request dumps keep the same write semantics as before.
|
||||
from agent.redact import redact_sensitive_text
|
||||
_serialized = json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str)
|
||||
_redacted_payload = json.loads(redact_sensitive_text(_serialized, force=True))
|
||||
atomic_json_write(dump_file, _redacted_payload, default=str)
|
||||
|
||||
agent._vprint(f"{agent.log_prefix}🧾 Request debug dump written to: {dump_file}")
|
||||
|
||||
if env_var_enabled("HERMES_DUMP_REQUEST_STDOUT"):
|
||||
print(json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str))
|
||||
print(json.dumps(_redacted_payload, ensure_ascii=False, indent=2, default=str))
|
||||
|
||||
return dump_file
|
||||
except Exception as dump_error:
|
||||
|
||||
+159
-27
@@ -3079,23 +3079,20 @@ def _try_configured_fallback_chain(
|
||||
if not fb_provider or fb_provider.lower() == skip:
|
||||
continue
|
||||
fb_model = str(entry.get("model", "")).strip() or None
|
||||
fb_base_url = str(entry.get("base_url", "")).strip() or None
|
||||
fb_api_key = str(entry.get("api_key", "")).strip() or None
|
||||
|
||||
label = f"fallback_chain[{i}]({fb_provider})"
|
||||
|
||||
try:
|
||||
fb_client = _resolve_single_provider(
|
||||
fb_provider, fb_model, fb_base_url, fb_api_key)
|
||||
fb_client, resolved_model = _resolve_fallback_entry(entry)
|
||||
except Exception:
|
||||
fb_client = None
|
||||
fb_client, resolved_model = None, None
|
||||
|
||||
if fb_client is not None:
|
||||
logger.info(
|
||||
"Auxiliary %s: %s on %s — configured fallback to %s (%s)",
|
||||
task, reason, failed_provider, label, fb_model or "default",
|
||||
task, reason, failed_provider, label, resolved_model or fb_model or "default",
|
||||
)
|
||||
return fb_client, fb_model, label
|
||||
return fb_client, resolved_model or fb_model, label
|
||||
tried.append(label)
|
||||
|
||||
if tried:
|
||||
@@ -3106,6 +3103,103 @@ def _try_configured_fallback_chain(
|
||||
return None, None, ""
|
||||
|
||||
|
||||
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()
|
||||
if explicit:
|
||||
return explicit
|
||||
key_env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip()
|
||||
if key_env:
|
||||
return os.getenv(key_env, "").strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_fallback_entry(entry: Dict[str, Any]) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Resolve one fallback entry through the central provider router."""
|
||||
provider = str(entry.get("provider") or "").strip()
|
||||
model = str(entry.get("model") or "").strip() or None
|
||||
if not provider or not model:
|
||||
return None, None
|
||||
base_url = str(entry.get("base_url") or "").strip() or None
|
||||
api_key = _fallback_entry_api_key(entry)
|
||||
api_mode = str(entry.get("api_mode") or entry.get("transport") or "").strip() or None
|
||||
return resolve_provider_client(
|
||||
provider,
|
||||
model=model,
|
||||
explicit_base_url=base_url,
|
||||
explicit_api_key=api_key,
|
||||
api_mode=api_mode,
|
||||
)
|
||||
|
||||
|
||||
def _try_main_fallback_chain(
|
||||
task: Optional[str],
|
||||
failed_provider: str = "",
|
||||
reason: str = "error",
|
||||
) -> Tuple[Optional[Any], Optional[str], str]:
|
||||
"""Try the top-level main-agent fallback chain for an auxiliary call.
|
||||
|
||||
``provider: auto`` auxiliary tasks should respect the user's declared
|
||||
main fallback policy before dropping into Hermes' built-in discovery
|
||||
chain. The top-level chain is read through ``get_fallback_chain`` so
|
||||
both modern ``fallback_providers`` and legacy ``fallback_model`` entries
|
||||
participate in the same order as the main agent.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.fallback_config import get_fallback_chain
|
||||
|
||||
chain = get_fallback_chain(load_config())
|
||||
except Exception as exc:
|
||||
logger.debug("Auxiliary %s: could not load main fallback chain: %s", task or "call", exc)
|
||||
return None, None, ""
|
||||
|
||||
if not chain:
|
||||
return None, None, ""
|
||||
|
||||
failed_norm = (failed_provider or "").strip().lower()
|
||||
main_norm = (_read_main_provider() or "").strip().lower()
|
||||
skip = {p for p in (failed_norm, main_norm, "auto") if p}
|
||||
tried: List[str] = []
|
||||
|
||||
for i, entry in enumerate(chain):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
fb_provider = str(entry.get("provider") or "").strip()
|
||||
fb_model = str(entry.get("model") or "").strip()
|
||||
if not fb_provider or not fb_model:
|
||||
continue
|
||||
fb_norm = fb_provider.lower()
|
||||
label = f"fallback_providers[{i}]({fb_provider})"
|
||||
if fb_norm in skip:
|
||||
tried.append(f"{label} (skipped)")
|
||||
continue
|
||||
if _is_provider_unhealthy(fb_norm):
|
||||
_log_skip_unhealthy(fb_norm, task)
|
||||
tried.append(f"{label} (unhealthy)")
|
||||
continue
|
||||
try:
|
||||
fb_client, resolved_model = _resolve_fallback_entry(entry)
|
||||
except Exception as exc:
|
||||
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:
|
||||
logger.info(
|
||||
"Auxiliary %s: %s on %s — main fallback chain to %s (%s)",
|
||||
task or "call", reason, failed_provider or "auto", label,
|
||||
resolved_model or fb_model,
|
||||
)
|
||||
return fb_client, resolved_model or fb_model, fb_provider
|
||||
tried.append(label)
|
||||
|
||||
if tried:
|
||||
logger.debug(
|
||||
"Auxiliary %s: main fallback chain exhausted (tried: %s)",
|
||||
task or "call", ", ".join(tried),
|
||||
)
|
||||
return None, None, ""
|
||||
|
||||
|
||||
def _resolve_single_provider(
|
||||
provider: str,
|
||||
model: Optional[str] = None,
|
||||
@@ -3116,16 +3210,19 @@ def _resolve_single_provider(
|
||||
|
||||
Uses the existing provider resolution infrastructure where possible.
|
||||
"""
|
||||
# Reuse resolve_provider_client which handles provider→client mapping
|
||||
# Reuse resolve_provider_client which handles provider→client mapping.
|
||||
client, resolved_model = resolve_provider_client(
|
||||
provider=provider,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
explicit_base_url=base_url,
|
||||
explicit_api_key=api_key,
|
||||
)
|
||||
return client
|
||||
|
||||
def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
def _resolve_auto(
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
task: Optional[str] = None,
|
||||
) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
"""Full auto-detection chain.
|
||||
|
||||
Priority:
|
||||
@@ -3223,7 +3320,22 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
|
||||
main_provider, resolved or main_model)
|
||||
return client, resolved or main_model
|
||||
|
||||
# ── Step 2: aggregator / fallback chain ──────────────────────────────
|
||||
# ── Step 2: user-configured fallback policy ─────────────────────────
|
||||
# In auto mode, respect the task-specific fallback chain first, then the
|
||||
# main agent's top-level fallback_providers/fallback_model chain. The
|
||||
# hardcoded provider discovery chain below is only the convenience default
|
||||
# for users who have not declared a fallback policy.
|
||||
if task:
|
||||
fb_client, fb_model, _fb_label = _try_configured_fallback_chain(
|
||||
task, main_provider or "auto", reason="main provider unavailable")
|
||||
if fb_client is not None:
|
||||
return fb_client, fb_model
|
||||
fb_client, fb_model, _fb_label = _try_main_fallback_chain(
|
||||
task, main_provider or "auto", reason="main provider unavailable")
|
||||
if fb_client is not None:
|
||||
return fb_client, fb_model
|
||||
|
||||
# ── Step 3: aggregator / fallback chain ──────────────────────────────
|
||||
tried = []
|
||||
for label, try_fn in _get_provider_chain():
|
||||
if _is_provider_unhealthy(label):
|
||||
@@ -3344,6 +3456,7 @@ def resolve_provider_client(
|
||||
api_mode: str = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
is_vision: bool = False,
|
||||
task: Optional[str] = None,
|
||||
) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Central router: given a provider name and optional model, return a
|
||||
configured client with the correct auth, base URL, and API format.
|
||||
@@ -3464,7 +3577,7 @@ def resolve_provider_client(
|
||||
|
||||
# ── Auto: try all providers in priority order ────────────────────
|
||||
if provider == "auto":
|
||||
client, resolved = _resolve_auto(main_runtime=main_runtime)
|
||||
client, resolved = _resolve_auto(main_runtime=main_runtime, task=task)
|
||||
if client is None:
|
||||
return None, None
|
||||
# When auto-detection lands on a non-OpenRouter provider (e.g. a
|
||||
@@ -4357,11 +4470,16 @@ def _client_cache_key(
|
||||
api_mode: Optional[str] = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
is_vision: bool = False,
|
||||
task: Optional[str] = None,
|
||||
) -> tuple:
|
||||
runtime = _normalize_main_runtime(main_runtime)
|
||||
runtime_key = tuple(runtime.get(field, "") for field in _MAIN_RUNTIME_FIELDS) if provider == "auto" else ()
|
||||
# `auto` can now resolve through task-specific or main fallback policy,
|
||||
# so the task participates in the cache key. Non-auto providers keep the
|
||||
# old cache shape because the explicit provider/model tuple is sufficient.
|
||||
task_key = (task or "") if provider == "auto" else ""
|
||||
pool_hint = _pool_cache_hint(provider, main_runtime=main_runtime)
|
||||
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, pool_hint)
|
||||
return (provider, async_mode, base_url or "", api_key or "", api_mode or "", runtime_key, is_vision, task_key, pool_hint)
|
||||
|
||||
|
||||
def _store_cached_client(cache_key: tuple, client: Any, default_model: Optional[str], *, bound_loop: Any = None) -> None:
|
||||
@@ -4554,6 +4672,7 @@ def _get_cached_client(
|
||||
api_mode: str = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
is_vision: bool = False,
|
||||
task: Optional[str] = None,
|
||||
) -> Tuple[Optional[Any], Optional[str]]:
|
||||
"""Get or create a cached client for the given provider.
|
||||
|
||||
@@ -4591,6 +4710,7 @@ def _get_cached_client(
|
||||
api_mode=api_mode,
|
||||
main_runtime=main_runtime,
|
||||
is_vision=is_vision,
|
||||
task=task,
|
||||
)
|
||||
with _client_cache_lock:
|
||||
if cache_key in _client_cache:
|
||||
@@ -4635,6 +4755,7 @@ def _get_cached_client(
|
||||
api_mode=api_mode,
|
||||
main_runtime=runtime,
|
||||
is_vision=is_vision,
|
||||
task=task,
|
||||
)
|
||||
if client is not None:
|
||||
# For async clients, remember which loop they were created on so we
|
||||
@@ -5140,7 +5261,7 @@ def call_llm(
|
||||
if 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)
|
||||
client, final_model = _get_cached_client("auto", main_runtime=main_runtime, task=task)
|
||||
if client is None:
|
||||
raise RuntimeError(
|
||||
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
||||
@@ -5466,14 +5587,19 @@ def call_llm(
|
||||
|
||||
# Fallback order (#26882, #26803):
|
||||
# 1. User-configured fallback_chain (per-task) if set
|
||||
# 2. Main agent model (last-resort safety net)
|
||||
# For auto users (no explicit aux provider), use the full
|
||||
# auto-detection chain instead — its Step 1 IS the main agent
|
||||
# model, so users on `auto` already get main-model fallback.
|
||||
# 2. For auto: top-level main fallback_providers/fallback_model
|
||||
# 3. For auto: built-in auxiliary discovery chain
|
||||
# 4. For explicit aux providers: main agent model safety net
|
||||
fb_client, fb_model, fb_label = (None, None, "")
|
||||
if is_auto:
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task, reason=reason)
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_main_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task, reason=reason)
|
||||
else:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
@@ -5636,7 +5762,7 @@ async def async_call_llm(
|
||||
if 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)
|
||||
client, final_model = _get_cached_client("auto", async_mode=True, main_runtime=main_runtime, task=task)
|
||||
if client is None:
|
||||
raise RuntimeError(
|
||||
f"No LLM provider configured for task={task} provider={resolved_provider}. "
|
||||
@@ -5904,13 +6030,19 @@ async def async_call_llm(
|
||||
|
||||
# Fallback order (#26882, #26803):
|
||||
# 1. User-configured fallback_chain (per-task) if set
|
||||
# 2. Main agent model (last-resort safety net)
|
||||
# Auto users get the full auto-detection chain instead — its
|
||||
# Step 1 IS the main agent model.
|
||||
# 2. For auto: top-level main fallback_providers/fallback_model
|
||||
# 3. For auto: built-in auxiliary discovery chain
|
||||
# 4. For explicit aux providers: main agent model safety net
|
||||
fb_client, fb_model, fb_label = (None, None, "")
|
||||
if is_auto:
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task, reason=reason)
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_main_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
if fb_client is None:
|
||||
fb_client, fb_model, fb_label = _try_payment_fallback(
|
||||
resolved_provider, task, reason=reason)
|
||||
else:
|
||||
fb_client, fb_model, fb_label = _try_configured_fallback_chain(
|
||||
task, resolved_provider or "auto", reason=reason)
|
||||
|
||||
+121
-19
@@ -237,18 +237,25 @@ _COMBINED_REVIEW_PROMPT = (
|
||||
def summarize_background_review_actions(
|
||||
review_messages: List[Dict],
|
||||
prior_snapshot: List[Dict],
|
||||
notification_mode: str = "on",
|
||||
) -> List[str]:
|
||||
"""Build the human-facing action summary for a background review pass.
|
||||
|
||||
Walks the review agent's session messages and collects "successful tool
|
||||
action" descriptions to surface to the user (e.g. "Memory updated").
|
||||
Tool messages already present in ``prior_snapshot`` are skipped so we
|
||||
don't re-surface stale results from the prior conversation that the
|
||||
review agent inherited via ``conversation_history`` (issue #14944).
|
||||
Walks the review agent's session messages and collects successful memory
|
||||
and skill-management actions to surface to the user. Tool messages already
|
||||
present in ``prior_snapshot`` are skipped so stale inherited results are
|
||||
not re-surfaced as fresh background work (issue #14944).
|
||||
|
||||
Matching is by ``tool_call_id`` when available, with a content-equality
|
||||
fallback for tool messages that lack one.
|
||||
``notification_mode`` controls display detail:
|
||||
- ``off``: return no actions.
|
||||
- ``on``: generic "Memory updated"/tool messages.
|
||||
- ``verbose``: include compact content previews from tool-call arguments.
|
||||
"""
|
||||
mode = str(notification_mode or "on").lower()
|
||||
if mode == "off":
|
||||
return []
|
||||
verbose = mode == "verbose"
|
||||
|
||||
existing_tool_call_ids = set()
|
||||
existing_tool_contents = set()
|
||||
for prior in prior_snapshot or []:
|
||||
@@ -262,6 +269,42 @@ def summarize_background_review_actions(
|
||||
if isinstance(content, str):
|
||||
existing_tool_contents.add(content)
|
||||
|
||||
# Map review-agent tool results back to the calls that produced them. The
|
||||
# result JSON only says "Entry added"; the call arguments contain action,
|
||||
# target, and content previews. Restricting to notify_tools also prevents
|
||||
# helper tools from surfacing as memory work just because they succeeded.
|
||||
notify_tools = {"memory", "skill_manage"}
|
||||
all_tool_call_ids: set = set()
|
||||
call_details: dict = {}
|
||||
for msg in review_messages or []:
|
||||
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||
continue
|
||||
for tc in msg.get("tool_calls", []) or []:
|
||||
if not isinstance(tc, dict):
|
||||
continue
|
||||
fn = tc.get("function", {}) or {}
|
||||
fn_name = fn.get("name", "")
|
||||
tcid = tc.get("id")
|
||||
if tcid:
|
||||
all_tool_call_ids.add(tcid)
|
||||
if fn_name not in notify_tools:
|
||||
continue
|
||||
try:
|
||||
args = json.loads(fn.get("arguments", "{}"))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
args = {}
|
||||
if tcid:
|
||||
call_details[tcid] = {
|
||||
"tool": fn_name,
|
||||
"action": args.get("action", "?"),
|
||||
"target": args.get("target", "memory"),
|
||||
"content": args.get("content", ""),
|
||||
"old_text": args.get("old_text", ""),
|
||||
"name": args.get("name", ""),
|
||||
"old_string": args.get("old_string", ""),
|
||||
"new_string": args.get("new_string", ""),
|
||||
}
|
||||
|
||||
actions: List[str] = []
|
||||
for msg in review_messages or []:
|
||||
if not isinstance(msg, dict) or msg.get("role") != "tool":
|
||||
@@ -273,6 +316,8 @@ def summarize_background_review_actions(
|
||||
content_str = msg.get("content")
|
||||
if isinstance(content_str, str) and content_str in existing_tool_contents:
|
||||
continue
|
||||
if tcid and all_tool_call_ids and tcid not in call_details:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(msg.get("content", "{}"))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
@@ -280,19 +325,75 @@ def summarize_background_review_actions(
|
||||
if not isinstance(data, dict) or not data.get("success"):
|
||||
continue
|
||||
message = data.get("message", "")
|
||||
target = data.get("target", "")
|
||||
if "created" in message.lower():
|
||||
actions.append(message)
|
||||
elif "updated" in message.lower():
|
||||
actions.append(message)
|
||||
elif "added" in message.lower() or (target and "add" in message.lower()):
|
||||
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
|
||||
actions.append(f"{label} updated")
|
||||
elif "Entry added" in message:
|
||||
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
|
||||
actions.append(f"{label} updated")
|
||||
elif "removed" in message.lower() or "replaced" in message.lower():
|
||||
detail = call_details.get(tcid, {})
|
||||
target = data.get("target", "") or detail.get("target", "")
|
||||
is_skill = detail.get("tool") == "skill_manage"
|
||||
|
||||
message_lower = message.lower()
|
||||
if not verbose:
|
||||
if "created" in message_lower:
|
||||
actions.append(message)
|
||||
continue
|
||||
if "updated" in message_lower:
|
||||
actions.append(message)
|
||||
continue
|
||||
if is_skill and "patched" in message_lower:
|
||||
actions.append(message)
|
||||
continue
|
||||
|
||||
if is_skill:
|
||||
label = "Skill"
|
||||
elif target:
|
||||
label = "Memory" if target == "memory" else "User profile" if target == "user" else target
|
||||
else:
|
||||
continue
|
||||
|
||||
if verbose:
|
||||
action = detail.get("action", "")
|
||||
content = detail.get("content", "")
|
||||
old_text = detail.get("old_text", "")
|
||||
skill_name = detail.get("name", "")
|
||||
max_preview = 120
|
||||
if is_skill:
|
||||
change = data.get("_change", {})
|
||||
old_string = change.get("old", "") or detail.get("old_string", "")
|
||||
new_string = change.get("new", "") or detail.get("new_string", "")
|
||||
description = change.get("description", "")
|
||||
if action == "patch" and (old_string or new_string):
|
||||
old_preview = old_string[:80].replace("\n", " ") + (
|
||||
"…" if len(old_string) > 80 else ""
|
||||
)
|
||||
new_preview = new_string[:80].replace("\n", " ") + (
|
||||
"…" if len(new_string) > 80 else ""
|
||||
)
|
||||
actions.append(
|
||||
f"📝 Skill '{skill_name}' patched: "
|
||||
f"\"{old_preview}\" → \"{new_preview}\""
|
||||
)
|
||||
elif action == "create" and description:
|
||||
actions.append(f"📝 Skill '{skill_name}' created: {description}")
|
||||
elif action == "edit" and description:
|
||||
actions.append(f"📝 Skill '{skill_name}' rewritten: {description}")
|
||||
else:
|
||||
actions.append(f"📝 {message}" if message else f"Skill {action}")
|
||||
elif action == "add" and content:
|
||||
preview = content[:max_preview] + ("…" if len(content) > max_preview else "")
|
||||
actions.append(f"{label} ➕ {preview}")
|
||||
elif action == "replace" and content:
|
||||
preview = content[:max_preview] + ("…" if len(content) > max_preview else "")
|
||||
actions.append(f"{label} ✏️ {preview}")
|
||||
elif action == "remove" and old_text:
|
||||
preview = old_text[:60] + ("…" if len(old_text) > 60 else "")
|
||||
actions.append(f"{label} ➖ {preview}")
|
||||
else:
|
||||
actions.append(f"{label} updated")
|
||||
elif (
|
||||
"added" in message_lower
|
||||
or "replaced" in message_lower
|
||||
or "removed" in message_lower
|
||||
or (target and "add" in message.lower())
|
||||
or "Entry added" in message
|
||||
):
|
||||
actions.append(f"{label} updated")
|
||||
return actions
|
||||
|
||||
@@ -522,6 +623,7 @@ def _run_review_in_thread(
|
||||
actions = summarize_background_review_actions(
|
||||
review_messages,
|
||||
messages_snapshot,
|
||||
notification_mode=getattr(agent, "memory_notifications", "on"),
|
||||
)
|
||||
|
||||
if actions:
|
||||
|
||||
@@ -58,17 +58,34 @@ _bedrock_runtime_client_cache: Dict[str, Any] = {}
|
||||
_bedrock_control_client_cache: Dict[str, Any] = {}
|
||||
|
||||
|
||||
_MIN_BOTO3_VERSION = (1, 34, 59)
|
||||
|
||||
|
||||
def _require_boto3():
|
||||
"""Import boto3, raising a clear error if not installed."""
|
||||
"""Import boto3, raising a clear error if not installed or too old."""
|
||||
try:
|
||||
import boto3
|
||||
return boto3
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"The 'boto3' package is required for the AWS Bedrock provider. "
|
||||
"Install it with: pip install boto3\n"
|
||||
"Or install Hermes with Bedrock support: pip install -e '.[bedrock]'"
|
||||
)
|
||||
# converse() / converse_stream() were added in boto3 1.34.59.
|
||||
# When Hermes is installed editable into system Python, the system boto3
|
||||
# (e.g. Ubuntu 24.04 ships 1.34.46) may take precedence over the venv
|
||||
# version pinned in pyproject.toml.
|
||||
try:
|
||||
version = tuple(int(x) for x in boto3.__version__.split(".")[:3])
|
||||
except (AttributeError, ValueError):
|
||||
return boto3 # can't parse — don't block on version check
|
||||
if version < _MIN_BOTO3_VERSION:
|
||||
raise RuntimeError(
|
||||
f"boto3 {boto3.__version__} does not support converse_stream "
|
||||
f"(minimum 1.34.59 required). Upgrade with: "
|
||||
f"pip install --upgrade boto3"
|
||||
)
|
||||
return boto3
|
||||
|
||||
|
||||
def _get_bedrock_runtime_client(region: str):
|
||||
|
||||
@@ -454,16 +454,16 @@ def _restore_cron_skill_links(snapshot_dir: Path) -> Dict[str, Any]:
|
||||
report["attempted"] = True # we tried but there was nothing to do
|
||||
return report
|
||||
|
||||
# Load and rewrite the live jobs under the scheduler's lock.
|
||||
# Load and rewrite the live jobs under the scheduler's cross-process lock.
|
||||
try:
|
||||
from cron.jobs import load_jobs, save_jobs, _jobs_file_lock
|
||||
from cron.jobs import load_jobs, save_jobs, _jobs_lock
|
||||
except ImportError as e:
|
||||
report["error"] = f"cron module unavailable: {e}"
|
||||
return report
|
||||
|
||||
report["attempted"] = True
|
||||
try:
|
||||
with _jobs_file_lock:
|
||||
with _jobs_lock():
|
||||
live_jobs = load_jobs()
|
||||
changed = False
|
||||
|
||||
|
||||
+42
-1
@@ -12,6 +12,7 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from difflib import unified_diff
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from utils import safe_json_loads
|
||||
from agent.tool_result_classification import file_mutation_result_landed
|
||||
@@ -168,6 +169,27 @@ def _oneline(text: str) -> str:
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def _truncate_preview(text: str, max_len: int | None) -> str:
|
||||
if max_len and max_len > 0 and len(text) > max_len:
|
||||
if max_len <= 3:
|
||||
return "." * max_len
|
||||
return text[:max_len - 3] + "..."
|
||||
return text
|
||||
|
||||
|
||||
def _delegate_task_goal_parts(tasks: Any, *, per_goal_len: int) -> tuple[int, list[str]]:
|
||||
if not isinstance(tasks, list):
|
||||
return 0, []
|
||||
goals: list[str] = []
|
||||
for task in tasks:
|
||||
if not isinstance(task, dict):
|
||||
continue
|
||||
raw_goal = task.get("goal")
|
||||
goal = "?" if raw_goal is None else _oneline(str(raw_goal))
|
||||
goals.append(_truncate_preview(goal or "?", per_goal_len))
|
||||
return len(goals), goals
|
||||
|
||||
|
||||
def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -> str | None:
|
||||
"""Build a short preview of a tool call's primary argument for display.
|
||||
|
||||
@@ -191,6 +213,22 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
||||
"clarify": "question", "skill_manage": "name",
|
||||
}
|
||||
|
||||
# delegate_task: show goal (single) or individual task goals (batch)
|
||||
if tool_name == "delegate_task":
|
||||
tasks = args.get("tasks")
|
||||
if tasks and isinstance(tasks, list):
|
||||
task_count, goals = _delegate_task_goal_parts(tasks, per_goal_len=40)
|
||||
preview = (
|
||||
f"{task_count} tasks: " + " | ".join(goals)
|
||||
if goals else f"{len(tasks)} parallel tasks"
|
||||
)
|
||||
return _truncate_preview(preview, max_len)
|
||||
goal = args.get("goal", "")
|
||||
if goal is None:
|
||||
return None
|
||||
preview = _oneline(str(goal))
|
||||
return _truncate_preview(preview, max_len) if preview else None
|
||||
|
||||
if tool_name == "process":
|
||||
action = args.get("action", "")
|
||||
sid = args.get("session_id", "")
|
||||
@@ -1019,7 +1057,10 @@ def get_cute_tool_message(
|
||||
if tool_name == "delegate_task":
|
||||
tasks = args.get("tasks")
|
||||
if tasks and isinstance(tasks, list):
|
||||
return _wrap(f"┊ 🔀 delegate {len(tasks)} parallel tasks {dur}")
|
||||
task_count, goals = _delegate_task_goal_parts(tasks, per_goal_len=30)
|
||||
detail = " | ".join(goals) if goals else "parallel"
|
||||
count_label = task_count or len(tasks)
|
||||
return _wrap(f"┊ 🔀 delegate {count_label}x: {_trunc(detail, 35)} {dur}")
|
||||
return _wrap(f"┊ 🔀 delegate {_trunc(args.get('goal', ''), 35)} {dur}")
|
||||
|
||||
preview = build_tool_preview(tool_name, args) or ""
|
||||
|
||||
@@ -104,6 +104,7 @@ _PREFIX_PATTERNS = [
|
||||
r"mem0_[A-Za-z0-9]{10,}", # Mem0 Platform API key
|
||||
r"brv_[A-Za-z0-9]{10,}", # ByteRover API key
|
||||
r"xai-[A-Za-z0-9]{30,}", # xAI (Grok) API key
|
||||
r"ntn_[A-Za-z0-9]{10,}", # Notion internal integration token
|
||||
]
|
||||
|
||||
# ENV assignment patterns: KEY=value where KEY contains a secret-like name
|
||||
|
||||
@@ -531,6 +531,7 @@ class ChatCompletionsTransport(ProviderTransport):
|
||||
supports_reasoning=params.get("supports_reasoning", False),
|
||||
qwen_session_metadata=params.get("qwen_session_metadata"),
|
||||
model=model,
|
||||
base_url=params.get("base_url"),
|
||||
ollama_num_ctx=params.get("ollama_num_ctx"),
|
||||
session_id=params.get("session_id"),
|
||||
)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "0.16.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.0.0",
|
||||
@@ -40,8 +40,8 @@
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
@@ -34,7 +34,7 @@ It builds and launches the GUI against your existing install — same config, ke
|
||||
|
||||
### Prebuilt installers
|
||||
|
||||
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/desktop).
|
||||
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -166,6 +166,39 @@ function profileRemoteOverride(config, profile) {
|
||||
return { url, authMode: normAuthMode(entry.authMode), token: entry.token }
|
||||
}
|
||||
|
||||
/**
|
||||
* In global-remote mode one backend serves every Desktop profile, so REST calls
|
||||
* that are scoped by renderer-side `request.profile` must carry that scope as a
|
||||
* query parameter. Local pooled backends and per-profile remote overrides do not
|
||||
* need this: they already run against a backend scoped to the target profile.
|
||||
*/
|
||||
function pathWithGlobalRemoteProfile(path, profile, opts = {}) {
|
||||
const scopedProfile = connectionScopeKey(profile)
|
||||
if (!scopedProfile || !opts.globalRemote || opts.profileRemoteOverride) {
|
||||
return path
|
||||
}
|
||||
|
||||
const rawPath = String(path || '')
|
||||
if (!rawPath) {
|
||||
return path
|
||||
}
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(rawPath, 'http://hermes.local')
|
||||
} catch {
|
||||
return path
|
||||
}
|
||||
|
||||
if (parsed.searchParams.has('profile')) {
|
||||
return path
|
||||
}
|
||||
|
||||
parsed.searchParams.set('profile', scopedProfile)
|
||||
|
||||
return `${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||
}
|
||||
|
||||
function tokenPreview(value) {
|
||||
const raw = String(value || '')
|
||||
|
||||
@@ -247,6 +280,7 @@ module.exports = {
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
|
||||
@@ -24,6 +24,7 @@ const {
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
@@ -90,6 +91,72 @@ test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
|
||||
assert.equal(profileRemoteOverride(null, 'coder'), null)
|
||||
})
|
||||
|
||||
// --- pathWithGlobalRemoteProfile ---
|
||||
|
||||
test('pathWithGlobalRemoteProfile appends profile in global remote mode', () => {
|
||||
assert.equal(
|
||||
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
|
||||
globalRemote: true,
|
||||
profileRemoteOverride: false
|
||||
}),
|
||||
'/api/model/info?profile=iris'
|
||||
)
|
||||
})
|
||||
|
||||
test('pathWithGlobalRemoteProfile preserves existing query params', () => {
|
||||
assert.equal(
|
||||
pathWithGlobalRemoteProfile('/api/model/options?force=1', 'iris', {
|
||||
globalRemote: true,
|
||||
profileRemoteOverride: false
|
||||
}),
|
||||
'/api/model/options?force=1&profile=iris'
|
||||
)
|
||||
})
|
||||
|
||||
test('pathWithGlobalRemoteProfile does not replace an explicit profile query', () => {
|
||||
assert.equal(
|
||||
pathWithGlobalRemoteProfile('/api/model/info?profile=default', 'iris', {
|
||||
globalRemote: true,
|
||||
profileRemoteOverride: false
|
||||
}),
|
||||
'/api/model/info?profile=default'
|
||||
)
|
||||
})
|
||||
|
||||
test('pathWithGlobalRemoteProfile skips local and per-profile remote override paths', () => {
|
||||
assert.equal(
|
||||
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
|
||||
globalRemote: false,
|
||||
profileRemoteOverride: false
|
||||
}),
|
||||
'/api/model/info'
|
||||
)
|
||||
assert.equal(
|
||||
pathWithGlobalRemoteProfile('/api/model/info', 'iris', {
|
||||
globalRemote: true,
|
||||
profileRemoteOverride: true
|
||||
}),
|
||||
'/api/model/info'
|
||||
)
|
||||
})
|
||||
|
||||
test('pathWithGlobalRemoteProfile skips empty profile/path safely', () => {
|
||||
assert.equal(
|
||||
pathWithGlobalRemoteProfile('/api/model/info', '', {
|
||||
globalRemote: true,
|
||||
profileRemoteOverride: false
|
||||
}),
|
||||
'/api/model/info'
|
||||
)
|
||||
assert.equal(
|
||||
pathWithGlobalRemoteProfile('', 'iris', {
|
||||
globalRemote: true,
|
||||
profileRemoteOverride: false
|
||||
}),
|
||||
''
|
||||
)
|
||||
})
|
||||
|
||||
// --- normalizeRemoteBaseUrl ---
|
||||
|
||||
test('normalizeRemoteBaseUrl strips trailing slashes, hash, and query', () => {
|
||||
|
||||
@@ -39,6 +39,7 @@ const { waitForDashboardPort } = require('./backend-ready.cjs')
|
||||
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
|
||||
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
|
||||
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
|
||||
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
|
||||
const { readDirForIpc } = require('./fs-read-dir.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const { worktreesForIpc } = require('./git-worktrees.cjs')
|
||||
@@ -62,6 +63,7 @@ const {
|
||||
cookiesHaveLiveSession,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
pathWithGlobalRemoteProfile,
|
||||
profileRemoteOverride,
|
||||
resolveAuthMode,
|
||||
resolveTestWsUrl,
|
||||
@@ -242,6 +244,16 @@ if (INSTALL_STAMP) {
|
||||
function resolveHermesHome() {
|
||||
if (process.env.HERMES_HOME) return normalizeHermesHomeRoot(process.env.HERMES_HOME)
|
||||
if (USER_DATA_OVERRIDE) return path.join(path.resolve(USER_DATA_OVERRIDE), 'hermes-home')
|
||||
if (IS_WINDOWS) {
|
||||
// A GUI app launched from Explorer inherits the environment block captured
|
||||
// at login, so a HERMES_HOME set via `setx` AFTER login is invisible in
|
||||
// process.env even though the CLI (a fresh shell) sees it. Without this the
|
||||
// backend silently falls back to %LOCALAPPDATA%\hermes and reports "No
|
||||
// inference provider configured" despite a valid configured home (#45471).
|
||||
// Consult the live User-scoped registry value before the default below.
|
||||
const fromRegistry = readWindowsUserEnvVar('HERMES_HOME')
|
||||
if (fromRegistry) return normalizeHermesHomeRoot(fromRegistry)
|
||||
}
|
||||
if (IS_WINDOWS && process.env.LOCALAPPDATA) {
|
||||
const localappdata = path.join(process.env.LOCALAPPDATA, 'hermes')
|
||||
const legacy = path.join(app.getPath('home'), '.hermes')
|
||||
@@ -5072,65 +5084,75 @@ function focusWindow(win) {
|
||||
win.focus()
|
||||
}
|
||||
|
||||
function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) {
|
||||
const icon = getAppIconPath()
|
||||
const win = new BrowserWindow({
|
||||
width: SESSION_WINDOW_MIN_WIDTH,
|
||||
height: SESSION_WINDOW_MIN_HEIGHT,
|
||||
minWidth: SESSION_WINDOW_MIN_WIDTH,
|
||||
minHeight: SESSION_WINDOW_MIN_HEIGHT,
|
||||
title: 'Hermes',
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: getTitleBarOverlayOptions(),
|
||||
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
||||
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
||||
opacity: windowOpacity(),
|
||||
icon,
|
||||
// Don't show until the renderer's first themed paint is ready. macOS
|
||||
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
|
||||
// material (which follows the OS appearance, not the app theme), so a
|
||||
// dark-themed app on a light-mode Mac flashes white until the renderer
|
||||
// covers it. ready-to-show fires after the boot-time paint in
|
||||
// themes/context.tsx, so the window appears already themed.
|
||||
show: false,
|
||||
backgroundColor: getWindowBackgroundColor(),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
webviewTag: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
devTools: true
|
||||
}
|
||||
})
|
||||
|
||||
if (IS_MAC) {
|
||||
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
|
||||
}
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
if (!win.isDestroyed()) win.show()
|
||||
})
|
||||
|
||||
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
|
||||
win.on('enter-full-screen', () => sendWindowStateChanged(true))
|
||||
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||
win.on('leave-full-screen', () => sendWindowStateChanged(false))
|
||||
|
||||
wireCommonWindowHandlers(win)
|
||||
|
||||
win.loadURL(
|
||||
buildSessionWindowUrl(sessionId, {
|
||||
devServer: DEV_SERVER,
|
||||
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
|
||||
watch,
|
||||
newSession
|
||||
})
|
||||
)
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
// Open (or focus) a standalone window for a single chat session.
|
||||
function createSessionWindow(sessionId, { watch = false } = {}) {
|
||||
return sessionWindows.openOrFocus(sessionId, () => {
|
||||
const icon = getAppIconPath()
|
||||
const win = new BrowserWindow({
|
||||
width: SESSION_WINDOW_MIN_WIDTH,
|
||||
height: SESSION_WINDOW_MIN_HEIGHT,
|
||||
minWidth: SESSION_WINDOW_MIN_WIDTH,
|
||||
minHeight: SESSION_WINDOW_MIN_HEIGHT,
|
||||
title: 'Hermes',
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: getTitleBarOverlayOptions(),
|
||||
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
||||
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
||||
opacity: windowOpacity(),
|
||||
icon,
|
||||
// Don't show until the renderer's first themed paint is ready. macOS
|
||||
// `vibrancy` ignores `backgroundColor` and paints a translucent OS
|
||||
// material (which follows the OS appearance, not the app theme), so a
|
||||
// dark-themed app on a light-mode Mac flashes white until the renderer
|
||||
// covers it. ready-to-show fires after the boot-time paint in
|
||||
// themes/context.tsx, so the window appears already themed.
|
||||
show: false,
|
||||
backgroundColor: getWindowBackgroundColor(),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
webviewTag: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
devTools: true
|
||||
}
|
||||
})
|
||||
return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch }))
|
||||
}
|
||||
|
||||
if (IS_MAC) {
|
||||
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
|
||||
}
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
if (!win.isDestroyed()) win.show()
|
||||
})
|
||||
|
||||
win.on('will-enter-full-screen', () => sendWindowStateChanged(true))
|
||||
win.on('enter-full-screen', () => sendWindowStateChanged(true))
|
||||
win.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||
win.on('leave-full-screen', () => sendWindowStateChanged(false))
|
||||
|
||||
wireCommonWindowHandlers(win)
|
||||
|
||||
win.loadURL(
|
||||
buildSessionWindowUrl(sessionId, {
|
||||
devServer: DEV_SERVER,
|
||||
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
|
||||
watch
|
||||
})
|
||||
)
|
||||
|
||||
return win
|
||||
})
|
||||
// Open a fresh compact window on the new-session draft (#/). Not registry-keyed:
|
||||
// like ⌘N in a browser, every press opens a new window — and a draft window that
|
||||
// later converts to a real session must not get refocused as if it were blank.
|
||||
function createNewSessionWindow() {
|
||||
return spawnSecondaryWindow({ newSession: true })
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
@@ -5317,6 +5339,11 @@ ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:window:openNewSession', async () => {
|
||||
createNewSessionWindow()
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:bootstrap:reset', async () => {
|
||||
// Renderer's "Reload and retry" path. Clear the latched failure and
|
||||
// reset connection state so the next startHermes() call restarts the
|
||||
@@ -5586,9 +5613,14 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
||||
|
||||
await prepareProfileDeleteRequest(request)
|
||||
|
||||
const connection = await ensureBackend(request?.profile)
|
||||
const profile = request?.profile
|
||||
const connection = await ensureBackend(profile)
|
||||
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
|
||||
const url = `${connection.baseUrl}${request.path}`
|
||||
const requestPath = pathWithGlobalRemoteProfile(request.path, profile, {
|
||||
globalRemote: globalRemoteActive(),
|
||||
profileRemoteOverride: profileHasRemoteOverride(profile)
|
||||
})
|
||||
const url = `${connection.baseUrl}${requestPath}`
|
||||
// OAuth gateways authenticate REST via the HttpOnly session cookie held in
|
||||
// the OAuth partition — route through Electron's net stack bound to that
|
||||
// session so the cookie attaches automatically. Token/local modes keep using
|
||||
|
||||
@@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
||||
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'),
|
||||
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),
|
||||
|
||||
@@ -15,12 +15,13 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
|
||||
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
|
||||
// treated as the route by HashRouter and would break routeSessionId(). The
|
||||
// renderer reads the flag from window.location.search to suppress the install /
|
||||
// onboarding overlays and the global session sidebar. `watch=1` marks a
|
||||
// spectator window (e.g. a running subagent's session): the renderer resumes
|
||||
// it lazily so the gateway never builds an agent just to stream into it.
|
||||
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch } = {}) {
|
||||
const query = `?win=secondary${watch ? '&watch=1' : ''}`
|
||||
const route = `#/${encodeURIComponent(sessionId)}`
|
||||
// onboarding overlays and the global session sidebar. `new=1` marks the compact
|
||||
// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's
|
||||
// session): the renderer resumes it lazily so the gateway never builds an agent
|
||||
// just to stream into it.
|
||||
function buildSessionWindowUrl(sessionId, { devServer, rendererIndexPath, watch, newSession } = {}) {
|
||||
const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}`
|
||||
const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`
|
||||
|
||||
if (devServer) {
|
||||
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
|
||||
|
||||
@@ -82,6 +82,12 @@ test('buildSessionWindowUrl adds the watch flag for spectator windows, before th
|
||||
assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc')
|
||||
})
|
||||
|
||||
test('buildSessionWindowUrl routes new-session windows to the draft (#/)', () => {
|
||||
const url = buildSessionWindowUrl(null, { devServer: 'http://localhost:5173', newSession: true })
|
||||
|
||||
assert.equal(url, 'http://localhost:5173/?win=secondary&new=1#/')
|
||||
})
|
||||
|
||||
test('registry opens one window per session and focuses on re-open', () => {
|
||||
const registry = createSessionWindowRegistry()
|
||||
let built = 0
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// windows-user-env.cjs
|
||||
//
|
||||
// Read a User-scoped environment variable straight from the Windows registry
|
||||
// (HKCU\Environment).
|
||||
//
|
||||
// A GUI app launched from Explorer inherits the environment block captured at
|
||||
// login, so a variable set via `setx` AFTER login is invisible in process.env
|
||||
// even though a fresh shell — and the Hermes CLI — sees it immediately. The
|
||||
// desktop's HERMES_HOME resolution relies on process.env, so that stale-snapshot
|
||||
// gap silently sends the backend to the default %LOCALAPPDATA%\hermes. Reading
|
||||
// the live registry value closes the gap. See #45471.
|
||||
|
||||
const { execFileSync } = require('node:child_process')
|
||||
|
||||
// Parse the output of `reg query HKCU\Environment /v <name>`, which looks like:
|
||||
//
|
||||
// HKEY_CURRENT_USER\Environment
|
||||
// HERMES_HOME REG_SZ F:\Hermes\data
|
||||
//
|
||||
// Returns the raw value string (spaces inside the value preserved), or null when
|
||||
// the requested value line isn't present.
|
||||
function parseRegQueryValue(stdout, name) {
|
||||
if (!stdout || !name) return null
|
||||
const typePattern =
|
||||
/^(\S+)\s+(?:REG_SZ|REG_EXPAND_SZ|REG_MULTI_SZ|REG_DWORD|REG_QWORD|REG_BINARY|REG_NONE)\s+(.*)$/
|
||||
for (const rawLine of String(stdout).split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
const match = line.match(typePattern)
|
||||
if (match && match[1].toLowerCase() === name.toLowerCase()) {
|
||||
return match[2]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Expand %VAR% references against an env map. REG_EXPAND_SZ values store
|
||||
// unexpanded references; plain REG_SZ paths have none, so this is a no-op for
|
||||
// the common F:\... case. Unknown references are left verbatim.
|
||||
function expandWindowsEnvRefs(value, env = process.env) {
|
||||
if (!value) return value
|
||||
return value.replace(/%([^%]+)%/g, (whole, name) => {
|
||||
const key = Object.keys(env).find(k => k.toUpperCase() === String(name).toUpperCase())
|
||||
return key != null && env[key] != null ? env[key] : whole
|
||||
})
|
||||
}
|
||||
|
||||
// Read a User-scoped env var from HKCU\Environment. Windows-only: returns null
|
||||
// off-Windows (without spawning), on any spawn error, when `reg` exits non-zero
|
||||
// (the value doesn't exist), or when the value is empty.
|
||||
function readWindowsUserEnvVar(
|
||||
name,
|
||||
{ platform = process.platform, env = process.env, exec = execFileSync } = {}
|
||||
) {
|
||||
if (platform !== 'win32' || !name) return null
|
||||
let stdout
|
||||
try {
|
||||
stdout = exec('reg', ['query', 'HKCU\\Environment', '/v', name], {
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
timeout: 5000
|
||||
})
|
||||
} catch {
|
||||
// `reg` missing, or value absent (reg exits 1) — caller falls back.
|
||||
return null
|
||||
}
|
||||
const raw = parseRegQueryValue(stdout, name)
|
||||
if (raw == null) return null
|
||||
const expanded = expandWindowsEnvRefs(raw, env).trim()
|
||||
return expanded || null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
expandWindowsEnvRefs,
|
||||
parseRegQueryValue,
|
||||
readWindowsUserEnvVar
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { test } = require('node:test')
|
||||
|
||||
const {
|
||||
expandWindowsEnvRefs,
|
||||
parseRegQueryValue,
|
||||
readWindowsUserEnvVar
|
||||
} = require('./windows-user-env.cjs')
|
||||
|
||||
// ── parseRegQueryValue ─────────────────────────────────────────────────────
|
||||
|
||||
test('parseRegQueryValue extracts a REG_SZ value', () => {
|
||||
const out = [
|
||||
'',
|
||||
'HKEY_CURRENT_USER\\Environment',
|
||||
' HERMES_HOME REG_SZ F:\\Hermes\\data',
|
||||
''
|
||||
].join('\r\n')
|
||||
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'F:\\Hermes\\data')
|
||||
})
|
||||
|
||||
test('parseRegQueryValue matches the name case-insensitively', () => {
|
||||
const out = 'HKEY_CURRENT_USER\\Environment\r\n Hermes_Home REG_EXPAND_SZ %USERPROFILE%\\h\r\n'
|
||||
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), '%USERPROFILE%\\h')
|
||||
})
|
||||
|
||||
test('parseRegQueryValue preserves spaces inside the value', () => {
|
||||
const out = ' HERMES_HOME REG_SZ C:\\Program Files\\Hermes\r\n'
|
||||
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), 'C:\\Program Files\\Hermes')
|
||||
})
|
||||
|
||||
test('parseRegQueryValue returns null when the value line is absent', () => {
|
||||
const out = 'HKEY_CURRENT_USER\\Environment\r\n Path REG_SZ C:\\x\r\n'
|
||||
assert.equal(parseRegQueryValue(out, 'HERMES_HOME'), null)
|
||||
assert.equal(parseRegQueryValue('', 'HERMES_HOME'), null)
|
||||
assert.equal(parseRegQueryValue('garbage', 'HERMES_HOME'), null)
|
||||
})
|
||||
|
||||
// ── expandWindowsEnvRefs ───────────────────────────────────────────────────
|
||||
|
||||
test('expandWindowsEnvRefs expands %VAR% case-insensitively', () => {
|
||||
assert.equal(
|
||||
expandWindowsEnvRefs('%UserProfile%\\h', { USERPROFILE: 'C:\\Users\\jeff' }),
|
||||
'C:\\Users\\jeff\\h'
|
||||
)
|
||||
})
|
||||
|
||||
test('expandWindowsEnvRefs leaves literal paths and unknown refs intact', () => {
|
||||
assert.equal(expandWindowsEnvRefs('F:\\Hermes\\data', {}), 'F:\\Hermes\\data')
|
||||
assert.equal(expandWindowsEnvRefs('%NOPE%\\x', {}), '%NOPE%\\x')
|
||||
})
|
||||
|
||||
// ── readWindowsUserEnvVar ──────────────────────────────────────────────────
|
||||
|
||||
test('readWindowsUserEnvVar returns null off Windows without spawning', () => {
|
||||
let spawned = false
|
||||
const exec = () => {
|
||||
spawned = true
|
||||
return ''
|
||||
}
|
||||
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'linux', exec }), null)
|
||||
assert.equal(spawned, false)
|
||||
})
|
||||
|
||||
test('readWindowsUserEnvVar queries HKCU\\Environment and expands the value', () => {
|
||||
const calls = []
|
||||
const exec = (cmd, args) => {
|
||||
calls.push([cmd, args])
|
||||
return 'HKEY_CURRENT_USER\\Environment\r\n HERMES_HOME REG_EXPAND_SZ %DRIVE%\\Hermes\r\n'
|
||||
}
|
||||
const value = readWindowsUserEnvVar('HERMES_HOME', {
|
||||
platform: 'win32',
|
||||
env: { DRIVE: 'F:' },
|
||||
exec
|
||||
})
|
||||
assert.equal(value, 'F:\\Hermes')
|
||||
assert.deepEqual(calls, [['reg', ['query', 'HKCU\\Environment', '/v', 'HERMES_HOME']]])
|
||||
})
|
||||
|
||||
test('readWindowsUserEnvVar returns null when reg exits non-zero (value missing)', () => {
|
||||
const exec = () => {
|
||||
throw new Error('reg exited 1')
|
||||
}
|
||||
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null)
|
||||
})
|
||||
|
||||
test('readWindowsUserEnvVar returns null for an empty value', () => {
|
||||
const exec = () => ' HERMES_HOME REG_SZ \r\n'
|
||||
assert.equal(readWindowsUserEnvVar('HERMES_HOME', { platform: 'win32', exec }), null)
|
||||
})
|
||||
@@ -20,6 +20,7 @@
|
||||
"start": "npm run build && electron .",
|
||||
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && npm run postbuild",
|
||||
"postbuild": "node scripts/assert-dist-built.cjs",
|
||||
"prebuilder": "node scripts/patch-electron-builder-mac-binary.cjs",
|
||||
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
|
||||
"pack": "npm run build && npm run builder -- --dir",
|
||||
"dist": "npm run build && npm run builder",
|
||||
@@ -36,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/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/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",
|
||||
"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/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/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/windows-user-env.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
@@ -134,6 +135,7 @@
|
||||
},
|
||||
"build": {
|
||||
"electronVersion": "40.9.3",
|
||||
"electronDist": "../../node_modules/electron/dist",
|
||||
"appId": "com.nousresearch.hermes",
|
||||
"productName": "Hermes",
|
||||
"executableName": "Hermes",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const desktopRoot = path.resolve(__dirname, '..')
|
||||
const repoRoot = path.resolve(desktopRoot, '..', '..')
|
||||
const electronMacPath = path.join(repoRoot, 'node_modules', 'app-builder-lib', 'out', 'electron', 'electronMac.js')
|
||||
|
||||
const marker = 'hermes-macos-electron-binary-fallback'
|
||||
const needle = ` await Promise.all([
|
||||
doRename(path.join(contentsPath, "MacOS"), electronBranding.productName, appPlist.CFBundleExecutable),
|
||||
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")),
|
||||
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")),
|
||||
]);`
|
||||
const replacement = ` // ${marker}: electron-builder 26.8.x can sometimes copy
|
||||
// Electron.app without its main MacOS/Electron binary before this rename.
|
||||
// Restore it from the installed Electron runtime so local desktop installs
|
||||
// do not fail with ENOENT during macOS arm64 packaging.
|
||||
const macosDir = path.join(contentsPath, "MacOS");
|
||||
const bundledElectronBinary = path.join(macosDir, electronBranding.productName);
|
||||
if (!fs.existsSync(bundledElectronBinary)) {
|
||||
const candidates = [
|
||||
path.join(packager.info.framework.distMacOsAppName, "Contents", "MacOS", electronBranding.productName),
|
||||
path.join(process.cwd(), "..", "..", "node_modules", "electron", "dist", "Electron.app", "Contents", "MacOS", electronBranding.productName),
|
||||
];
|
||||
const sourceBinary = candidates.find(candidate => fs.existsSync(candidate));
|
||||
if (sourceBinary == null) {
|
||||
throw new Error("Electron binary missing from packaged app and Electron runtime: " + bundledElectronBinary);
|
||||
}
|
||||
await (0, promises_1.copyFile)(sourceBinary, bundledElectronBinary);
|
||||
await (0, promises_1.chmod)(bundledElectronBinary, 0o755);
|
||||
}
|
||||
await Promise.all([
|
||||
doRename(macosDir, electronBranding.productName, appPlist.CFBundleExecutable),
|
||||
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSE")),
|
||||
(0, builder_util_1.unlinkIfExists)(path.join(appOutDir, "LICENSES.chromium.html")),
|
||||
]);`
|
||||
|
||||
if (!fs.existsSync(electronMacPath)) {
|
||||
console.warn(`[patch-electron-builder] skipped: ${electronMacPath} not found`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const source = fs.readFileSync(electronMacPath, 'utf8')
|
||||
if (source.includes(marker)) {
|
||||
console.log('[patch-electron-builder] macOS Electron binary fallback already applied')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!source.includes(needle)) {
|
||||
console.warn('[patch-electron-builder] skipped: expected electronMac.js shape not found')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
fs.writeFileSync(electronMacPath, source.replace(needle, replacement))
|
||||
console.log('[patch-electron-builder] applied macOS Electron binary fallback')
|
||||
@@ -23,6 +23,7 @@ import { type Translations, useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { ExternalLink, ExternalLinkIcon, hostPathLabel, urlSlugTitleLabel, useLinkTitle } from '@/lib/external-link'
|
||||
import { FileImage, FileText, FolderOpen, Link2 } from '@/lib/icons'
|
||||
import { mediaExternalUrl } from '@/lib/media'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import type { SessionInfo, SessionMessage } from '@/types/hermes'
|
||||
@@ -124,17 +125,12 @@ function artifactKind(value: string): ArtifactKind {
|
||||
}
|
||||
|
||||
function artifactHref(value: string): string {
|
||||
if (
|
||||
value.startsWith('http://') ||
|
||||
value.startsWith('https://') ||
|
||||
value.startsWith('file://') ||
|
||||
value.startsWith('data:')
|
||||
) {
|
||||
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('data:')) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (value.startsWith('/')) {
|
||||
return `file://${encodeURI(value)}`
|
||||
if (value.startsWith('file://') || value.startsWith('/')) {
|
||||
return mediaExternalUrl(value)
|
||||
}
|
||||
|
||||
return value
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
$sessions,
|
||||
sessionPinId
|
||||
} from '@/store/session'
|
||||
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
|
||||
import type { ModelOptionsResponse } from '@/types/hermes'
|
||||
|
||||
import { routeSessionId } from '../routes'
|
||||
@@ -122,7 +123,7 @@ function ChatHeader({
|
||||
// A brand-new session has no session to pin/delete/rename, so the header is
|
||||
// just a dead "New session" label + chevron. Drop it (and its border)
|
||||
// entirely until there's a real session to act on.
|
||||
if (!selectedSessionId && !activeSessionId && !isRoutedSessionView) {
|
||||
if (isNewSessionWindow() || (!selectedSessionId && !activeSessionId && !isRoutedSessionView)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -302,7 +303,10 @@ export function ChatView({
|
||||
// waiting for the resume effect (which paints a frame later) to clear them.
|
||||
const routeSessionMismatch = isRoutedSessionView && routedSessionId !== selectedSessionId
|
||||
|
||||
const showIntro = freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
|
||||
// The compact new-session pop-out skips the wordmark/tagline intro — it's a
|
||||
// scratch window, not the full-height empty state.
|
||||
const showIntro =
|
||||
!isSecondaryWindow() && freshDraftReady && !isRoutedSessionView && !selectedSessionId && !activeSessionId && messagesEmpty
|
||||
|
||||
// Session is still loading if the route references a session we haven't
|
||||
// resumed yet. Once `activeSessionId` is set (runtime has resumed), the
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
setSessionsLoading,
|
||||
setSessionsTotal
|
||||
} from '../store/session'
|
||||
import { onSessionsChanged } from '../store/session-sync'
|
||||
import { clearSessionTodos, setSessionTodos, todoListActive } from '../store/todos'
|
||||
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '../store/updates'
|
||||
import { isSecondaryWindow } from '../store/windows'
|
||||
@@ -464,6 +465,17 @@ export function DesktopController() {
|
||||
void refreshSessions()
|
||||
}, [refreshSessions])
|
||||
|
||||
// Another window mutated the shared session list (e.g. a chat started in the
|
||||
// pop-out). Re-pull so the sidebar reflects it. Pop-outs have no sidebar, so
|
||||
// only real windows bother.
|
||||
useEffect(() => {
|
||||
if (isSecondaryWindow()) {
|
||||
return
|
||||
}
|
||||
|
||||
return onSessionsChanged(() => void refreshSessions().catch(() => undefined))
|
||||
}, [refreshSessions])
|
||||
|
||||
// ALL-profiles view pages one profile at a time: fetch that profile's next
|
||||
// page and merge it in place, leaving every other profile's rows untouched.
|
||||
const loadMoreSessionsForProfile = useCallback(async (profile: string) => {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
switcherActive,
|
||||
switcherJustClosed
|
||||
} from '@/store/session-switcher'
|
||||
import { openNewSessionInNewWindow } from '@/store/windows'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { requestComposerFocus } from '../chat/composer/focus'
|
||||
@@ -132,6 +133,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
||||
deps.startFreshSession()
|
||||
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
|
||||
},
|
||||
'session.newWindow': () => void openNewSessionInNewWindow(),
|
||||
'session.next': () => stepSession(1),
|
||||
'session.prev': () => stepSession(-1),
|
||||
...sessionSlotHandlers,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesReadDirResult } from '@/global'
|
||||
import { $connection, setCurrentCwd } from '@/store/session'
|
||||
|
||||
import { resetProjectTreeState } from './files/use-project-tree'
|
||||
|
||||
import { RightSidebarPane } from './index'
|
||||
|
||||
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
|
||||
const selectPaths = vi.fn()
|
||||
|
||||
function ok(entries: { name: string; path: string; isDirectory: boolean }[]): HermesReadDirResult {
|
||||
return { entries }
|
||||
}
|
||||
|
||||
function installBridge() {
|
||||
;(
|
||||
window as unknown as {
|
||||
hermesDesktop: {
|
||||
readDir: typeof readDir
|
||||
selectPaths: typeof selectPaths
|
||||
}
|
||||
}
|
||||
).hermesDesktop = { readDir, selectPaths }
|
||||
}
|
||||
|
||||
describe('RightSidebarPane', () => {
|
||||
beforeEach(() => {
|
||||
$connection.set(null)
|
||||
resetProjectTreeState()
|
||||
setCurrentCwd('/repo')
|
||||
readDir.mockReset()
|
||||
selectPaths.mockReset()
|
||||
readDir.mockResolvedValue(ok([{ name: 'README.md', path: '/repo/README.md', isDirectory: false }]))
|
||||
selectPaths.mockResolvedValue(['/repo-next'])
|
||||
installBridge()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$connection.set(null)
|
||||
setCurrentCwd('')
|
||||
resetProjectTreeState()
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
it('refreshes the current tree without opening the folder picker', async () => {
|
||||
const onChangeCwd = vi.fn()
|
||||
|
||||
render(<RightSidebarPane onActivateFile={vi.fn()} onActivateFolder={vi.fn()} onChangeCwd={onChangeCwd} />)
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh tree' }).hasAttribute('disabled')).toBe(false))
|
||||
|
||||
readDir.mockClear()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh tree' }))
|
||||
|
||||
await waitFor(() => expect(readDir).toHaveBeenCalledWith('/repo'))
|
||||
expect(selectPaths).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open folder' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(selectPaths).toHaveBeenCalledWith({
|
||||
defaultPath: '/repo',
|
||||
directories: true,
|
||||
multiple: false,
|
||||
title: 'Change working directory'
|
||||
})
|
||||
)
|
||||
await waitFor(() => expect(onChangeCwd).toHaveBeenCalledWith('/repo-next'))
|
||||
})
|
||||
})
|
||||
@@ -126,12 +126,12 @@ interface FilesystemTabProps extends FileTreeBodyProps {
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
// Sidebar palette + hover-reveal: refresh tracks label hover; collapse-all
|
||||
// stays visible while any folder is expanded.
|
||||
// Sidebar palette + hover-reveal: header actions stay reachable while moving
|
||||
// from the project label to the action buttons.
|
||||
const HEADER_ACTION_CLASS =
|
||||
'text-sidebar-foreground/70 hover:bg-sidebar-accent! hover:text-sidebar-accent-foreground! focus-visible:ring-sidebar-ring'
|
||||
|
||||
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 peer-focus-visible/project-label:pointer-events-auto peer-focus-visible/project-label:opacity-100 peer-hover/project-label:pointer-events-auto peer-hover/project-label:opacity-100`
|
||||
const HEADER_ACTION_LABEL_REVEAL = `${HEADER_ACTION_CLASS} pointer-events-none opacity-0 transition-opacity focus-visible:pointer-events-auto focus-visible:opacity-100 group-focus-within/project-header:pointer-events-auto group-focus-within/project-header:opacity-100 group-hover/project-header:pointer-events-auto group-hover/project-header:opacity-100`
|
||||
|
||||
function FilesystemTab({
|
||||
canCollapse,
|
||||
@@ -158,7 +158,7 @@ function FilesystemTab({
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<RightSidebarSectionHeader>
|
||||
<div className="peer/project-label flex min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-1">
|
||||
<button
|
||||
className="flex w-full min-w-0 items-center rounded-md text-left hover:text-(--ui-text-secondary)"
|
||||
onClick={() => void onChangeFolder()}
|
||||
@@ -216,7 +216,7 @@ function FilesystemTab({
|
||||
}
|
||||
|
||||
export function RightSidebarSectionHeader({ children }: { children: ReactNode }) {
|
||||
return <div className="flex h-7 shrink-0 items-center px-2.5">{children}</div>
|
||||
return <div className="group/project-header flex h-7 shrink-0 items-center px-2.5">{children}</div>
|
||||
}
|
||||
|
||||
interface FileTreeBodyProps {
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { broadcastSessionsChanged } from '@/store/session-sync'
|
||||
import { clearSessionSubagents, pruneDelegateFallbackSubagents, upsertSubagent } from '@/store/subagents'
|
||||
import { setSessionTodos } from '@/store/todos'
|
||||
import { recordToolDiff } from '@/store/tool-diffs'
|
||||
@@ -641,6 +642,9 @@ export function useMessageStream({
|
||||
})
|
||||
|
||||
void refreshSessions().catch(() => undefined)
|
||||
// Sync the freshly-titled row to other windows (e.g. main, when the turn
|
||||
// ran in the pop-out).
|
||||
broadcastSessionsChanged()
|
||||
|
||||
if (compactedTurnRef.current.delete(sessionId)) {
|
||||
shouldHydrate = false
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { cleanup, render, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getGlobalModelInfo } from '@/hermes'
|
||||
@@ -13,12 +13,51 @@ import {
|
||||
|
||||
import { useModelControls } from './use-model-controls'
|
||||
|
||||
const setGlobalModel = vi.fn()
|
||||
const notifyError = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
getGlobalModelInfo: vi.fn(),
|
||||
setGlobalModel: vi.fn()
|
||||
setGlobalModel: (...args: Parameters<typeof setGlobalModel>) => setGlobalModel(...args)
|
||||
}))
|
||||
|
||||
describe('useModelControls.refreshCurrentModel', () => {
|
||||
vi.mock('@/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: {
|
||||
desktop: {
|
||||
modelSwitchFailed: 'Model switch failed'
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/store/notifications', () => ({
|
||||
notifyError: (...args: Parameters<typeof notifyError>) => notifyError(...args)
|
||||
}))
|
||||
|
||||
type Controls = ReturnType<typeof useModelControls>
|
||||
|
||||
function Harness({
|
||||
activeSessionId,
|
||||
onReady,
|
||||
requestGateway
|
||||
}: {
|
||||
activeSessionId: string | null
|
||||
onReady: (controls: Controls) => void
|
||||
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}) {
|
||||
const controls = useModelControls({
|
||||
activeSessionId,
|
||||
queryClient: new QueryClient(),
|
||||
requestGateway
|
||||
})
|
||||
|
||||
onReady(controls)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
describe('useModelControls', () => {
|
||||
beforeEach(() => {
|
||||
$activeSessionId.set(null)
|
||||
setCurrentModel('')
|
||||
@@ -26,6 +65,7 @@ describe('useModelControls.refreshCurrentModel', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
$activeSessionId.set(null)
|
||||
setCurrentModel('')
|
||||
@@ -74,4 +114,55 @@ describe('useModelControls.refreshCurrentModel', () => {
|
||||
expect($currentModel.get()).toBe('deepseek/deepseek-v4-pro')
|
||||
expect($currentProvider.get()).toBe('deepseek')
|
||||
})
|
||||
|
||||
it('routes active-session picker changes through config.set with an explicit provider', async () => {
|
||||
const requestGateway = vi.fn(async () => ({ key: 'model', value: 'claude-sonnet-4.6' }) as never)
|
||||
let controls!: Controls
|
||||
|
||||
render(
|
||||
<Harness
|
||||
activeSessionId="session-1"
|
||||
onReady={value => (controls = value)}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
await expect(
|
||||
controls.selectModel({
|
||||
model: 'claude-sonnet-4.6',
|
||||
persistGlobal: false,
|
||||
provider: 'anthropic'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith('config.set', {
|
||||
session_id: 'session-1',
|
||||
key: 'model',
|
||||
value: 'claude-sonnet-4.6 --provider anthropic'
|
||||
})
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
|
||||
})
|
||||
|
||||
it('keeps the global path on setGlobalModel when there is no active session', async () => {
|
||||
setGlobalModel.mockResolvedValue(undefined)
|
||||
let controls!: Controls
|
||||
|
||||
render(
|
||||
<Harness
|
||||
activeSessionId={null}
|
||||
onReady={value => (controls = value)}
|
||||
requestGateway={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
await expect(
|
||||
controls.selectModel({
|
||||
model: 'claude-sonnet-4.6',
|
||||
persistGlobal: false,
|
||||
provider: 'anthropic'
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(setGlobalModel).toHaveBeenCalledWith('anthropic', 'claude-sonnet-4.6')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,9 +82,10 @@ export function useModelControls({ activeSessionId, queryClient, requestGateway
|
||||
|
||||
try {
|
||||
if (activeSessionId) {
|
||||
await requestGateway('slash.exec', {
|
||||
await requestGateway('config.set', {
|
||||
session_id: activeSessionId,
|
||||
command: `/model ${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
|
||||
key: 'model',
|
||||
value: `${selection.model} --provider ${selection.provider}${selection.persistGlobal ? ' --global' : ''}`
|
||||
})
|
||||
|
||||
if (selection.persistGlobal) {
|
||||
|
||||
@@ -58,6 +58,7 @@ import { clearSessionTodos } from '@/store/todos'
|
||||
|
||||
import type {
|
||||
ClientSessionState,
|
||||
BrowserManageResponse,
|
||||
FileAttachResponse,
|
||||
HandoffFailResponse,
|
||||
HandoffRequestResponse,
|
||||
@@ -1141,6 +1142,81 @@ export function usePromptActions({
|
||||
} catch (err) {
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
},
|
||||
// /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
|
||||
// meaningful when that process runs on this machine, so it's gated to
|
||||
// local connections. A remote gateway would act on the wrong host.
|
||||
browser: async ctx => {
|
||||
const resolved = await withSlashOutput(ctx)
|
||||
|
||||
if (!resolved) {
|
||||
return
|
||||
}
|
||||
|
||||
const { render: renderSlashOutput, sessionId } = resolved
|
||||
|
||||
if ($connection.get()?.mode === 'remote') {
|
||||
renderSlashOutput(
|
||||
'/browser manages a Chromium-family browser on the gateway host — only available when connected to a local gateway.'
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const [rawAction = 'status', ...rest] = ctx.arg.trim().split(/\s+/).filter(Boolean)
|
||||
const cmdAction = rawAction.toLowerCase()
|
||||
|
||||
if (!['connect', 'disconnect', 'status'].includes(cmdAction)) {
|
||||
renderSlashOutput(
|
||||
'usage: /browser [connect|disconnect|status] [url] · persistent: set browser.cdp_url in config.yaml'
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const url = cmdAction === 'connect' ? rest.join(' ').trim() || 'http://127.0.0.1:9222' : undefined
|
||||
|
||||
if (url) {
|
||||
renderSlashOutput(`checking Chromium-family browser remote debugging at ${url}...`)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestGateway<BrowserManageResponse>('browser.manage', {
|
||||
action: cmdAction,
|
||||
session_id: sessionId,
|
||||
...(url && { url })
|
||||
})
|
||||
|
||||
// Without a streamed session subscription, the gateway bundles its
|
||||
// progress lines into `messages` — flush them inline.
|
||||
result?.messages?.forEach(message => renderSlashOutput(message))
|
||||
|
||||
if (cmdAction === 'status') {
|
||||
renderSlashOutput(
|
||||
result?.connected
|
||||
? `browser connected: ${result.url || '(url unavailable)'}`
|
||||
: 'browser not connected (try /browser connect <url> or set browser.cdp_url in config.yaml)'
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (cmdAction === 'disconnect') {
|
||||
renderSlashOutput('browser disconnected')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (result?.connected) {
|
||||
renderSlashOutput('Browser connected to live Chromium-family browser via CDP')
|
||||
renderSlashOutput(`Endpoint: ${result.url || '(url unavailable)'}`)
|
||||
renderSlashOutput('next browser tool call will use this CDP endpoint')
|
||||
}
|
||||
} catch (err) {
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
setYoloActive,
|
||||
workspaceCwdForNewSession
|
||||
} from '@/store/session'
|
||||
import { broadcastSessionsChanged } from '@/store/session-sync'
|
||||
import { reportBackendContract } from '@/store/updates'
|
||||
import { isWatchWindow } from '@/store/windows'
|
||||
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo, UsageStats } from '@/types/hermes'
|
||||
@@ -472,6 +473,9 @@ export function useSessionActions({
|
||||
// server later returns its own preview/title and supersedes this.
|
||||
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
|
||||
navigate(sessionRoute(stored), { replace: true })
|
||||
// Other windows (e.g. the main window when this is the pop-out) can't
|
||||
// see this session until they re-pull the shared list.
|
||||
broadcastSessionsChanged()
|
||||
}
|
||||
|
||||
setFreshDraftReady(false)
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '@/store/layout'
|
||||
import { $paneWidthOverride } from '@/store/panes'
|
||||
import { $connection } from '@/store/session'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
|
||||
|
||||
@@ -80,6 +80,7 @@ export function AppShell({
|
||||
const connection = useStore($connection)
|
||||
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
|
||||
const isFullscreen = Boolean(connection?.isFullscreen) || viewportFullscreen
|
||||
const hideTitlebarControls = isNewSessionWindow()
|
||||
const titlebarControls = titlebarControlsPosition(connection?.windowButtonPosition, isFullscreen)
|
||||
// Width Windows/Linux reserve for the OS-painted min/max/close overlay (zero
|
||||
// on macOS, where window controls sit on the left and are reported via
|
||||
@@ -162,7 +163,9 @@ export function AppShell({
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
|
||||
{!hideTitlebarControls && (
|
||||
<TitlebarControls leftTools={leftTitlebarTools} onOpenSettings={onOpenSettings} tools={titlebarTools} />
|
||||
)}
|
||||
|
||||
<main className="relative z-3 flex min-h-0 w-full flex-1 flex-col overflow-hidden transition-none">
|
||||
<PaneShell className="min-h-0 flex-1">
|
||||
@@ -183,7 +186,9 @@ export function AppShell({
|
||||
the panes' z-20 resize handles, keeping every pane resizable. */}
|
||||
{mainOverlays}
|
||||
|
||||
<StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />
|
||||
{/* The compact pop-out drops the statusbar — it's a scratch window, not
|
||||
the full shell. */}
|
||||
{!isSecondaryWindow() && <StatusbarControls items={statusbarItems} leftItems={leftStatusbarItems} />}
|
||||
</main>
|
||||
|
||||
{overlays}
|
||||
|
||||
@@ -46,6 +46,12 @@ export interface SlashExecResponse {
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export interface BrowserManageResponse {
|
||||
connected?: boolean
|
||||
url?: string
|
||||
messages?: string[]
|
||||
}
|
||||
|
||||
export interface SessionSteerResponse {
|
||||
// 'queued' == accepted into the live turn's steer slot (injected at the next
|
||||
// tool-result boundary); 'rejected' == no live tool window, caller queues.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
memo,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
resetThreadScroll,
|
||||
setThreadAtBottom
|
||||
} from '@/store/thread-scroll'
|
||||
import { isNewSessionWindow, isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import { MessageRenderBoundary } from './message-render-boundary'
|
||||
|
||||
@@ -132,6 +134,13 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
const hiddenCount = firstVisible
|
||||
const visibleGroups = hiddenCount > 0 ? groups.slice(hiddenCount) : groups
|
||||
const restoreFromBottomRef = useRef<number | null>(null)
|
||||
const newSessionWindow = isNewSessionWindow()
|
||||
const newSessionTitlebarGap = 'calc(var(--titlebar-height)+0.75rem)'
|
||||
const threadContentTopPad = newSessionWindow
|
||||
? 'pt-[calc(var(--titlebar-height)+0.75rem)]'
|
||||
: isSecondaryWindow()
|
||||
? 'pt-6'
|
||||
: 'pt-[calc(var(--titlebar-height)+1.5rem)]'
|
||||
|
||||
useEffect(() => setThreadAtBottom(isAtBottom), [isAtBottom])
|
||||
useEffect(() => () => resetThreadScroll(), [])
|
||||
@@ -235,7 +244,12 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
return (
|
||||
<div
|
||||
className="relative min-h-0 max-w-full overflow-hidden contain-[layout_paint]"
|
||||
style={{ height: clampToComposer ? 'var(--thread-viewport-height)' : '100%' }}
|
||||
style={
|
||||
{
|
||||
height: clampToComposer ? 'var(--thread-viewport-height)' : '100%',
|
||||
...(newSessionWindow ? { '--sticky-human-top': newSessionTitlebarGap } : {})
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="size-full overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
@@ -252,9 +266,7 @@ const ThreadMessageListInner: FC<ThreadMessageListProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6 pt-[calc(var(--titlebar-height)+1.5rem)]'
|
||||
)}
|
||||
className={cn('mx-auto flex w-full max-w-(--composer-width) min-w-0 flex-col px-6', threadContentTopPad)}
|
||||
data-slot="aui_thread-content"
|
||||
ref={contentRef as React.RefCallback<HTMLDivElement>}
|
||||
>
|
||||
|
||||
@@ -80,9 +80,12 @@ const HOVER_REVEAL_EASE = 'cubic-bezier(0.32,0.72,0,1)'
|
||||
// Offset shadow lifting the revealed panel off the content (same both sides;
|
||||
// the mirror axis is offset-x, which is 0). Same color on light + dark.
|
||||
const HOVER_REVEAL_SHADOW = '0px -18px 18px -5px #00000012'
|
||||
// Edge trigger strip, inset past the OS window-resize grab area.
|
||||
// Edge trigger strip, inset past the OS window-resize grab area AND the
|
||||
// adjacent pane's scrollbar (0.5rem, .scrollbar-dt) — the strip overlays the
|
||||
// neighboring scroller's edge, so any overlap makes the scrollbar reveal the
|
||||
// pane on hover and swallow its clicks (#44140).
|
||||
const HOVER_REVEAL_TRIGGER_WIDTH = 14
|
||||
const HOVER_REVEAL_EDGE_GUTTER = 6
|
||||
const HOVER_REVEAL_EDGE_GUTTER = 'calc(0.5rem + 2px)'
|
||||
|
||||
// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal
|
||||
// from the keyboard, since its store-open toggle is a no-op while collapsed.
|
||||
|
||||
Vendored
+2
@@ -24,6 +24,8 @@ declare global {
|
||||
// a spectator window (lazy resume — no agent build) for live-streaming
|
||||
// a running subagent's session.
|
||||
openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }>
|
||||
// Open (or focus) a compact secondary window on the new-session draft.
|
||||
openNewSessionWindow: () => Promise<{ ok: boolean; error?: string }>
|
||||
getBootProgress: () => Promise<DesktopBootProgress>
|
||||
getConnectionConfig: (profile?: null | string) => Promise<DesktopConnectionConfig>
|
||||
saveConnectionConfig: (payload: DesktopConnectionConfigInput) => Promise<DesktopConnectionConfig>
|
||||
|
||||
@@ -189,6 +189,7 @@ export const en: Translations = {
|
||||
'nav.cron': 'Open scheduled jobs',
|
||||
'nav.agents': 'Open agents',
|
||||
'session.new': 'New session',
|
||||
'session.newWindow': 'New session in window',
|
||||
'session.next': 'Next session',
|
||||
'session.prev': 'Previous session',
|
||||
'session.slot.1': 'Switch to recent session 1',
|
||||
|
||||
@@ -185,6 +185,7 @@ export const zh: Translations = {
|
||||
'nav.cron': '打开定时任务',
|
||||
'nav.agents': '打开智能体',
|
||||
'session.new': '新建会话',
|
||||
'session.newWindow': '在新窗口中新建会话',
|
||||
'session.next': '下一个会话',
|
||||
'session.prev': '上一个会话',
|
||||
'session.slot.1': '切换到最近会话 1',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { ChatMessage, ChatMessagePart } from './chat-messages'
|
||||
import {
|
||||
appendAssistantTextPart,
|
||||
appendReasoningPart,
|
||||
chatMessageText,
|
||||
preserveLocalAssistantErrors,
|
||||
renderMediaTags,
|
||||
@@ -175,6 +176,52 @@ describe('renderMediaTags', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('interleaved reasoning/text coalescing', () => {
|
||||
it('keeps narration contiguous when reasoning interrupts mid-sentence', () => {
|
||||
// Models that interleave reasoning_content + content deltas emit
|
||||
// text → reasoning → text within one tool-bounded segment. The two text
|
||||
// fragments are really one sentence and must not be split by the
|
||||
// "Thinking" block between them.
|
||||
let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Let me ')
|
||||
parts = appendReasoningPart(parts, 'checking the file...')
|
||||
parts = appendAssistantTextPart(parts, 'verify the full file is correct:')
|
||||
|
||||
expect(parts.map(p => p.type)).toEqual(['text', 'reasoning'])
|
||||
expect((parts[0] as { text: string }).text).toBe('Let me verify the full file is correct:')
|
||||
expect((parts[1] as { text: string }).text).toBe('checking the file...')
|
||||
})
|
||||
|
||||
it('merges reasoning bursts that straddle a narration fragment', () => {
|
||||
let parts: ChatMessagePart[] = appendReasoningPart([], 'first thought ')
|
||||
parts = appendAssistantTextPart(parts, 'Working on it.')
|
||||
parts = appendReasoningPart(parts, 'second thought')
|
||||
|
||||
expect(parts.map(p => p.type)).toEqual(['reasoning', 'text'])
|
||||
expect((parts[0] as { text: string }).text).toBe('first thought second thought')
|
||||
expect((parts[1] as { text: string }).text).toBe('Working on it.')
|
||||
})
|
||||
|
||||
it('starts a fresh text part after a tool call (segment boundary)', () => {
|
||||
let parts: ChatMessagePart[] = appendAssistantTextPart([], 'Let me check.')
|
||||
parts = upsertToolPart(parts, { name: 'read_file', tool_id: 'tc-1' }, 'running')
|
||||
parts = appendAssistantTextPart(parts, 'Now editing.')
|
||||
|
||||
expect(parts.map(p => p.type)).toEqual(['text', 'tool-call', 'text'])
|
||||
expect((parts[0] as { text: string }).text).toBe('Let me check.')
|
||||
expect((parts[2] as { text: string }).text).toBe('Now editing.')
|
||||
})
|
||||
|
||||
it('does not merge reasoning across a tool call', () => {
|
||||
let parts: ChatMessagePart[] = appendReasoningPart([], 'before tool')
|
||||
parts = upsertToolPart(parts, { name: 'read_file', tool_id: 'tc-1' }, 'running')
|
||||
parts = appendReasoningPart(parts, 'after tool')
|
||||
|
||||
expect(parts.map(p => p.type)).toEqual(['reasoning', 'tool-call', 'reasoning'])
|
||||
expect((parts[0] as { text: string }).text).toBe('before tool')
|
||||
expect((parts[2] as { text: string }).text).toBe('after tool')
|
||||
})
|
||||
})
|
||||
|
||||
describe('preserveLocalAssistantErrors', () => {
|
||||
it('preserves a local user+error pair when hydration omits the failed turn', () => {
|
||||
const nextMessages: ChatMessage[] = [
|
||||
|
||||
@@ -178,50 +178,70 @@ function displayContentForMessage(role: SessionMessage['role'], content: unknown
|
||||
return [refs.join('\n'), visibleText].filter(Boolean).join('\n\n') || visibleText
|
||||
}
|
||||
|
||||
export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
||||
const next = [...parts]
|
||||
const last = next.at(-1)
|
||||
|
||||
if (last?.type === 'text') {
|
||||
next[next.length - 1] = { ...last, text: `${last.text}${delta}` }
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
next.push(textPart(delta))
|
||||
|
||||
return next
|
||||
const STREAM_PART: Record<'reasoning' | 'text', (text: string) => ChatMessagePart> = {
|
||||
reasoning: reasoningPart,
|
||||
text: textPart
|
||||
}
|
||||
|
||||
export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
||||
const next = appendTextPart(parts, delta)
|
||||
const last = next.at(-1)
|
||||
// Coalesce a streaming delta into the most recent same-type part within the
|
||||
// current segment, where a segment is bounded by any non-streaming part (a
|
||||
// tool call, image, …). The opposite streaming channel (text <-> reasoning) is
|
||||
// transparent, so a reasoning burst between two content deltas can't shred one
|
||||
// sentence into text / Thinking / text — the fragmentation models that
|
||||
// interleave reasoning_content + content otherwise produce. Tool calls still
|
||||
// open a fresh part, preserving narration order across steps.
|
||||
function appendStreamPart(
|
||||
parts: ChatMessagePart[],
|
||||
type: 'reasoning' | 'text',
|
||||
delta: string
|
||||
): { index: number; parts: ChatMessagePart[] } {
|
||||
const next = [...parts]
|
||||
|
||||
if (last?.type === 'text') {
|
||||
const current = last.text
|
||||
for (let i = next.length - 1; i >= 0; i--) {
|
||||
const part = next[i]
|
||||
|
||||
const deltaMayContainMedia =
|
||||
delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:')
|
||||
if (part.type === type) {
|
||||
next[i] = { ...part, text: `${(part as { text: string }).text}${delta}` } as ChatMessagePart
|
||||
|
||||
const needsMediaPass = deltaMayContainMedia || current.includes('MEDIA:')
|
||||
const nextText = needsMediaPass ? renderMediaTags(current) : current
|
||||
next[next.length - 1] = nextText === current ? last : { ...last, text: nextText }
|
||||
return { index: i, parts: next }
|
||||
}
|
||||
|
||||
if (part.type !== 'text' && part.type !== 'reasoning') {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
next.push(STREAM_PART[type](delta))
|
||||
|
||||
return { index: next.length - 1, parts: next }
|
||||
}
|
||||
|
||||
export function appendTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
||||
return appendStreamPart(parts, 'text', delta).parts
|
||||
}
|
||||
|
||||
export function appendReasoningPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
||||
const next = [...parts]
|
||||
const last = next.at(-1)
|
||||
return appendStreamPart(parts, 'reasoning', delta).parts
|
||||
}
|
||||
|
||||
if (last?.type === 'reasoning') {
|
||||
next[next.length - 1] = { ...last, text: `${last.text}${delta}` }
|
||||
export function appendAssistantTextPart(parts: ChatMessagePart[], delta: string): ChatMessagePart[] {
|
||||
const { index, parts: next } = appendStreamPart(parts, 'text', delta)
|
||||
const part = next[index]
|
||||
|
||||
if (part?.type !== 'text') {
|
||||
return next
|
||||
}
|
||||
|
||||
next.push(reasoningPart(delta))
|
||||
const mayContainMedia =
|
||||
delta.includes('MEDIA:') || delta.includes('DIA:') || delta.includes('EDIA:') || delta.includes('IA:')
|
||||
|
||||
if (mayContainMedia || part.text.includes('MEDIA:')) {
|
||||
const rendered = renderMediaTags(part.text)
|
||||
|
||||
if (rendered !== part.text) {
|
||||
next[index] = { ...part, text: rendered }
|
||||
}
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -52,6 +52,17 @@ describe('desktop slash command curation', () => {
|
||||
expect(desktopSlashUnavailableMessage('/personality')).toBeNull()
|
||||
})
|
||||
|
||||
it('treats /browser as an executable action command (local-gateway connect)', () => {
|
||||
// /browser used to be terminal-only; it now resolves to a desktop action
|
||||
// handler that routes browser.manage RPC when the gateway is local.
|
||||
expect(isDesktopSlashCommand('/browser')).toBe(true)
|
||||
expect(isDesktopSlashSuggestion('/browser')).toBe(true)
|
||||
expect(desktopSlashUnavailableMessage('/browser')).toBeNull()
|
||||
expect(resolveDesktopCommand('/browser')?.surface).toEqual({ kind: 'action', action: 'browser' })
|
||||
// Bare /browser expands to its sub-action options in the popover.
|
||||
expect(resolveDesktopCommand('/browser')?.args).toBe(true)
|
||||
})
|
||||
|
||||
it('allows aliases to execute without cluttering the popover', () => {
|
||||
expect(isDesktopSlashSuggestion('/reset')).toBe(false)
|
||||
expect(isDesktopSlashCommand('/reset')).toBe(true)
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface DesktopThemeCommandOption {
|
||||
*/
|
||||
export type DesktopActionId =
|
||||
| 'branch'
|
||||
| 'browser'
|
||||
| 'handoff'
|
||||
| 'help'
|
||||
| 'new'
|
||||
@@ -103,6 +104,12 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
||||
{ name: '/skin', description: 'Switch desktop theme or cycle to the next one', surface: action('skin'), args: true },
|
||||
{ name: '/title', description: 'Rename the current session', surface: action('title') },
|
||||
{ name: '/help', description: 'Show desktop slash commands', aliases: ['/commands'], surface: action('help') },
|
||||
{
|
||||
name: '/browser',
|
||||
description: 'Manage browser CDP connection [connect|disconnect|status] (local gateway only)',
|
||||
surface: action('browser'),
|
||||
args: true
|
||||
},
|
||||
|
||||
// Overlay pickers
|
||||
{ name: '/model', description: 'Switch the model for this session', surface: picker('model'), hidden: true },
|
||||
@@ -142,7 +149,7 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
||||
// per reason beats 40 identical object literals.
|
||||
const NO_DESKTOP_SURFACE: Record<DesktopUnavailableReason, readonly string[]> = {
|
||||
terminal: [
|
||||
'/browser', '/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
|
||||
'/busy', '/clear', '/compact', '/config', '/copy', '/cron', '/details',
|
||||
'/exit', '/footer', '/gateway', '/gquota', '/history', '/image', '/indicator', '/logs',
|
||||
'/mouse', '/paste', '/platforms', '/plugins', '/quit', '/redraw', '/reload', '/restart',
|
||||
'/sb', '/set-home', '/sethome', '/snap', '/snapshot', '/statusbar', '/toolsets', '/update', '/verbose'
|
||||
|
||||
@@ -66,6 +66,7 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
|
||||
|
||||
// ── Session ──────────────────────────────────────────────────────────────
|
||||
{ id: 'session.new', category: 'session', defaults: ['mod+n', 'shift+n'] },
|
||||
{ id: 'session.newWindow', category: 'session', defaults: ['mod+shift+n'] },
|
||||
// ⌃Tab / ⌃⇧Tab — the universal tab-cycle chord. Literally Control, not Cmd
|
||||
// (macOS reserves Cmd+Tab for app switching); see `ctrl` in combo.ts.
|
||||
{ id: 'session.next', category: 'session', defaults: ['ctrl+tab'] },
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $connection } from '@/store/session'
|
||||
|
||||
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway } from './media'
|
||||
import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway, mediaExternalUrl } from './media'
|
||||
|
||||
describe('isRemoteGateway', () => {
|
||||
afterEach(() => {
|
||||
@@ -35,6 +35,38 @@ describe('filePathFromMediaPath', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('mediaExternalUrl', () => {
|
||||
afterEach(() => {
|
||||
$connection.set(null)
|
||||
})
|
||||
|
||||
it('passes through http(s) URLs untouched', () => {
|
||||
$connection.set({ mode: 'remote', baseUrl: 'https://gw', token: 't' } as never)
|
||||
expect(mediaExternalUrl('https://example.com/a.png')).toBe('https://example.com/a.png')
|
||||
})
|
||||
|
||||
it('keeps file:// form in local mode', () => {
|
||||
$connection.set({ mode: 'local' } as never)
|
||||
expect(mediaExternalUrl('/tmp/a.png')).toBe('file:///tmp/a.png')
|
||||
expect(mediaExternalUrl('file:///tmp/a.png')).toBe('file:///tmp/a.png')
|
||||
})
|
||||
|
||||
it('rewrites gateway-local paths to an authenticated download URL', () => {
|
||||
$connection.set({ mode: 'remote', baseUrl: 'https://gw', token: 's e/cret' } as never)
|
||||
expect(mediaExternalUrl('file:///tmp/a b.png')).toBe(
|
||||
'https://gw/api/files/download?path=%2Ftmp%2Fa%20b.png&token=s%20e%2Fcret'
|
||||
)
|
||||
expect(mediaExternalUrl('/tmp/a b.png')).toBe(
|
||||
'https://gw/api/files/download?path=%2Ftmp%2Fa%20b.png&token=s%20e%2Fcret'
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to file:// when remote connection lacks a token', () => {
|
||||
$connection.set({ mode: 'remote', baseUrl: 'https://gw' } as never)
|
||||
expect(mediaExternalUrl('/tmp/a.png')).toBe('file:///tmp/a.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gatewayMediaDataUrl', () => {
|
||||
const api = vi.fn(async () => ({ data_url: 'data:image/png;base64,ZHVtbXk=' }))
|
||||
|
||||
|
||||
@@ -56,8 +56,25 @@ export function mediaMarkdownHref(path: string): string {
|
||||
return `#media:${encodeURIComponent(path)}`
|
||||
}
|
||||
|
||||
// Resolve a media path to a URL the shell can open. Remote mode rewrites
|
||||
// gateway-local paths to an authenticated /api/files/download URL (the file
|
||||
// lives on the gateway, not this disk); local mode keeps the file:// form.
|
||||
export function mediaExternalUrl(path: string): string {
|
||||
return /^(?:https?|file):/i.test(path) ? path : `file://${path}`
|
||||
if (/^https?:/i.test(path)) {
|
||||
return path
|
||||
}
|
||||
|
||||
if (isRemoteGateway()) {
|
||||
const conn = $connection.get()
|
||||
|
||||
if (conn?.baseUrl && conn.token) {
|
||||
const file = encodeURIComponent(filePathFromMediaPath(path))
|
||||
|
||||
return `${conn.baseUrl}/api/files/download?path=${file}&token=${encodeURIComponent(conn.token)}`
|
||||
}
|
||||
}
|
||||
|
||||
return /^file:/i.test(path) ? path : `file://${path}`
|
||||
}
|
||||
|
||||
// Custom Electron scheme (registered in electron/main.cjs) that streams a local
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { atom } from 'nanostores'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesConnection } from '@/global'
|
||||
|
||||
// Keep profile.ts's side-effecting imports inert: the gateway socket layer and
|
||||
// the REST query client must not run for real in a unit test.
|
||||
const ensureGatewayForProfile = vi.fn(async () => undefined)
|
||||
const $gateway = atom<unknown>({ id: 'live-socket' })
|
||||
|
||||
vi.mock('@/store/gateway', () => ({ $gateway, ensureGatewayForProfile }))
|
||||
vi.mock('@/hermes', () => ({
|
||||
getProfiles: vi.fn(async () => ({ profiles: [] })),
|
||||
setApiRequestProfile: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } }))
|
||||
|
||||
const { $activeGatewayProfile, ensureGatewayProfile } = await import('./profile')
|
||||
const { $connection } = await import('./session')
|
||||
|
||||
const remoteConn = (over: Partial<HermesConnection> = {}): HermesConnection =>
|
||||
({ baseUrl: 'https://hermes-roy.tail.ts.net', mode: 'remote', profile: 'vps-remote', ...over }) as HermesConnection
|
||||
|
||||
const localConn = (over: Partial<HermesConnection> = {}): HermesConnection =>
|
||||
({ baseUrl: '', mode: 'local', profile: 'default', ...over }) as HermesConnection
|
||||
|
||||
const getConnection = vi.fn<(profile?: string | null) => Promise<HermesConnection>>()
|
||||
|
||||
beforeEach(() => {
|
||||
getConnection.mockReset()
|
||||
ensureGatewayForProfile.mockClear()
|
||||
$gateway.set({ id: 'live-socket' })
|
||||
$activeGatewayProfile.set('default')
|
||||
$connection.set(localConn())
|
||||
vi.stubGlobal('window', { hermesDesktop: { getConnection } })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
$connection.set(null)
|
||||
})
|
||||
|
||||
describe('ensureGatewayProfile → $connection sync (#46651)', () => {
|
||||
it('refreshes $connection to the remote descriptor when activating a remote pool profile', async () => {
|
||||
// Regression: the primary window backend is local, so $connection.mode is
|
||||
// "local". Activating the remote profile must flip it to "remote" — without
|
||||
// this, image attach uses path-based image.attach against the remote
|
||||
// gateway ("image not found: C:\\…") instead of image.attach_bytes.
|
||||
getConnection.mockResolvedValue(remoteConn())
|
||||
|
||||
await ensureGatewayProfile('vps-remote')
|
||||
|
||||
expect(ensureGatewayForProfile).toHaveBeenCalledWith('vps-remote')
|
||||
expect(getConnection).toHaveBeenCalledWith('vps-remote')
|
||||
expect($connection.get()?.mode).toBe('remote')
|
||||
expect($connection.get()?.profile).toBe('vps-remote')
|
||||
})
|
||||
|
||||
it('resyncs $connection back to local when returning to the default profile', async () => {
|
||||
$activeGatewayProfile.set('vps-remote')
|
||||
$connection.set(remoteConn())
|
||||
getConnection.mockResolvedValue(localConn())
|
||||
|
||||
await ensureGatewayProfile('default')
|
||||
|
||||
expect(getConnection).toHaveBeenCalledWith('default')
|
||||
expect($connection.get()?.mode).toBe('local')
|
||||
})
|
||||
|
||||
it('leaves the prior connection intact when the descriptor fetch fails', async () => {
|
||||
getConnection.mockRejectedValue(new Error('backend unreachable'))
|
||||
|
||||
await ensureGatewayProfile('vps-remote')
|
||||
|
||||
// Best-effort: boot/reconnect resyncs later; we must not null it out here.
|
||||
expect($connection.get()?.mode).toBe('local')
|
||||
})
|
||||
|
||||
it('does not churn $connection when the target is already the active profile', async () => {
|
||||
$activeGatewayProfile.set('vps-remote')
|
||||
$connection.set(remoteConn())
|
||||
|
||||
await ensureGatewayProfile('vps-remote')
|
||||
|
||||
expect(getConnection).not.toHaveBeenCalled()
|
||||
expect(ensureGatewayForProfile).not.toHaveBeenCalled()
|
||||
expect($connection.get()?.mode).toBe('remote')
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
storedStringRecord
|
||||
} from '@/lib/storage'
|
||||
import { $gateway, ensureGatewayForProfile } from '@/store/gateway'
|
||||
import { setConnection } from '@/store/session'
|
||||
import type { ProfileInfo } from '@/types/hermes'
|
||||
|
||||
// Canonical key for a profile: trimmed, empty → "default". Used everywhere we
|
||||
@@ -178,6 +179,32 @@ export const $gatewaySwapTarget = atom<string | null>(null)
|
||||
|
||||
let gatewaySwitch: Promise<void> | null = null
|
||||
|
||||
// Keep the renderer's $connection (mode / baseUrl / profile) in lockstep with
|
||||
// the profile the live gateway is now on. $connection seeds from the PRIMARY
|
||||
// (window) backend at boot and otherwise only refreshes on a sleep/wake
|
||||
// reconnect — so activating a *background* profile left $connection describing
|
||||
// the primary, with the wrong `mode` for everything that branches on
|
||||
// local-vs-remote. Headline symptom: with a local primary and a remote pool
|
||||
// profile active, image attachments went out via the path-based `image.attach`
|
||||
// instead of `image.attach_bytes`, handing the remote gateway a client-only
|
||||
// path it can't resolve ("image not found: C:\…"), while the /api/fs/* file
|
||||
// browser and /api/media fetches targeted the wrong machine (#46651).
|
||||
// Best-effort: a failed descriptor fetch leaves the prior connection intact for
|
||||
// boot/reconnect to resync.
|
||||
async function syncConnectionToActiveProfile(profile: string): Promise<void> {
|
||||
const getConnection = window.hermesDesktop?.getConnection
|
||||
|
||||
if (!getConnection) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setConnection(await getConnection(profile))
|
||||
} catch {
|
||||
// Leave the prior connection in place; boot/reconnect resyncs it later.
|
||||
}
|
||||
}
|
||||
|
||||
// Make `profile`'s backend the active gateway, lazily opening its socket if it
|
||||
// isn't live yet. Unlike the old single-socket swap, background profiles keep
|
||||
// their sockets — so their sessions keep streaming concurrently. A null/empty
|
||||
@@ -218,6 +245,9 @@ export async function ensureGatewayProfile(profile: string | null | undefined):
|
||||
// the active gateway at it — without closing the profile you came from.
|
||||
await ensureGatewayForProfile(target)
|
||||
$activeGatewayProfile.set(target)
|
||||
// The active backend just changed; resync $connection so remote-aware
|
||||
// paths (image.attach_bytes vs image.attach, /api/fs/*, /api/media) follow.
|
||||
await syncConnectionToActiveProfile(target)
|
||||
})()
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Cross-window session-list sync. Each desktop window is its own renderer
|
||||
// process with its own gateway socket and session store, so a mutation in one
|
||||
// (e.g. a new chat started in the compact pop-out) never reaches another
|
||||
// window. This bus pings every window to re-pull the shared session list; the
|
||||
// data already lives in the backend, the other window just doesn't know to look.
|
||||
const CHANNEL = 'hermes:sessions'
|
||||
|
||||
const channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL)
|
||||
|
||||
// A window that mutated the session list (created / titled a chat) tells the
|
||||
// others to refresh. A BroadcastChannel never delivers to its own poster, so the
|
||||
// caller refreshes locally as it already does.
|
||||
export function broadcastSessionsChanged(): void {
|
||||
channel?.postMessage(1)
|
||||
}
|
||||
|
||||
export function onSessionsChanged(handler: () => void): () => void {
|
||||
if (!channel) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
channel.addEventListener('message', handler)
|
||||
|
||||
return () => channel.removeEventListener('message', handler)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from './windows'
|
||||
import { canOpenSessionWindow, openNewSessionInNewWindow, openSessionInNewWindow } from './windows'
|
||||
|
||||
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
|
||||
const initialHermesDesktop = desktopWindow.hermesDesktop
|
||||
@@ -11,9 +11,13 @@ vi.mock('./notifications', () => ({
|
||||
notifyError: (...args: unknown[]) => notifyError(...args)
|
||||
}))
|
||||
|
||||
function installBridge(openSessionWindow?: Window['hermesDesktop']['openSessionWindow']) {
|
||||
function installBridge(
|
||||
openSessionWindow?: Window['hermesDesktop']['openSessionWindow'],
|
||||
openNewSessionWindow?: Window['hermesDesktop']['openNewSessionWindow']
|
||||
) {
|
||||
desktopWindow.hermesDesktop = {
|
||||
...(openSessionWindow ? { openSessionWindow } : {})
|
||||
...(openSessionWindow ? { openSessionWindow } : {}),
|
||||
...(openNewSessionWindow ? { openNewSessionWindow } : {})
|
||||
} as unknown as Window['hermesDesktop']
|
||||
}
|
||||
|
||||
@@ -101,3 +105,39 @@ describe('openSessionInNewWindow', () => {
|
||||
expect(notifyError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openNewSessionInNewWindow', () => {
|
||||
it('no-ops gracefully when the bridge is absent (web fallback)', async () => {
|
||||
delete desktopWindow.hermesDesktop
|
||||
|
||||
await openNewSessionInNewWindow()
|
||||
|
||||
expect(notifyError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('no-ops when openNewSessionWindow is missing', async () => {
|
||||
installBridge(vi.fn().mockResolvedValue({ ok: true }))
|
||||
|
||||
await openNewSessionInNewWindow()
|
||||
|
||||
expect(notifyError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('invokes the bridge', async () => {
|
||||
const openNew = vi.fn().mockResolvedValue({ ok: true })
|
||||
installBridge(vi.fn().mockResolvedValue({ ok: true }), openNew)
|
||||
|
||||
await openNewSessionInNewWindow()
|
||||
|
||||
expect(openNew).toHaveBeenCalledTimes(1)
|
||||
expect(notifyError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('notifies on an ok:false result', async () => {
|
||||
installBridge(vi.fn().mockResolvedValue({ ok: true }), vi.fn().mockResolvedValue({ ok: false, error: 'nope' }))
|
||||
|
||||
await openNewSessionInNewWindow()
|
||||
|
||||
expect(notifyError).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { notifyError } from './notifications'
|
||||
// never from the router. A "secondary" window renders a single chat without the
|
||||
// global session sidebar or the install / onboarding overlays.
|
||||
const SECONDARY_WINDOW_FLAG = 'secondary'
|
||||
const NEW_SESSION_WINDOW_FLAG = '1'
|
||||
|
||||
let secondaryWindowCache: boolean | null = null
|
||||
|
||||
@@ -27,6 +28,26 @@ export function isSecondaryWindow(): boolean {
|
||||
return result
|
||||
}
|
||||
|
||||
let newSessionWindowCache: boolean | null = null
|
||||
|
||||
export function isNewSessionWindow(): boolean {
|
||||
if (newSessionWindowCache !== null) {
|
||||
return newSessionWindowCache
|
||||
}
|
||||
|
||||
let result = false
|
||||
|
||||
try {
|
||||
result = new URLSearchParams(window.location.search).get('new') === NEW_SESSION_WINDOW_FLAG
|
||||
} catch {
|
||||
result = false
|
||||
}
|
||||
|
||||
newSessionWindowCache = result
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
let watchWindowCache: boolean | null = null
|
||||
|
||||
// A "watch" window spectates a session that is being driven elsewhere (a
|
||||
@@ -57,6 +78,22 @@ export function canOpenSessionWindow(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.hermesDesktop?.openSessionWindow === 'function'
|
||||
}
|
||||
|
||||
type WindowOpenResult = { ok: boolean; error?: string } | undefined
|
||||
|
||||
// Run a window-open bridge call, surfacing any failure as a toast. Shared by the
|
||||
// session pop-out and the new-session pop-out.
|
||||
async function openWindow(call: () => Promise<WindowOpenResult>, failMessage: string): Promise<void> {
|
||||
try {
|
||||
const result = await call()
|
||||
|
||||
if (!result?.ok) {
|
||||
notifyError(new Error(result?.error || 'unknown error'), failMessage)
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, failMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Open (or focus) a standalone OS window for a single chat session. No-ops
|
||||
// gracefully outside Electron so callers can wire it unconditionally.
|
||||
// `watch: true` opens a spectator window (lazy resume, live-mirror stream).
|
||||
@@ -65,13 +102,14 @@ export async function openSessionInNewWindow(sessionId: string, opts?: { watch?:
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.hermesDesktop.openSessionWindow(sessionId, opts)
|
||||
await openWindow(() => window.hermesDesktop.openSessionWindow(sessionId, opts), 'Could not open chat in a new window')
|
||||
}
|
||||
|
||||
if (!result?.ok) {
|
||||
notifyError(new Error(result?.error || 'unknown error'), 'Could not open chat in a new window')
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not open chat in a new window')
|
||||
// Open a fresh compact window on the new-session draft.
|
||||
export async function openNewSessionInNewWindow(): Promise<void> {
|
||||
if (!canOpenSessionWindow() || typeof window.hermesDesktop.openNewSessionWindow !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
await openWindow(() => window.hermesDesktop.openNewSessionWindow(), 'Could not open new session window')
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2023"],
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
@@ -724,7 +724,7 @@ platform_toolsets:
|
||||
# # allowed_chats: ["-1001234567890"]
|
||||
# extra:
|
||||
# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages
|
||||
# rich_messages: false # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
|
||||
# rich_messages: false # Bot API 10.1 rich messages (tables/task lists/details/math); default true, set false to force legacy MarkdownV2
|
||||
#
|
||||
# Discord-specific settings (config.yaml top-level, not under platforms:):
|
||||
#
|
||||
|
||||
@@ -977,6 +977,11 @@ def _run_cleanup(*, notify_session_finalize: bool = True):
|
||||
_cleanup_all_terminals()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from tools.async_delegation import interrupt_all as _interrupt_async_delegations
|
||||
_interrupt_async_delegations(reason="CLI shutdown")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_cleanup_all_browsers()
|
||||
except Exception:
|
||||
@@ -5783,14 +5788,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
if not self._session_db:
|
||||
return []
|
||||
try:
|
||||
sessions = self._session_db.list_sessions_rich(
|
||||
from hermes_cli.session_listing import query_session_listing
|
||||
|
||||
return query_session_listing(
|
||||
self._session_db,
|
||||
source="cli",
|
||||
exclude_sources=["tool"],
|
||||
current_session_id=self.session_id,
|
||||
include_all_sources=False,
|
||||
include_unnamed=True,
|
||||
limit=limit,
|
||||
exclude_sources=["tool"],
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
return [s for s in sessions if s.get("id") != self.session_id]
|
||||
|
||||
def _show_recent_sessions(self, *, reason: str = "history", limit: int = 10) -> bool:
|
||||
"""Render recent sessions inline from the active chat TUI.
|
||||
|
||||
+153
-61
@@ -5,6 +5,7 @@ Jobs are stored in ~/.hermes/cron/jobs.json
|
||||
Output is saved to ~/.hermes/cron/output/{job_id}/{timestamp}.md
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
@@ -14,6 +15,19 @@ import threading
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
|
||||
# Cross-process advisory file locking for jobs.json critical sections.
|
||||
# fcntl is Unix-only; on Windows fall back to msvcrt. Either may be absent,
|
||||
# in which case _jobs_lock() degrades to in-process locking only (the old
|
||||
# behaviour) rather than failing.
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - non-Unix
|
||||
fcntl = None
|
||||
try:
|
||||
import msvcrt
|
||||
except ImportError: # pragma: no cover - non-Windows
|
||||
msvcrt = None
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
@@ -41,10 +55,79 @@ JOBS_FILE = CRON_DIR / "jobs.json"
|
||||
# In-process lock protecting load_jobs→modify→save_jobs cycles.
|
||||
# Required when tick() runs jobs in parallel threads — without this,
|
||||
# concurrent mark_job_run / advance_next_run calls can clobber each other.
|
||||
_jobs_file_lock = threading.Lock()
|
||||
_jobs_file_lock = threading.RLock()
|
||||
_jobs_lock_state = threading.local()
|
||||
OUTPUT_DIR = CRON_DIR / "output"
|
||||
ONESHOT_GRACE_SECONDS = 120
|
||||
|
||||
|
||||
def _jobs_lock_file() -> Path:
|
||||
"""Return the advisory lock path for the current cron directory."""
|
||||
return CRON_DIR / ".jobs.lock"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _jobs_lock():
|
||||
"""Serialize a load_jobs→modify→save_jobs critical section.
|
||||
|
||||
Combines the in-process threading lock (cheap mutual exclusion between
|
||||
the gateway's parallel tick threads) with a cross-process advisory file
|
||||
lock on ``<cron dir>/.jobs.lock`` (mutual exclusion between the gateway process
|
||||
and standalone ``hermes`` CLI invocations, which previously shared no lock
|
||||
at all — a `cron pause` could be silently clobbered by a concurrent
|
||||
gateway write, leaving a "paused" job still firing).
|
||||
|
||||
The flock is blocking, but every critical section that uses it is short
|
||||
(field updates only — no agent execution), so contention resolves in
|
||||
milliseconds. If neither fcntl nor msvcrt is available the manager still
|
||||
provides in-process locking, matching the historical behaviour.
|
||||
|
||||
Nested calls in the same thread reuse the held lock so legacy callers that
|
||||
invoke save_jobs() inside a broader mutation section don't deadlock or try
|
||||
to reacquire the advisory file lock.
|
||||
"""
|
||||
depth = getattr(_jobs_lock_state, "depth", 0)
|
||||
if depth:
|
||||
_jobs_lock_state.depth = depth + 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_jobs_lock_state.depth -= 1
|
||||
return
|
||||
|
||||
with _jobs_file_lock:
|
||||
_jobs_lock_state.depth = 1
|
||||
lock_fd = None
|
||||
try:
|
||||
try:
|
||||
ensure_dirs()
|
||||
lock_fd = open(_jobs_lock_file(), "a+", encoding="utf-8")
|
||||
lock_fd.seek(0)
|
||||
if fcntl is not None:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
elif msvcrt is not None:
|
||||
getattr(msvcrt, "locking")(lock_fd.fileno(), getattr(msvcrt, "LK_LOCK"), 1)
|
||||
except (OSError, IOError) as e:
|
||||
# Never let a locking failure take down cron writes — fall back to
|
||||
# in-process-only protection (still held via _jobs_file_lock).
|
||||
logger.warning("jobs.json cross-process lock unavailable (%s); "
|
||||
"proceeding with in-process lock only", e)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if lock_fd is not None:
|
||||
try:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
elif msvcrt is not None:
|
||||
getattr(msvcrt, "locking")(lock_fd.fileno(), getattr(msvcrt, "LK_UNLCK"), 1)
|
||||
except (OSError, IOError):
|
||||
pass
|
||||
finally:
|
||||
lock_fd.close()
|
||||
finally:
|
||||
_jobs_lock_state.depth = 0
|
||||
|
||||
# Fields on a cron job that must never change after creation. ``id`` is used
|
||||
# as a filesystem path component under ``OUTPUT_DIR``; allowing it to be
|
||||
# updated lets an unsafe value (``../escape``, absolute path, nested) leak
|
||||
@@ -468,8 +551,8 @@ def load_jobs() -> List[Dict[str, Any]]:
|
||||
)
|
||||
|
||||
|
||||
def save_jobs(jobs: List[Dict[str, Any]]):
|
||||
"""Save all jobs to storage."""
|
||||
def _save_jobs_unlocked(jobs: List[Dict[str, Any]]):
|
||||
"""Save all jobs to storage. Caller must hold _jobs_lock()."""
|
||||
ensure_dirs()
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(JOBS_FILE.parent), suffix='.tmp', prefix='.jobs_')
|
||||
try:
|
||||
@@ -487,6 +570,12 @@ def save_jobs(jobs: List[Dict[str, Any]]):
|
||||
raise
|
||||
|
||||
|
||||
def save_jobs(jobs: List[Dict[str, Any]]):
|
||||
"""Save all jobs to storage."""
|
||||
with _jobs_lock():
|
||||
_save_jobs_unlocked(jobs)
|
||||
|
||||
|
||||
def _normalize_workdir(workdir: Optional[str]) -> Optional[str]:
|
||||
"""Normalize and validate a cron job workdir.
|
||||
|
||||
@@ -670,9 +759,10 @@ def create_job(
|
||||
"workdir": normalized_workdir,
|
||||
}
|
||||
|
||||
jobs = load_jobs()
|
||||
jobs.append(job)
|
||||
save_jobs(jobs)
|
||||
with _jobs_lock():
|
||||
jobs = load_jobs()
|
||||
jobs.append(job)
|
||||
save_jobs(jobs)
|
||||
|
||||
return job
|
||||
|
||||
@@ -743,49 +833,50 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
|
||||
f"Cron job field(s) cannot be updated: {', '.join(sorted(bad_fields))}"
|
||||
)
|
||||
|
||||
jobs = load_jobs()
|
||||
for i, job in enumerate(jobs):
|
||||
if job["id"] != job_id:
|
||||
continue
|
||||
with _jobs_lock():
|
||||
jobs = load_jobs()
|
||||
for i, job in enumerate(jobs):
|
||||
if job["id"] != job_id:
|
||||
continue
|
||||
|
||||
# Validate / normalize workdir if present in updates. Empty string or
|
||||
# None both mean "clear the field" (restore old behaviour).
|
||||
if "workdir" in updates:
|
||||
_wd = updates["workdir"]
|
||||
if _wd in {None, "", False}:
|
||||
updates["workdir"] = None
|
||||
else:
|
||||
updates["workdir"] = _normalize_workdir(_wd)
|
||||
# Validate / normalize workdir if present in updates. Empty string
|
||||
# or None both mean "clear the field" (restore old behaviour).
|
||||
if "workdir" in updates:
|
||||
_wd = updates["workdir"]
|
||||
if _wd in {None, "", False}:
|
||||
updates["workdir"] = None
|
||||
else:
|
||||
updates["workdir"] = _normalize_workdir(_wd)
|
||||
|
||||
updated = _apply_skill_fields({**job, **updates})
|
||||
schedule_changed = "schedule" in updates
|
||||
updated = _apply_skill_fields({**job, **updates})
|
||||
schedule_changed = "schedule" in updates
|
||||
|
||||
if "skills" in updates or "skill" in updates:
|
||||
normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills"))
|
||||
updated["skills"] = normalized_skills
|
||||
updated["skill"] = normalized_skills[0] if normalized_skills else None
|
||||
if "skills" in updates or "skill" in updates:
|
||||
normalized_skills = _normalize_skill_list(updated.get("skill"), updated.get("skills"))
|
||||
updated["skills"] = normalized_skills
|
||||
updated["skill"] = normalized_skills[0] if normalized_skills else None
|
||||
|
||||
if schedule_changed:
|
||||
updated_schedule = updated["schedule"]
|
||||
# The API may pass schedule as a raw string (e.g. "every 10m")
|
||||
# instead of a pre-parsed dict. Normalize it the same way
|
||||
# create_job() does so downstream code can call .get() safely.
|
||||
if isinstance(updated_schedule, str):
|
||||
updated_schedule = parse_schedule(updated_schedule)
|
||||
updated["schedule"] = updated_schedule
|
||||
updated["schedule_display"] = updates.get(
|
||||
"schedule_display",
|
||||
updated_schedule.get("display", updated.get("schedule_display")),
|
||||
)
|
||||
if updated.get("state") != "paused":
|
||||
updated["next_run_at"] = compute_next_run(updated_schedule)
|
||||
if schedule_changed:
|
||||
updated_schedule = updated["schedule"]
|
||||
# The API may pass schedule as a raw string (e.g. "every 10m")
|
||||
# instead of a pre-parsed dict. Normalize it the same way
|
||||
# create_job() does so downstream code can call .get() safely.
|
||||
if isinstance(updated_schedule, str):
|
||||
updated_schedule = parse_schedule(updated_schedule)
|
||||
updated["schedule"] = updated_schedule
|
||||
updated["schedule_display"] = updates.get(
|
||||
"schedule_display",
|
||||
updated_schedule.get("display", updated.get("schedule_display")),
|
||||
)
|
||||
if updated.get("state") != "paused":
|
||||
updated["next_run_at"] = compute_next_run(updated_schedule)
|
||||
|
||||
if updated.get("enabled", True) and updated.get("state") != "paused" and not updated.get("next_run_at"):
|
||||
updated["next_run_at"] = compute_next_run(updated["schedule"])
|
||||
if updated.get("enabled", True) and updated.get("state") != "paused" and not updated.get("next_run_at"):
|
||||
updated["next_run_at"] = compute_next_run(updated["schedule"])
|
||||
|
||||
jobs[i] = updated
|
||||
save_jobs(jobs)
|
||||
return _normalize_job_record(jobs[i])
|
||||
jobs[i] = updated
|
||||
save_jobs(jobs)
|
||||
return _normalize_job_record(jobs[i])
|
||||
return None
|
||||
|
||||
|
||||
@@ -847,19 +938,20 @@ def remove_job(job_id: str) -> bool:
|
||||
if not job:
|
||||
return False
|
||||
canonical_id = job["id"]
|
||||
jobs = load_jobs()
|
||||
original_len = len(jobs)
|
||||
jobs = [j for j in jobs if j["id"] != canonical_id]
|
||||
if len(jobs) < original_len:
|
||||
# Resolve the output dir BEFORE saving so a legacy unsafe ID (e.g.
|
||||
# left over from before the create-time guard) fails closed without
|
||||
# half-applying the removal.
|
||||
job_output_dir = _job_output_dir(canonical_id)
|
||||
save_jobs(jobs)
|
||||
# Clean up output directory to prevent orphaned dirs accumulating
|
||||
if job_output_dir.exists():
|
||||
shutil.rmtree(job_output_dir)
|
||||
return True
|
||||
with _jobs_lock():
|
||||
jobs = load_jobs()
|
||||
original_len = len(jobs)
|
||||
jobs = [j for j in jobs if j["id"] != canonical_id]
|
||||
if len(jobs) < original_len:
|
||||
# Resolve the output dir BEFORE saving so a legacy unsafe ID (e.g.
|
||||
# left over from before the create-time guard) fails closed without
|
||||
# half-applying the removal.
|
||||
job_output_dir = _job_output_dir(canonical_id)
|
||||
save_jobs(jobs)
|
||||
# Clean up output directory to prevent orphaned dirs accumulating
|
||||
if job_output_dir.exists():
|
||||
shutil.rmtree(job_output_dir)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -874,7 +966,7 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None,
|
||||
``delivery_error`` is tracked separately from the agent error — a job
|
||||
can succeed (agent produced output) but fail delivery (platform down).
|
||||
"""
|
||||
with _jobs_file_lock:
|
||||
with _jobs_lock():
|
||||
jobs = load_jobs()
|
||||
for i, job in enumerate(jobs):
|
||||
if job["id"] == job_id:
|
||||
@@ -948,7 +1040,7 @@ def advance_next_run(job_id: str) -> bool:
|
||||
|
||||
Returns True if next_run_at was advanced, False otherwise.
|
||||
"""
|
||||
with _jobs_file_lock:
|
||||
with _jobs_lock():
|
||||
jobs = load_jobs()
|
||||
for job in jobs:
|
||||
if job["id"] == job_id:
|
||||
@@ -973,12 +1065,12 @@ def get_due_jobs() -> List[Dict[str, Any]]:
|
||||
the job is fast-forwarded to the next future run instead of firing
|
||||
immediately. This prevents a burst of missed jobs on gateway restart.
|
||||
"""
|
||||
with _jobs_file_lock:
|
||||
with _jobs_lock():
|
||||
return _get_due_jobs_locked()
|
||||
|
||||
|
||||
def _get_due_jobs_locked() -> List[Dict[str, Any]]:
|
||||
"""Inner implementation of get_due_jobs(); must be called with _jobs_file_lock held."""
|
||||
"""Inner implementation of get_due_jobs(); must be called with _jobs_lock held."""
|
||||
now = _hermes_now()
|
||||
raw_jobs = load_jobs()
|
||||
jobs = [_apply_skill_fields(j) for j in copy.deepcopy(raw_jobs)]
|
||||
@@ -1158,7 +1250,7 @@ def rewrite_skill_refs(
|
||||
if not consolidated and not pruned_set:
|
||||
return {"rewrites": [], "jobs_updated": 0, "jobs_scanned": 0}
|
||||
|
||||
with _jobs_file_lock:
|
||||
with _jobs_lock():
|
||||
jobs = load_jobs()
|
||||
rewrites: List[Dict[str, Any]] = []
|
||||
changed = False
|
||||
|
||||
@@ -316,6 +316,7 @@ as_hermes mkdir -p \
|
||||
"$HERMES_HOME/cron" \
|
||||
"$HERMES_HOME/sessions" \
|
||||
"$HERMES_HOME/logs" \
|
||||
"$HERMES_HOME/logs/gateways" \
|
||||
"$HERMES_HOME/hooks" \
|
||||
"$HERMES_HOME/memories" \
|
||||
"$HERMES_HOME/skills" \
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Ink TUI — diagnostic environment flags
|
||||
|
||||
Non-secret behavioral knobs for the Ink engine (`ui-tui/`). These are
|
||||
**environment overrides**, not `.env` secrets — set them in your shell for a
|
||||
session, or `export` them in your shell rc to make them sticky. They mirror the
|
||||
OpenTUI engine's flags (`docs/opentui-env-flags.md`) so a single switch covers
|
||||
both engines.
|
||||
|
||||
| Flag | Default | What it does |
|
||||
|---|---|---|
|
||||
| `HERMES_TUI_DIAGNOSTICS` | off | Master diagnostics switch. Turning it on enables the developer/profiling surface across the TUI — including the memory self-sampler below. One `export HERMES_TUI_DIAGNOSTICS=1` in your shell rc covers **every** session you start, on **either** engine. |
|
||||
| `HERMES_TUI_MEMLOG` | = `HERMES_TUI_DIAGNOSTICS` | In-process 1Hz memory self-sampling (`ui-tui/src/lib/memlog.ts`) → `~/.hermes/logs/memwatch/<boot>-<pid>.jsonl`. Defaults to the master switch; set `=1` / `=0` to force it on/off independently. |
|
||||
|
||||
## What the memory trace captures
|
||||
|
||||
Each Ink session, when sampling is enabled, appends one JSON line per second to
|
||||
its own file under `~/.hermes/logs/memwatch/`, keyed by boot time + pid:
|
||||
|
||||
```json
|
||||
{"t":1781514892,"rss_kb":92148,"heap_used_kb":7234,"external_kb":2378}
|
||||
```
|
||||
|
||||
- `t` — unix seconds.
|
||||
- `rss_kb` — resident set size (the number that matters for the native-RSS-gap
|
||||
story: rss climbing while heap stays flat is the #15141-class signal).
|
||||
- `heap_used_kb` — V8 heap in use.
|
||||
- `external_kb` — off-heap (buffers, native allocations).
|
||||
|
||||
**Ink emits no `mounted` / `peak_mounted` field.** Those are OpenTUI's
|
||||
windowing dev counters; Ink has no windowing, so it logs the rss/heap/external
|
||||
core only. `memwatch-report.mjs` treats `mounted` as optional, so Ink lines
|
||||
aggregate cleanly alongside OpenTUI's.
|
||||
|
||||
## Why this exists — cross-engine memory comparison
|
||||
|
||||
The filename scheme, directory, and line schema are **byte-compatible with
|
||||
OpenTUI's collector** (`ui-opentui/src/boundary/memlog.ts`). Both engines write
|
||||
to the same `~/.hermes/logs/memwatch/` directory, so one aggregator reads both:
|
||||
|
||||
```sh
|
||||
# enable on either/both engines (master switch covers both)
|
||||
export HERMES_TUI_DIAGNOSTICS=1
|
||||
HERMES_TUI_ENGINE=ink hermes --tui # Ink session → its own .jsonl
|
||||
HERMES_TUI_ENGINE=opentui hermes --tui # OpenTUI session → its own .jsonl
|
||||
|
||||
# fleet table across BOTH engines' sessions:
|
||||
cd ~/github/tui-bench && node memwatch-report.mjs
|
||||
```
|
||||
|
||||
This is what makes a true side-by-side **real-world** memory arc possible —
|
||||
cold floor → load → plateau/leak — instead of comparing OpenTUI dogfood traces
|
||||
against an Ink harness with no equivalent data.
|
||||
|
||||
## Cost & safety
|
||||
|
||||
- ~50 bytes/s when on; one `process.memoryUsage()` + one short append per
|
||||
second. The interval is **unref'd** — it never keeps the process alive.
|
||||
- 14-day retention: older traces are pruned (best-effort) at start.
|
||||
- **Every failure path disables the logger silently.** Diagnostics must never
|
||||
break the TUI — this is the one place the "errors propagate" rule is
|
||||
intentionally inverted, matching the OpenTUI collector.
|
||||
- Off by default: regular users write nothing.
|
||||
|
||||
## Getting a meaningful trace
|
||||
|
||||
A short scroll-through won't show growth. For a comparison against OpenTUI's
|
||||
4–5h sessions, drive a tool-heavy 2–3h Ink session as the floor (see
|
||||
`docs/plans/opentui-ink-asymmetry-note.md` for why the harness ≠ dogfood data).
|
||||
@@ -0,0 +1,120 @@
|
||||
# Handoff — OpenTUI memory + UX, continuing on the canonical branch
|
||||
|
||||
**You are continuing the Hermes OpenTUI engine work.** This is the base operating manual; the
|
||||
user (glitch) appends specific tasks on top. Read it, then read the repo docs it points to. It
|
||||
assumes NO prior transcript/memory.
|
||||
|
||||
## Where things are
|
||||
|
||||
- **Canonical branch: `feat/opentui-native-engine`** (the draft PR to main, #42922).
|
||||
`feat/opentui-memory-window` is a synonym at the *same tip* — they were consolidated. Treat
|
||||
native-engine as canonical; if you work from memory-window, periodically
|
||||
`git push origin HEAD:feat/opentui-native-engine` to keep them in sync, or just use native-engine.
|
||||
- The native engine source is **`ui-opentui/`**; the legacy Ink engine is `ui-tui/` (shipping
|
||||
default, untouched by this campaign). The Python gateway is `tui_gateway/`, launcher
|
||||
`hermes_cli/main.py`.
|
||||
- **The worktree is often the user's LIVE global `hermes`** (`~/.local/bin/hermes` symlinks into a
|
||||
worktree's `.venv`). Consequences: (1) NEVER leave the worktree in a half-merged/conflicted state
|
||||
— a new `hermes` session would fail to build; (2) after you land source changes, rebuild
|
||||
`dist/main.js` so the next session picks them up; (3) `hermes-stable` is the flip-back to the
|
||||
stock `~/.hermes/hermes-agent` install if you need to bypass the worktree.
|
||||
- Backups of pre-merge branch states exist as `backup/*` refs (recoverable via `git reset`).
|
||||
|
||||
## Runtime, build, gate (Node 26 — NOT Bun; the port is done)
|
||||
|
||||
```sh
|
||||
export PATH="$HOME/.local/share/fnm/node-versions/v26.3.0/installation/bin:$PATH"
|
||||
cd ui-opentui && node scripts/build.mjs # → dist/main.js (esbuild + Solid/JSX)
|
||||
HERMES_TUI_MOUSE=1 node --experimental-ffi --no-warnings dist/main.js # launch; quit = double Ctrl+C
|
||||
cd ui-opentui && npm run check # THE GATE: prettier+eslint(typed)+vitest (~700). Judge by `echo $?`, never a piped tail.
|
||||
```
|
||||
|
||||
Never run bun here. Never run `hermes update` in the worktree (it flips the branch — recovery is
|
||||
painful). Never broad-pkill tui_gateway (other live sessions). Host RAM ~15GB, often <5GB free —
|
||||
run benches SEQUENTIALLY (the harness already wraps SUTs in `systemd-run … MemoryMax=2G`).
|
||||
|
||||
## The docs that are the source of truth (read, and KEEP UPDATED as you change things)
|
||||
|
||||
- `docs/opentui-memory-story.md` — ELI5 of the whole memory architecture (primitives + every decision).
|
||||
- `docs/plans/opentui-transcript-windowing.md` — windowing design (S1 spacers, S2 append-time), the
|
||||
`correctionIsLegal` zero-jank law, pre-registered gates, SHIPPED status + S3 backlog.
|
||||
- `docs/opentui-env-flags.md` — the consolidated env-flag ledger (master switch / user / dev / plumbing).
|
||||
- `docs/opentui-upstream-alignment.md` — forkless invariant, `boundary/` shim ledger, the per-release
|
||||
OpenTUI upgrade playbook (native-yoga is coming upstream — re-tune windowing margins when it lands).
|
||||
- the bench suite (cells, harness, live-attach, memwatch) now lives in its own
|
||||
repo: **tui-bench** (`github.com/NousResearch/tui-bench`); see its `README.md`.
|
||||
- `ui-opentui/README.md` — Node 26 onboarding (fnm setup that doesn't disturb other projects).
|
||||
- `docs/plans/ink-memory-adversarial-review.md` — Ink's memory weaknesses (F1–F10, the turnabout).
|
||||
- `docs/plans/gateway-death-forensics.md`, `docs/plans/workorder-2026-06-11-results.md`,
|
||||
`docs/plans/rebase-from-main-spec.md` — forensics, the merge-bar verdict, the rebase plan.
|
||||
|
||||
## Workflow (this is how the last 60+ commits were produced with ~zero rework)
|
||||
|
||||
1. **Subagent-driven** (skill: `subagent-driven-development`): one implementer per task with a TIGHT
|
||||
file fence ("you own exactly these files; `git diff --cached --stat` before commit, abort on
|
||||
out-of-fence"), a mandatory `opentui` skill read FIRST for any renderable work, and a gate judged
|
||||
by exit code. Verify the self-report YOURSELF (re-run the gate, read the riskiest hunks, check the
|
||||
commit file-list) — a subagent "✅ done" is a claim, not a fact.
|
||||
2. **Adversarial review** after a task: a fresh read-only reviewer (Explore-type) with NAMED attack
|
||||
surfaces. Then ADJUDICATE in code — reviewers over-flag; ~half of "blockers" don't survive a read.
|
||||
3. **Parallel implementers are safe ONLY with disjoint file fences.** Read-only recon agents
|
||||
parallelize freely.
|
||||
4. **Live smoke catches what headless can't** — tmux + the `tmux-pane-screenshot` skill for real
|
||||
colored frames. The demo: `node scripts/build.mjs scripts/demo.tsx .demo` then
|
||||
`DEMO_TOTAL=2000 … node --experimental-ffi --no-warnings .demo/demo.js`.
|
||||
5. Commit format `opentui(v6): …`, **NO attribution lines**. The user's standing instruction is
|
||||
"commit + push as you land things" — honor it; otherwise don't push without asking. Edit large
|
||||
load-bearing files (the Python launcher, `store.ts`) DIRECTLY, never via subagent.
|
||||
|
||||
## Dogfooding (the user works on this FROM the hermes TUI)
|
||||
|
||||
`export HERMES_TUI_DIAGNOSTICS=1` in the shell rc turns on, for every session: the `/mem` +
|
||||
`/heapdump` slash commands, window-stats, and **fleet memory self-logging** to
|
||||
`~/.hermes/logs/memwatch/<boot>-<pid>.jsonl`. Aggregate all sessions with
|
||||
`node memwatch-report.mjs` from the **tui-bench** repo
|
||||
(`github.com/NousResearch/tui-bench`) (per-session baseline/peak/slope + SLOPE/PEAK/MOUNTED anomaly
|
||||
flags). Chase a flagged session with tui-bench's `live-attach.sh <pid> --heap`. The discipline: live
|
||||
anomaly → encode as a bench cell → fix → validate against live sessions again.
|
||||
|
||||
## Current state (2026-06) + the ranked backlog
|
||||
|
||||
Windowing SHIPPED: 2k-msg peak ~300MB (was 686; Ink 234), scroll p99 6ms, cap restored 1000→3000,
|
||||
determinism digest unchanged, peak mounted ~31 rows. Live sessions peak <200MB. The transcript is no
|
||||
longer the biggest lever — the ~160MB floor is ≈104MB Node+OpenTUI runtime + **≈55MB tool/skill
|
||||
catalogs hydrated at boot**. Ranked next levers:
|
||||
|
||||
1. **W3 — 1GB V8 heap default** (small, ~free): set the unconstrained default in
|
||||
`_resolve_tui_heap_mb`; both engines are Node now so both inherit it. Ink half = separate gated
|
||||
commit (shipping engine). Measured −90MB at bench scale.
|
||||
2. **cg_peak harness fix** (small): the cgroup `memory.peak` field is polluted (shared across runs) —
|
||||
reset/scope it before quoting tui-bench's `report.html` again. Trust `vmhwm_kb` + `samples[].rss_kb`.
|
||||
3. **New bench cells** (before W1, as its baselines): `resume-1900` (real p99 shape: time-to-first-
|
||||
paint + post-hydration RSS) and `10MB-tool-output` (the F1 byte-unbounded class). Run BOTH engines.
|
||||
4. **Catalog lazy-load** (new, promoted by live data): don't hydrate 1,185 tools at boot — fetch on
|
||||
picker-open. Attacks the ≈55MB floor; pays on EVERY session (median is 20 msgs). Likely cheaper
|
||||
than W1.
|
||||
5. **W1 thin renderer** (structural, biggest): bodies live in the gateway (SQLite); TUI keeps ~300B
|
||||
stubs + fetches bodies for the window only. Design the gateway windowed-read RPC FIRST. WATCH: `/copy`
|
||||
and the ⧉ block-copy read store parts — they need a fetch-on-demand fallback or W1 ships a copy regression.
|
||||
6. **Standing**: when native-yoga OpenTUI ships, run the upgrade playbook (re-bench, re-tune margins,
|
||||
audit the shim ledger). Three questions to relay to the OpenTUI maintainer are in the alignment doc.
|
||||
|
||||
## What NOT to do
|
||||
- Don't copy opencode's 100-msg store cap (user's p90 session is 182 msgs — it would truncate normal use).
|
||||
- Don't reintroduce estimate-correction scroll jank (the user explicitly vetoed it; `correctionIsLegal` forbids it).
|
||||
- Don't cite the obsolete "~210MB bun renderer / +120MB" memory figures — pre-port, pre-windowing, wrong.
|
||||
- Don't push/PR without the standing OK; don't commit `.plans/` scratch unless asked.
|
||||
|
||||
## Suggested skills
|
||||
(All available from the Hermes TUI agent too — this is the dogfooding surface. Curated to the load-bearing set, not the full ~40-skill catalog.)
|
||||
- `opentui-tui-engineering` — the workflow/architecture/pitfalls layer for `ui-opentui/` (just updated).
|
||||
- `hermes-tui-architecture` — the Hermes-specific TUI facts (launch pipeline, both engines; just updated).
|
||||
- `opentui` — the offline renderable-API doc set; mandatory `skill_view` before any view/renderable code.
|
||||
- `subagent-driven-development` — the process spine for parallel/heavy work.
|
||||
- `tmux-pane-screenshot` — real colored PNG of a tmux pane for visual verification (ported
|
||||
into hermes skills 2026-06-13). Use: `bash ~/.hermes/skills/software-development/
|
||||
tmux-pane-screenshot/scripts/tshot.sh <session:win.pane> out.png 2`, then Read the PNG.
|
||||
`freeze` (~/go/bin) + the resvg rasterizer are shared/system-wide — works as-is.
|
||||
- `effect-ts` — for the Effect-at-boundary entry/lifecycle code.
|
||||
- `superpowers:brainstorming` — before committing to a memory-architecture design (e.g. W1's store split).
|
||||
- `systematic-debugging` — if a gate fails; root-cause before patching.
|
||||
@@ -0,0 +1,81 @@
|
||||
# OpenTUI env flags — the consolidated ledger
|
||||
|
||||
Every environment variable the OpenTUI TUI reads (grep-verified 2026-06-12),
|
||||
classified by who should ever touch it. The design rule shipped with this doc:
|
||||
**regular users see zero diagnostic surface by default; one master switch
|
||||
(`HERMES_TUI_DIAGNOSTICS=1`) turns all of it on when needed.**
|
||||
|
||||
## 1. The master switch
|
||||
|
||||
| var | default | effect |
|
||||
|---|---|---|
|
||||
| `HERMES_TUI_DIAGNOSTICS` | **off** | Enables the diagnostic slash commands (`/mem`, `/heapdump`). While off they're hidden from `/help` (client-side filter) and invoking them prints the enable hint rather than executing. They never appear in slash *completion* in either state — completion is gateway-driven and these are client-only commands the gateway doesn't know (an adversarial review confirmed there's no bypass path; if a SERVER command named `mem`/`heapdump` is ever added it must be gated gateway-side too — the client gate would shadow but not hide it). Also flips the *default* of `HERMES_TUI_WINDOW_STATS` to on. Not a secret — support flows are "relaunch with `HERMES_TUI_DIAGNOSTICS=1`". |
|
||||
|
||||
## 2. User-facing configuration (fine to document publicly)
|
||||
|
||||
| var | default | effect |
|
||||
|---|---|---|
|
||||
| `HERMES_TUI_ENGINE` | auto (`opentui` if Node≥26.3 + built, else `ink`) | Engine pick; also `display.tui_engine` in config.yaml. |
|
||||
| `HERMES_TUI_MOUSE` / `HERMES_TUI_MOUSE_TRACKING` / `HERMES_TUI_DISABLE_MOUSE` | on | Mouse support (wheel scroll, selection, click-to-expand). **Defers to Ink's env surface (`logic/env.ts` `resolveMouseEnabled`):** precedence is `HERMES_TUI_MOUSE_TRACKING` (toggle, force knob) > `HERMES_TUI_DISABLE_MOUSE=1` (legacy kill switch) > `HERMES_TUI_MOUSE` (OpenTUI-native alias, kept — also what the launcher sets) > default on. OpenTUI's renderer mouse is a single boolean, so Ink's granular off\|wheel\|buttons\|all collapses to on/off (the granular mode lives in `display.mouse_tracking` config). |
|
||||
| `HERMES_TUI_SCROLL_SPEED` (alias `CLAUDE_CODE_SCROLL_SPEED`) | native | Wheel-scroll speed multiplier (Ink parity). UNSET → OpenTUI's native scroll acceleration (untouched). A positive value (clamped to (0,20]) installs a constant-multiplier `ScrollAcceleration` on the transcript scrollbox (`view/transcript.tsx`). |
|
||||
| `HERMES_TUI_NO_CONFIRM` | off | Skip the destructive-action confirm step (`/clear`, `/new`) and run immediately (Ink parity, `NO_CONFIRM_DESTRUCTIVE`). Wired at the `confirm` seam (`entry/main.tsx`). |
|
||||
| `HERMES_TUI_MAX_MESSAGES` | ceiling | Scrollback rows kept in the TUI. Can LOWER the ceiling, never raise: 3000 with windowing, 1000 with windowing off (handle-table safety). |
|
||||
| `HERMES_TUI_TOOL_OUTPUT_LINES` | unlimited | Cap expanded tool-output lines (set a number to restore a cap). |
|
||||
| `HERMES_TUI_TOOL_OUTPUTS` | **on** | Keep rich tool-call OUTPUTS (full result body + raw result/args dicts). `=off` drops both the RENDER and the STORE of those bodies (Ink parity: only a one-line context preview + name/duration/error/diff survive) — the memory lever for the OpenTUI-vs-Ink retention asymmetry, and what the bench launches OpenTUI with for the fair engine-overhead comparison (W3). Diffs (file-edit) are KEPT either way. |
|
||||
| `HERMES_TUI_HEAP_MB` | cgroup-aware (default 8192) | V8 `--max-old-space-size` (MB) for BOTH engines. Highest precedence (then `display.tui_heap_mb` config, then the cgroup-75% fallback). Set it LOW for a low-mem session (still cgroup-clamped on top so it never exceeds the container); raise it to lift the ceiling. The low-mem opt-in signal that also arms `HERMES_TUI_PROACTIVE_GC` (W1). |
|
||||
| `HERMES_TUI_PROACTIVE_GC` | = low-`HERMES_TUI_HEAP_MB` (≤4096) | Idle-gated `global.gc()` for the low-mem path. Defaults ON only when a low heap cap is set (so the knobs compose); `=on`/`=off` forces it. Needs `--expose-gc` (the OpenTUI argv now carries it). Never runs mid-stream; tightens cadence above 400MB RSS but stays idle-gated. OpenTUI-only — Ink never GCs proactively (W2). |
|
||||
| `HERMES_TUI_COMPOSER_ROWS` | default rows | Composer height. |
|
||||
|
||||
## 3. Escape hatches & tuning (dev-facing, individually settable)
|
||||
|
||||
| var | default | effect |
|
||||
|---|---|---|
|
||||
| `HERMES_TUI_WINDOWING` | **on** | `0` = bit-exact pre-windowing renderer (every row mounts; cap clamps back to 1000). The A/B + regression escape hatch. |
|
||||
| `HERMES_TUI_WINDOW_IDLE_MS` | ~1000 | Idle-measure pulse cadence (the spacer-exactness march). Test knob. |
|
||||
| `HERMES_TUI_WINDOW_STATS` | = `HERMES_TUI_DIAGNOSTICS` | Exposes live/peak mounted-row counters (`globalThis.__hermesTuiWindowStats`) for tui-bench's live-attach reads. |
|
||||
| `HERMES_TUI_MEMLOG` | = `HERMES_TUI_DIAGNOSTICS` | In-process 1Hz memory self-sampling (`boundary/memlog.ts`) → `~/.hermes/logs/memwatch/<boot>-<pid>.jsonl` (rss/heap/external + mounted rows; 14-day retention). Fleet view: `node memwatch-report.mjs` from the tui-bench repo (`github.com/NousResearch/tui-bench`). The "monitor all my sessions" answer: one `export HERMES_TUI_DIAGNOSTICS=1` in your shell rc covers every session. |
|
||||
| `HERMES_TUI_LOG_LEVEL` / `HERMES_TUI_LOG_FILE` | engine defaults | Logging verbosity/destination (`/logs` reads the ring buffer regardless). Deliberately independent of the master switch — support often wants logs without the full diag surface. |
|
||||
| `HERMES_HEAPDUMP_ON_START` | off | Write one V8 heap snapshot at boot (Ink parity). A deliberate baseline-capture escape hatch that BYPASSES the diagnostics master switch; lands at `$HERMES_HOME/logs/opentui-heap-<ts>.heapsnapshot` and echoes the path as a system line (`entry/main.tsx`). |
|
||||
| `HERMES_TUI_NOTIFY` | on | Desktop-notification kill switch (`=0`/`false`/`off` silences the "waiting on you" pings). The ping itself goes through the renderer's native `triggerNotification` (protocol detection + tmux/Zellij wrapping); the window title is not gated by this. |
|
||||
|
||||
## 4. Internal plumbing (set by the launcher/tui-bench/tests — humans never set these)
|
||||
|
||||
| var | set by | effect |
|
||||
|---|---|---|
|
||||
| `HERMES_PYTHON`, `HERMES_PYTHON_SRC_ROOT`, `HERMES_CWD` | launcher / bench | Which gateway python + repo root + cwd the TUI spawns against (the bench's fake-gateway seam). |
|
||||
| `HERMES_TUI_ACTIVE_SESSION_FILE` | launcher/bench | Session handoff file. |
|
||||
| `HERMES_TUI_RESUME`, `HERMES_TUI_QUERY`, `HERMES_TUI_PROMPT`, `HERMES_TUI_IMAGE`, `HERMES_TUI_FAKE` | launcher/tests | Resume-at-boot; seeded prompt (`--tui "prompt"`: launcher sets `HERMES_TUI_QUERY`, the engine reads QUERY > the `HERMES_TUI_PROMPT` alias > a bare argv tail — `logic/env.ts` `startupPrompt`); seeded image PATH (`--image`: `HERMES_TUI_IMAGE`, `image.attach`ed before the prompt — `startupImage`, attach in `postSessionSetup`); fake-mode. |
|
||||
| `HERMES_AUTO_HEAPDUMP*` (`_COOLDOWN_MS`/`_MAX_BYTES`), `HERMES_HEAPDUMP_DIR`, `HERMES_HEAPDUMP_MAX_BYTES` | — | **NOT read by the OpenTUI engine (deliberate).** The engine ports Ink's #34095 silent-death early-WARNING (a transcript system line, `boundary/memoryMonitor.ts`) but NOT the auto heap-SNAPSHOT capture — the always-on memlog NDJSON trace is the diagnosis path, and its rss-vs-heap divergence is the better diagnostic for the native-RSS leak class (#15141) a V8 snapshot captures poorly. So the #41948 disk-fill safety set (gate/cooldown/byte-cap/dir) has no consumer here. `HERMES_HEAPDUMP_ON_START` (manual one-shot, §3) is the only heapdump knob the engine honors. |
|
||||
| `HERMES_TUI_RPC_TIMEOUT_MS`, `HERMES_TUI_STARTUP_TIMEOUT_MS` | tests/CI | Protocol timeouts. |
|
||||
| (`ui-tui` only) `HERMES_TUI_MEMSAMPLE_FD/MS` | bench | Ink fd-3 node sampler. |
|
||||
|
||||
## 5. Ink flags NOT ported — handled natively or out of scope
|
||||
|
||||
These exist on the legacy Ink TUI (`ui-tui/`) and are deliberately **not** read
|
||||
by the OpenTUI engine. Documented so a missing flag reads as a decision, not a gap.
|
||||
|
||||
| Ink flag | why not ported |
|
||||
|---|---|
|
||||
| `HERMES_TUI_TRUECOLOR` | OpenTUI core does COLORTERM/truecolor detection natively — the Ink force-truecolor hack is a fork workaround we shed. |
|
||||
| `HERMES_TUI_FORCE_OSC52` | OpenTUI core owns OSC52 clipboard as a primitive; no fallback hint needed. |
|
||||
| `HERMES_TUI_INLINE` / `HERMES_TUI_TERMUX_MODE` / `HERMES_TUI_TERMUX_FAST_ECHO` | Termux/primary-buffer accommodations. OpenTUI's native FFI floor (Node ≥26.3 + `--experimental-ffi`) is absent on Termux, so those sessions stay on **Ink** — these are correctly N/A for the OpenTUI engine. |
|
||||
| `HERMES_TUI_FPS` | Ink FPS overlay; the OpenTUI equivalent is the diag/window-stats surface (`HERMES_TUI_WINDOW_STATS`). Not parity-critical. |
|
||||
| `HERMES_DEV_CREDITS` / `HERMES_DEV_PERF*` | Dev-only throwaway scaffolding (live-spend readout, perf logging) — not user parity. |
|
||||
| `HERMES_BIN` / `HERMES_TUI_GATEWAY_URL` / `HERMES_TUI_SIDECAR_URL` | External-CLI / remote-gateway-URL overrides. OpenTUI spawns its gateway via the Effect boundary (`liveGateway.ts`) and does not shell out to `hermes` or take an external gateway URL. |
|
||||
| `HERMES_VOICE` | Voice mode is tracked on the OpenTUI parity backlog separately, not here. |
|
||||
|
||||
## How the pieces compose (the support script)
|
||||
|
||||
- Regular user, normal day: zero flags, zero diagnostic commands visible.
|
||||
- "My TUI feels heavy" support flow: `HERMES_TUI_DIAGNOSTICS=1 hermes` → `/mem`
|
||||
for the live numbers, `/heapdump` for a snapshot to attach, window stats
|
||||
exposed for tui-bench's `live-attach.sh <pid>` to read.
|
||||
- Developer profiling: same master switch + the individual knobs
|
||||
(`HERMES_TUI_WINDOWING=0` A/B, `WINDOW_IDLE_MS` tuning) as needed.
|
||||
- Anything in section 4 appearing in a user-facing doc is a bug.
|
||||
|
||||
Gating implementation: `logic/env.ts` (`diagnosticsEnabled()`),
|
||||
`logic/slash.ts` (`DIAGNOSTIC_COMMANDS` — dispatch hint, help + completion
|
||||
filtering), `view/transcript.tsx` (stats default). Tests:
|
||||
`slash.test.ts` (gating both states), `utilityCommands.test.ts` (commands
|
||||
themselves, gate enabled suite-wide).
|
||||
@@ -0,0 +1,207 @@
|
||||
# How the OpenTUI transcript got from 686MB to ~300MB — the full story
|
||||
|
||||
*For: glitch. Branch: `feat/opentui-memory-window`. Everything here is measured,
|
||||
not vibes; every number has a result JSON in the **tui-bench** repo's `results/` (`github.com/NousResearch/tui-bench`).*
|
||||
|
||||
---
|
||||
|
||||
## 1. The cast of characters (the primitives, bottom-up)
|
||||
|
||||
To understand where the memory went, you need to know who's holding it. Six
|
||||
layers, from the screen up:
|
||||
|
||||
**The terminal grid.** Your terminal is a spreadsheet of character cells.
|
||||
Nobody pays per-message here — tmux holds ~5MB flat no matter how long the
|
||||
session is (we measured). The terminal is never the problem.
|
||||
|
||||
**The OpenTUI native renderer (Zig).** A compiled library that owns the
|
||||
"frame buffer" — the grid of cells about to be painted. Every piece of text the
|
||||
TUI shows lives in a native **TextBuffer** (the characters + their colors),
|
||||
viewed through a **TextBufferView**, styled by a **SyntaxStyle**. Each of those
|
||||
is a **native handle** — a ticket into one global table that has only **65,535
|
||||
slots, total, ever** (16-bit indices — like a coat check with 65k hooks).
|
||||
Destroying a renderable returns its tickets, so the constraint is not "how much
|
||||
have you ever created" but **"how much is alive right now."**
|
||||
|
||||
**Renderables.** OpenTUI's UI objects — `<text>`, `<box>`, `<markdown>`,
|
||||
`<code>`, `<scrollbox>`. One transcript row (a message with its tool calls,
|
||||
markdown, code blocks, copy chips) is a *tree* of these: **~16 text renderables
|
||||
≈ 47 native handles ≈ ~250–340KB of RSS, per row.** This is the number that
|
||||
drives everything. 1,400 mounted rows × 47 handles = table full = the crash we
|
||||
root-caused last week.
|
||||
|
||||
**Yoga (the layout engine, WASM).** Every renderable also has a Yoga node —
|
||||
Yoga is the flexbox calculator that decides where boxes go. OpenTUI ships it
|
||||
compiled to **WebAssembly**, and WASM has a brutal property: its memory can
|
||||
**grow but never shrink** back to the OS. So the peak number of
|
||||
*simultaneously-mounted* renderables sets a high-water mark you pay **forever**,
|
||||
even after everything is destroyed. (Fun fact from this week's forensics: we
|
||||
spent two days believing Ink had this disease. It doesn't — our Ink fork swapped
|
||||
Yoga-WASM for a plain TypeScript port at fork creation. **We** are the ones
|
||||
running layout in WASM. The accusation was true; we just had the defendant
|
||||
wrong.)
|
||||
|
||||
**Solid (the view framework).** Renders each store message into a row via
|
||||
`<For>`. The property we exploit: Solid mounts/unmounts *surgically* — remove a
|
||||
row from what the component returns and Solid destroys exactly that row's
|
||||
renderables (returning its handles and freeing its Yoga nodes), touching
|
||||
nothing else. No virtual-DOM diffing, no collateral re-renders.
|
||||
|
||||
**V8 (the JavaScript engine) + the store.** The store keeps every message as JS
|
||||
strings/objects. V8's garbage collector is *lazy by design*: with the default
|
||||
8GB ceiling we launch with, it sees no reason to clean up aggressively, so RSS
|
||||
includes a lot of "collectible but not yet collected" garbage. Cheap to fix,
|
||||
worth real MB (measured below).
|
||||
|
||||
**The scrollbox.** One detail that fooled everyone at some point:
|
||||
`viewportCulling` (on by default) skips *drawing* offscreen rows — but they stay
|
||||
fully **mounted**: handles held, Yoga nodes alive, memory paid. Culling saves
|
||||
paint time, not memory. That misunderstanding is half the reason the "rolling
|
||||
store cap" was expected to be enough, and wasn't.
|
||||
|
||||
## 2. Why it was 686MB
|
||||
|
||||
Simple arithmetic. The old TUI mounted **every message in the store** as a full
|
||||
renderable tree. 2,000 messages × ~16 renderables × (handles + Yoga nodes +
|
||||
text buffers + V8 objects) ≈ 670–690MB, growing ~300MB per 1,000 messages. And
|
||||
at ~1,400 rows the handle table filled: first a hard crash (exit 7), then —
|
||||
after our containment fix — survival with **unstyled text** past that point,
|
||||
plus a cap clamped from 3,000 rows down to 1,000 as the price of not crashing.
|
||||
|
||||
Ink, meanwhile, sat at ~234MB at the same workload, because Ink only ever
|
||||
mounts the rows near your viewport (~84–400 live nodes). Its memory is the
|
||||
*data* plus some caches — not the *view*.
|
||||
|
||||
## 3. The decisions, in order
|
||||
|
||||
### Decision 1: virtualize the view, don't starve the store
|
||||
|
||||
Two ways to cut view memory: keep fewer messages (opencode's answer — they keep
|
||||
100 and delete the rest from memory; transcript truth lives on their server), or
|
||||
keep all messages but only *materialize* the ones near the viewport. You vetoed
|
||||
the first (your p90 session is 182 messages — a 100-row store truncates normal
|
||||
sessions), so: **windowing**. Notably the OpenTUI devs confirmed this week that
|
||||
framework-level virtualization is the intended path — the engine doesn't ship
|
||||
it out of the box, and opencode never built it. We did.
|
||||
|
||||
### Decision 2: exact heights, recorded at unmount — never estimates in your face
|
||||
|
||||
This is the load-bearing idea, and it's where we beat Ink at its own game.
|
||||
|
||||
The hard problem of any virtualized list: an unmounted row still needs to
|
||||
occupy its correct *height*, or the scrollbar lies and content jumps. Ink
|
||||
solves it by **guessing** heights and correcting after measurement — those
|
||||
corrections are precisely the 83–101ms scroll stutters you hate. You explicitly
|
||||
vetoed "estimate-correction jank" as a model.
|
||||
|
||||
Our advantage: OpenTUI lays out with real, queryable heights. So when a row
|
||||
scrolls out of the window, we record its **exact laid-out height** (an
|
||||
`onSizeChange` hook fires inside layout, pre-paint) and replace the row with an
|
||||
empty `<box height={exactly-that}/>` — a **spacer**: one Yoga node, zero text
|
||||
buffers, zero native handles. Think of a bookshelf where books you're not
|
||||
reading are swapped for cardboard sleeves cut to *exactly* the book's
|
||||
thickness: the shelf never shifts, and you can't tell from across the room.
|
||||
|
||||
The window is your viewport ± one viewport of margin (plus hysteresis so it
|
||||
doesn't thrash at the edges). Scroll near a spacer and the real row remounts —
|
||||
at the recorded height, so nothing moves.
|
||||
|
||||
And one **law**, written into the code as `correctionIsLegal`: a spacer's
|
||||
height may only ever be corrected where you *cannot see it* — fully above the
|
||||
viewport (with the scroll position compensated in the same frame, so the world
|
||||
doesn't move) or fully below it. A correction that would shift visible content
|
||||
is forbidden, structurally. Jank isn't tuned down; it's outlawed.
|
||||
|
||||
### Decision 3 (the S2 insight): adjudicate on *append*, not just on scroll
|
||||
|
||||
S1 alone got 686 → 518MB. Why not more? Because of *when* windowing decided.
|
||||
S1 re-decided the window when you **scrolled**. But during a streaming burst —
|
||||
an agent turn dumping hundreds of rows — you don't scroll; rows arrive, each
|
||||
mounting fully, and only get demoted later. That transient pile-up is mostly
|
||||
invisible in steady-state numbers… except for Yoga-WASM, where **the transient
|
||||
peak is permanent** (memory never shrinks). The burst was quietly ratcheting
|
||||
the floor.
|
||||
|
||||
S2 makes the window recompute on **transcript growth**: while you're pinned at
|
||||
the bottom, the window anchors to the content *bottom*, so a row that falls
|
||||
more than a margin behind the live edge becomes a spacer the moment it's
|
||||
measured — not whenever you next scroll. Measured result: across a 1,500-row
|
||||
burst, the peak number of simultaneously-mounted rows is **31**.
|
||||
|
||||
Same trick for **resume**: opening a 2,000-message session used to mount all of
|
||||
it (transient peak again — paid forever). Now resume mounts only the bottom
|
||||
window; everything above starts as spacers using a line-count estimate, and an
|
||||
idle-time "measure march" quietly mounts ten rows at a time near the window
|
||||
edge, records their true heights, and swaps them back — all outside the
|
||||
viewport, all invisible by the law above.
|
||||
|
||||
### Decision 4: rows that must never be windowed
|
||||
|
||||
Windowing has to know what it's not allowed to touch:
|
||||
- **Streaming rows** — the native markdown renderer streams incrementally;
|
||||
unmounting mid-stream would restart it visibly.
|
||||
- **The bottom 30 rows** — the region you actually live in.
|
||||
- **Rows under a mouse selection** — the review caught that a lingering
|
||||
highlight originally froze windowing *forever* (memory regrowing silently).
|
||||
Fixed: only an active drag pauses swaps, and selected rows get pinned, so
|
||||
copy is byte-exact while everything else keeps windowing.
|
||||
|
||||
### Decision 5: give back the scrollback (cap 1,000 → 3,000)
|
||||
|
||||
The 1,000-row clamp existed only because mounted-rows == stored-rows and the
|
||||
handle table dies at ~1,400. With windowing, mounted ≈ 31 regardless of store
|
||||
size — so the cap went back to the originally-shipped 3,000. It's
|
||||
windowing-aware: the `HERMES_TUI_WINDOWING=0` escape hatch (which mounts
|
||||
everything again) keeps the safe 1,000.
|
||||
|
||||
### Decision 6 (measured, not yet shipped as default): right-size the V8 heap
|
||||
|
||||
Running the windowed TUI with a 512MB heap ceiling instead of 8GB forced V8 to
|
||||
actually collect: another −90MB with zero latency cost. That's queued as a
|
||||
launcher default change (~1GB), for both engines.
|
||||
|
||||
## 4. The scoreboard
|
||||
|
||||
At 2,000 messages (your real p99 session size — yes, we checked your DB:
|
||||
median session is 20 messages, p99 is 1,941):
|
||||
|
||||
| | peak memory | scroll p99 (slowest 1-in-100) |
|
||||
|---|---|---|
|
||||
| OpenTUI before | 686MB | 16ms |
|
||||
| + S1 windowing | 518MB | 16ms |
|
||||
| + S2 append/resume windowing | **300–375MB** | **6ms** |
|
||||
| Ink (reference) | 229–246MB | ~100ms |
|
||||
|
||||
At the **3,000-message stress** with the restored triple-size scrollback:
|
||||
**360MB, fully styled, scroll p99 8ms** — a workload that six days ago crashed
|
||||
the process, and three days ago survived only by dropping syntax colors.
|
||||
|
||||
Scroll got *faster* because there are simply fewer live renderables to walk.
|
||||
The determinism gate stayed **byte-identical** — the windowed TUI's settled
|
||||
frame is provably the same pixels as before. And the live smoke (2,000-message
|
||||
session: full sweep to the top, resize storm, back to bottom) returned a frame
|
||||
pixel-identical to boot, with deep history fully syntax-highlighted — something
|
||||
the pre-windowing TUI literally could not do.
|
||||
|
||||
## 5. What's honestly still open
|
||||
|
||||
- The remaining ~60–120MB over Ink is mostly the **store's JS strings** and
|
||||
process baseline — the view is no longer the problem. The structural fix is
|
||||
the **thin renderer** (W1): bodies live in the Python gateway (which already
|
||||
has them in SQLite); the TUI keeps ~300-byte stubs and fetches bodies only
|
||||
for the window. That also fixes the class of problem neither engine handles
|
||||
today: a single 10MB tool output.
|
||||
- Two accepted, documented limits: scrollbar-*jumping* deep into a freshly
|
||||
resumed session can land on estimate-height rows that snap to true height as
|
||||
they enter view (normal scrolling doesn't — the margin pre-measures; the idle
|
||||
march erodes the exposure over time), and a tool you expanded, scrolled far
|
||||
away from, then returned to will have re-collapsed (state is component-local;
|
||||
hoisting it to the store is queued).
|
||||
- Everything is behind `HERMES_TUI_WINDOWING` (default on, `0` = bit-exact old
|
||||
behavior) — a one-env escape hatch if anything feels off in real use.
|
||||
|
||||
*Where to verify: the **tui-bench** repo's `results/` (`github.com/NousResearch/tui-bench`; every number above), the design+gates doc
|
||||
`docs/plans/opentui-transcript-windowing.md`, tests in
|
||||
`ui-opentui/src/test/window.test.ts` and `transcriptWindow.test.tsx` (the
|
||||
zero-jank invariants are literal assertions: identical scrollHeight windowed
|
||||
vs not, byte-stable frames across corrections).*
|
||||
@@ -0,0 +1,432 @@
|
||||
# OpenTUI native engine — PR documentation
|
||||
|
||||
**Branch:** `feat/opentui-native-engine` · **Base:** `origin/main` (merged in; HEAD is at `~main`)
|
||||
**New engine root:** `ui-opentui/` (Node 26 + `@opentui/core` 0.4.1 + `@opentui/solid`, Effect at the boundary)
|
||||
**Legacy engine root:** `ui-tui/` (React + the `@hermes/ink` fork at `ui-tui/packages/hermes-ink/`)
|
||||
|
||||
> This is the canonical in-repo doc for the PR. The companion interactive HTML
|
||||
> write-up (`~/projects/opentui-perf-writeup/index.html`) is the case/benchmark
|
||||
> deep-dive; this doc is the reviewable text version + the four things review
|
||||
> actually needs: **(1) the LoC reduction math, (2) the measured perf deltas,
|
||||
> (3) the real UI divergence (with screenshots), (4) the non-core / kitchen-sink
|
||||
> change audit.**
|
||||
|
||||
This PR adds a from-scratch native terminal UI built on OpenTUI, intended to
|
||||
replace the React/Ink TUI **and the Ink fork we maintain alone**. It currently
|
||||
ships as a parallel engine (Ink untouched, auto-fallback), selected by
|
||||
`HERMES_TUI_ENGINE` env > `display.tui_engine` config > auto (OpenTUI when the
|
||||
host is Node ≥ 26.3 with the built bundle, else Ink). **100% parity with the Ink
|
||||
TUI is the bar.**
|
||||
|
||||
---
|
||||
|
||||
## 1. Line-of-code reduction (the headline maintenance win)
|
||||
|
||||
All counts are **git-tracked files only** (respects `.gitignore`; `dist/` and
|
||||
`node_modules/` are untracked and excluded). Measured live on this branch at
|
||||
`~HEAD`. "Code" = `.ts/.tsx/.js/.jsx` only; "total" includes config/json/md.
|
||||
|
||||
### What gets *removed* when Ink is retired
|
||||
|
||||
| Area | Files | Total lines | Code lines (ts/tsx/js) | Non-blank code |
|
||||
|---|---:|---:|---:|---:|
|
||||
| `ui-tui/src/` — Ink **consumer app** (our React/Ink view code) | 204 | 40,422 | 40,422 | 33,550 |
|
||||
| `ui-tui/packages/hermes-ink/` — **the fork** (`@hermes/ink`) | 148 | 28,167 | 28,113 | 23,718 |
|
||||
| **`ui-tui/` whole tree (tracked)** | **362** | **69,320** | **68,831** | **57,545** |
|
||||
|
||||
The `ui-tui/` whole-tree number (69,320) also folds in a handful of build
|
||||
scripts, `.prettierrc`, `package.json`, etc. The two rows above it are the
|
||||
load-bearing split:
|
||||
|
||||
- **The fork alone is 28,167 LOC across 148 files** — code we own and can never
|
||||
sync from upstream. Upstream Ink v6.8.0 `src/` is ~7,259 LOC, so the fork's
|
||||
renderer core is **~3.2× the size of stock Ink**. (Cross-checked against the
|
||||
HTML write-up's `ink-fork-analysis.json`: 28,111 LOC / 148 files — the 56-line
|
||||
delta is a single tracked JSON the file-level count includes.)
|
||||
- **The consumer app is another 40,422 LOC** — React components/hooks that only
|
||||
exist to drive Ink.
|
||||
|
||||
### What gets *added*
|
||||
|
||||
| Area | Files | Total lines | Code lines | Non-blank code |
|
||||
|---|---:|---:|---:|---:|
|
||||
| `ui-opentui/src/` — new engine (app code **+ its own tests**) | 153 | 28,763 | 28,763 | 26,495 |
|
||||
| ↳ non-test (app code only) | 97 | 16,628 | 16,628 | 15,450 |
|
||||
| ↳ tests (`src/test/`) | 56 | 12,135 | 12,135 | 11,045 |
|
||||
| Tree-sitter grammars (`python`…`toml`) | 0 | 0 | 0 | 0 |
|
||||
| **`ui-opentui/` whole tree (tracked)** | **~170** | **~34,800** | **29,614** | **27,283** |
|
||||
|
||||
> Tree-sitter grammars carry **zero repo lines**: the engine declares the 10
|
||||
> extra grammars as remote URLs (`src/boundary/parsers.manifest.json`) and
|
||||
> OpenTUI fetches+caches each `.wasm`/`.scm` on first use into
|
||||
> `~/.hermes/cache/opentui-parsers/` (à la opencode, which vendors none). An
|
||||
> earlier revision vendored them as 37,302 checked-in binary lines (10 `.wasm` +
|
||||
> 10 `.scm`); that's gone — code lines and total lines now move together.
|
||||
|
||||
### The net reduction (code lines, the honest comparison)
|
||||
|
||||
| Comparison | Removed (ts/tsx/js) | Added (ts/tsx/js) | Net change |
|
||||
|---|---:|---:|---:|
|
||||
| **Incl. fork** — retire all of `ui-tui/` vs add `ui-opentui/src` | −68,831 | +28,763 | **−40,068 LOC (−58%)** |
|
||||
| **Incl. fork, app-vs-app** (exclude both test suites) | −56,463¹ | +16,628 | **−39,835 LOC (−71%)** |
|
||||
| **Excl. fork** — only the Ink *consumer app* vs new engine | −40,422 | +28,763 | **−11,659 LOC (−29%)** |
|
||||
| **The fork in isolation** (the unsyncable liability we shed) | −28,113 | — | **−28,113 code lines deleted outright (28,167 incl. its 1 config file)** |
|
||||
|
||||
¹ `ui-tui/src` non-test = 28,350 LOC + fork (≈ all 28,113 code lines are non-test;
|
||||
it carries only ~54 config lines) = 56,463. (`ui-tui/src` carries 80 test files /
|
||||
12,072 LOC; the new engine carries 56 test files / 12,135 LOC.)
|
||||
|
||||
**Read it this way:**
|
||||
|
||||
- **The cleanest single number: ~−40k code lines net** (retire all of `ui-tui/`,
|
||||
add `ui-opentui/src`). That is a **~58% reduction in the TUI's
|
||||
hand-maintained surface**, and it *includes* the new engine's full 56-file test
|
||||
suite.
|
||||
- **The most important number is the fork: −28,167 LOC of unsyncable engine
|
||||
code** disappears. That is the load-bearing maintenance win — it's not just
|
||||
fewer lines, it's lines we are the *sole* maintainer of (own reconciler, ANSI
|
||||
parser, scrollbox, selection/OSC52, hand-rolled memory eviction, Yoga binding).
|
||||
- **Even excluding the fork** — i.e. if you imagine upstream Ink were free — the
|
||||
app rewrite is still a net reduction (−11,659 LOC) because the new engine
|
||||
mounts OpenTUI built-ins instead of hand-building components.
|
||||
|
||||
### Caveat on the comparison (keep it honest for review)
|
||||
|
||||
- These are **whole-tree retirements vs a single source dir add.** If/when Ink is
|
||||
deleted, the `ui-tui/` `package.json`, lockfile, and build scripts go too; the
|
||||
table counts `ui-tui/src` + the fork as the apples-to-apples "hand-maintained
|
||||
TS" figure.
|
||||
- **Tree-sitter grammars are NOT vendored.** The 10 extra grammars are declared
|
||||
as remote URLs (`src/boundary/parsers.manifest.json`); OpenTUI fetches each
|
||||
`.wasm`/`.scm` on first use of a language and caches it under
|
||||
`~/.hermes/cache/opentui-parsers/` (profile-aware, set via
|
||||
`HERMES_TUI_PARSER_CACHE` by the launcher). Registration does **zero** network;
|
||||
the fetch is lazy and off the boot critical path, and an unreachable
|
||||
GitHub/air-gapped env degrades that language to plain text — never a throw. This
|
||||
replaces an earlier revision that vendored 37k binary lines, so the repo no
|
||||
longer grows on disk for syntax highlighting. (Trade-off: first-use-per-language
|
||||
needs network to `github.com`/`raw.githubusercontent.com`; pre-seed the cache in
|
||||
a Docker build if you need offline highlighting.)
|
||||
- Python/backend LoC is **not** part of this reduction: `tui_gateway/` (~12k LOC)
|
||||
is **shared by both engines** and stays. See §4.
|
||||
|
||||
---
|
||||
|
||||
## 2. Performance (CPU / latency / memory)
|
||||
|
||||
Measured with the `tui-bench` harness driving **both engines on a real PTY
|
||||
120×40**, fake gateway feeding deterministic events, `/proc`-sampled identically,
|
||||
each SUT under `systemd-run --scope -p MemoryMax=2G -p MemorySwapMax=0`,
|
||||
sequential with a load-gate + 10s cooldown. Determinism gate **GREEN**, 71 result
|
||||
files, 0 cell errors, 3 reps/cell, `@opentui/core` 0.4.1 native-yoga
|
||||
(`libopentui.so`, no `yoga.wasm`). Every number traces to a `summary.<field>` in
|
||||
a result dir. Source: `~/projects/opentui-html/bench-numbers.json` (frozen
|
||||
2026-06-14, build under test `1ddf7a102` + WIP).
|
||||
|
||||
### Scorecard
|
||||
|
||||
| Dimension | Winner | Margin | Source cell |
|
||||
|---|---|---|---|
|
||||
| Streaming frame rate | **OpenTUI** | **~3×** (43 vs 14 fps) | `cpu800.frame_pacing` |
|
||||
| Streaming smoothness (interframe p95) | **OpenTUI** | **40ms vs ~220ms** (no ¼-second stalls) | `cpu800.frame_pacing` |
|
||||
| Scroll CPU | **OpenTUI** | **~2.7× cheaper** (134–155 vs 403–416 ticks) | `scroll3000.scroll.cpu_ticks` |
|
||||
| Cold-start floor | **OpenTUI** | ~97–103 vs ~107–109 MB | `startup.vmhwm_kb` |
|
||||
| Session-create latency | **OpenTUI** | ~151–177 vs ~204–229 ms | `startup.session_create_ms` |
|
||||
| First-byte paint | Ink | ~93 vs ~122 ms | `startup.first_byte_ms` |
|
||||
| Memory @ small/typical | Ink | OpenTUI +30–50 MB | `mem50/100/300.vmhwm` |
|
||||
| Memory @ heavy tool output | **OpenTUI** | **crossover** (258–265 vs 280–290 MB) | `results-fat-mem-*` |
|
||||
| Layout reflow latency | **Ink** | **~0ms vs ~13ms** (OpenTUI's one honest loss) | `resize3000.resize.reflow_ms` |
|
||||
|
||||
### The honest reading
|
||||
|
||||
- **OpenTUI wins everything you feel continuously** — frame rate (~3×), scroll
|
||||
CPU (~2.7×), and smoothness (no 200ms hitches; p95 40ms vs ~220ms). This is the
|
||||
lead. The single most user-perceptible difference is the stall-free stream.
|
||||
- **Memory: lead with smoothness, not raw RSS.** Ink is lighter at small/typical
|
||||
sizes (OpenTUI carries a ~102 MB irreducible Node+V8+`libopentui.so` floor, so
|
||||
it sits +30–50 MB above Ink there). But it **crosses over** under heavy tool
|
||||
output (mem300: 258–265 MB OpenTUI vs 280–290 MB Ink) because windowing beats
|
||||
Ink's mount-every-row. Real-world: 20 memwatch sessions show a flat ~108 MB
|
||||
floor and ~0 MB/h on long sessions (one 15h session, 0 MB/h; one 4.4h session
|
||||
plateaus flat at ~237 MB with mounted rows pinned at 33).
|
||||
- **The one outright loss is layout reflow** (~13ms p50 vs Ink's ~0ms; under a
|
||||
resize storm OpenTUI degrades to ~14fps/~197ms vs Ink ~26fps/~100ms). Heavier
|
||||
native renderables vs Ink's string nodes. This is a real, quantified
|
||||
optimization target — **not** a regression vs current behavior, and **not** the
|
||||
"halved 0.4.0→0.4.1" delta (we measured the absolute 12–15ms only; do not quote
|
||||
"halved" from this run).
|
||||
- **The memory fix is engine-agnostic** — a rolling display cap
|
||||
(`HERMES_TUI_MAX_MESSAGES=3000` default) that is display-only and never touches
|
||||
the model's context. Uncapped is a stress config, not real usage (10k msgs
|
||||
uncapped: 793 MB; capped sessions are flat MB/h).
|
||||
- **Gut-check vs upstream/opencode: no bugs.** Exactly one frame callback
|
||||
(early-exits cheaply), zero `writeToScrollback` for the transcript (one sticky
|
||||
`<scrollbox>` + reactive `<For>`), native `<markdown streaming>` byte-for-byte
|
||||
parity with live opencode, no reactive-read-outside-tracking-scope (the #1 Solid
|
||||
trap). Source: `docs/plans/opentui-gutcheck-verification.md`.
|
||||
|
||||
Full methodology + every cell: see the HTML write-up's benchmark sections and
|
||||
`docs/plans/opentui-endgame-benchmark-report.md`.
|
||||
|
||||
---
|
||||
|
||||
## 3. UI parity — and where the two engines genuinely diverge visually
|
||||
|
||||
100% *feature* parity is the bar (matrix in §6), but the two engines are **not**
|
||||
visually identical. The Ink TUI renders the transcript as a **box-drawing tree**;
|
||||
OpenTUI renders it **flat and marker-based**. This is a deliberate design
|
||||
divergence, captured in `ui-opentui/src/view/messageLine.tsx`:
|
||||
|
||||
> *"the view is a dark room and gold is the single lamp — it sits on the NEWEST
|
||||
> answer's `⚕` and the user's `❯`, nowhere else (older assistant glyphs demote to
|
||||
> grey: they merely happened)."*
|
||||
|
||||
Real screenshots (saved under `docs/research/opentui-screenshots/`), captured live
|
||||
on a real PTY 120×40 via the `tmux-pane-screenshot` workflow — **same session
|
||||
resumed in both engines** where possible.
|
||||
|
||||
### Legacy Ink — `docs/research/opentui-screenshots/ink-transcript.png`
|
||||
|
||||

|
||||
|
||||
- **Box-drawing tree layout.** Each turn is a nested structure: `└─ Response`,
|
||||
`└─ ▾ Tool calls (1)`, ` └─ ● Terminal("…")` — explicit corner rails and
|
||||
disclosure triangles.
|
||||
- **`┊` dotted quote-bar** prefixes assistant prose.
|
||||
- **Tool calls collapse by default** behind a `▾ Tool calls (N)` disclosure,
|
||||
nested one rail deeper.
|
||||
- **Whole assistant message tinted gold/amber** (body text is colored, not just
|
||||
the marker).
|
||||
- Right-edge scrollbar: thin `│` track + `┃`/orange thumb.
|
||||
- Status bar: `─ ready │ opus 4.8 fast high │ 0/1m │ [░░░░░░] 0% │ 25s │ voice off │ 1 session ─ ~`
|
||||
— leading dash, pipe-delimited fields, trailing `~`.
|
||||
- **No top header bar.**
|
||||
|
||||
### New OpenTUI — `docs/research/opentui-screenshots/opentui-transcript.png` (+ `opentui-toolcall.png`)
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
- **Flat, marker-based layout.** No tree rails. Assistant = `⚕` (caduceus, gold
|
||||
only on the newest answer), user = `❯` (gold chevron + gold text). Older
|
||||
assistant glyphs demote to grey.
|
||||
- **Neutral body text.** Gold is reserved for markers and inline-code accents;
|
||||
prose is grey/white (the "single lamp" rule), so the screen reads calmer than
|
||||
Ink's all-amber blocks.
|
||||
- **Tool calls render inline, expanded, on one header line:**
|
||||
`⚕ ▶ delegate_task Run the shell command `…` (/agents to monitor) · 41s (11 lines)`
|
||||
— marker, `▶` collapse triangle, bold tool name, grey arg preview, hint,
|
||||
`· duration`, `(N lines)` — and the result flows flat directly below (no nesting
|
||||
rail). Per-tool renderers exist (`view/tools/registry.tsx`) — bash/file+diff/
|
||||
read/search/skill/clarify/todo each render differently, not a uniform dump.
|
||||
- **Per-block `⧉ copy` affordance** on a quiet footer line under every settled
|
||||
assistant block and user prompt (click → copies that block's source).
|
||||
- **Top header bar:** `⚕ Hermes Agent · opentui · ready` + a gold horizontal rule
|
||||
(Ink has none).
|
||||
- Status bar (real backend): `● claude-fable-5 │ [▒▒▒] 4% │ …/lively-thrush/hermes-agent (feat/opentui-native-engine)`
|
||||
— green status dot, model, context/token bar, **right-pinned cwd + branch**.
|
||||
|
||||
### Divergence summary table
|
||||
|
||||
| Aspect | Ink (legacy) | OpenTUI (new) |
|
||||
|---|---|---|
|
||||
| Transcript structure | Box-drawing **tree** (`└─`, rails) | **Flat**, indented, marker-based |
|
||||
| Assistant marker | `└─ Response` rail + `┊` quote-bar | `⚕` caduceus glyph |
|
||||
| User marker | (rail) | `❯` gold chevron |
|
||||
| Assistant body color | Tinted gold/amber | Neutral grey/white (gold = accents only) |
|
||||
| Tool calls | Collapsed `▾ Tool calls (N)`, nested | Inline expanded header + flat result |
|
||||
| Per-tool rendering | Largely uniform | Dedicated renderers per tool |
|
||||
| Copy affordance | `/copy` command | `/copy` **+ per-block `⧉ copy`** |
|
||||
| Header bar | None | `⚕ Hermes Agent · opentui · ready` + rule |
|
||||
| Status bar | `─`/`│`-delimited, trailing `~` | dot + bars + right-pinned cwd/branch |
|
||||
|
||||
**For review:** the divergence is intentional (a design pass, not an accident),
|
||||
but it means "drop-in replacement" is true at the *feature* level, not the
|
||||
*pixel* level. A user switching engines will immediately notice the flatter,
|
||||
calmer transcript. Worth calling out explicitly so the swap isn't sold as
|
||||
visually invisible.
|
||||
|
||||
---
|
||||
|
||||
## 4. Non-core / kitchen-sink change audit (what review should scrutinize)
|
||||
|
||||
Full report: **`docs/research/opentui-noncore-change-audit.md`** (file-by-file,
|
||||
commit-by-commit, with `file:line` evidence). Summary below.
|
||||
|
||||
This PR's net footprint vs `origin/main` (two-dot diff = exactly this PR's adds,
|
||||
no main work re-included):
|
||||
|
||||
| Bucket | Files | Net diff |
|
||||
|---|---:|---:|
|
||||
| UI (`ui-opentui/`, the engine + tests) | 197 | +36,001 / −1 |
|
||||
| Docs | 8 | +1,164 / −0 |
|
||||
| **Other (the review-flag surface)** | **28** | **+3,218 / −204** |
|
||||
|
||||
The 28 "other" files are the only place this PR touches shared Hermes core. They
|
||||
classify as:
|
||||
|
||||
### ✅ CORE-OPENTUI-NECESSARY (the engine can't work without these; Ink path provably untouched)
|
||||
|
||||
- **`hermes_cli/main.py`** (+382/−5) — dual-engine launcher (engine resolution,
|
||||
Node 26 / fnm detection, `_make_opentui_argv`, heap override). Default falls
|
||||
back to Ink unless the host is OpenTUI-ready (`main.py:1685`); OpenTUI is
|
||||
dispatched *around* the Ink bootstrap, never through it (`main.py:1914-1922`).
|
||||
- **`scripts/install.sh`** (+78/−1) — `install_opentui` stage, **strictly
|
||||
best-effort** (every failure returns 0; falls back to Ink; Windows/Termux
|
||||
skipped). Ink install path unchanged.
|
||||
- **`Dockerfile`** (+21/−11) — Node 22→**26** bump (required by the `node:ffi`
|
||||
renderer) + `ui-opentui` build step. Opt-in; Ink build line preserved. **Caveat:
|
||||
the Node major bump affects the whole image (Ink + web + Playwright)** — the
|
||||
diff self-flags "verify the full image build on Node 26 in CI."
|
||||
- **`hermes_cli/_parser.py`** (+16/−2) — bare `--resume` → OpenTUI session picker;
|
||||
`--resume <id>` unchanged.
|
||||
- **`tui_gateway/server.py`** (+612/−40) — predominantly opt-in RPCs/fields the
|
||||
new engine calls (`session.peek`, `session.list` filters, `startup.catalog`,
|
||||
`diff_unified`, window-title, skin keys). Each is gated so **the Ink path is
|
||||
byte-for-byte unchanged** (`server.py:3930`, `:4254`, `:10447`). *Note:* this
|
||||
file also carries some of the cost-accounting code (below) — separable.
|
||||
|
||||
> `tui_gateway/` (~12k LOC Python) is **shared by both engines** and is **not**
|
||||
> removed when Ink is retired. Only the `ui-tui/` frontend tree goes.
|
||||
|
||||
### 🚩 FLAG FOR REVIEW — Category C, separable from an OpenTUI PR
|
||||
|
||||
These do **not** need to ship with the engine and a reviewer should ask to split
|
||||
them out:
|
||||
|
||||
1. **Provider-reported-cost accounting** (commits `85546bb9e` + `364b93a4b` +
|
||||
`e01b04de4`) — a coherent feature spanning **11 files**: `agent/usage_pricing.py`,
|
||||
`plugins/model-providers/openrouter/__init__.py`,
|
||||
`agent/transports/chat_completions.py`, `agent/agent_init.py`, `run_agent.py`,
|
||||
`agent/conversation_loop.py`, `agent/account_usage.py`, `hermes_state.py`,
|
||||
`gateway/slash_commands.py`, the cost half of `cli.py`, and the
|
||||
`_get_usage`/`_compact_usage_text` blocks of `tui_gateway/server.py` (+ 5 test
|
||||
files). Strongest evidence: commit `85546bb9e` *"gateway: capture real
|
||||
provider-reported cost (openrouter usage accounting)"* — a provider-accounting
|
||||
rework, not a renderer.
|
||||
2. **`plugins/model-providers/openrouter/__init__.py`** — sends
|
||||
`usage:{include:true}`, a provider request-shape change affecting *all*
|
||||
interfaces, not just the TUI (`openrouter/__init__.py:85-90` cites the
|
||||
OpenRouter usage-accounting docs).
|
||||
3. **Worktree lock / dirty-tree preservation** (commit `94765e48f`,
|
||||
`cli.py` + `tests/cli/test_worktree.py`, ~145 lines) — git-worktree lifecycle
|
||||
safety plumbing with **zero TUI references** (`cli.py:1391-1545`, `:1635-1713`).
|
||||
4. **`tools/clarify_tool.py`** (+16/−4) — docstring/schema-description-only fix
|
||||
(commit `16e408f3f`); applies to every interface, trivially separable.
|
||||
|
||||
### ✅ Conversation-loop / role-alternation / prompt-cache correctness verdict: **NO RISK**
|
||||
|
||||
Verified: none of `run_agent.py`, `agent/conversation_loop.py`,
|
||||
`agent/agent_init.py`, `agent/transports/chat_completions.py` touch
|
||||
message-role alternation or the prompt-cache prefix. The
|
||||
`conversation_loop.py` added lines grep clean for
|
||||
`cache_control|alternation|prompt_cach|api_messages`; the cache/alternation
|
||||
machinery (`:57`, `:660-674`, `:759`) is untouched; the PR's insertion at
|
||||
`:1809-1879` is purely additive cost bookkeeping after `cost_result`. **Prompt
|
||||
caching and strict role alternation are preserved.**
|
||||
|
||||
---
|
||||
|
||||
## 5. What this does and does NOT fix
|
||||
|
||||
**Fixes (structurally, by replacing the rendering substrate):** the renderer bug
|
||||
class — layout/scroll/input/copy/mouse/markdown/resize — plus the
|
||||
hand-maintained memory-eviction problem (windowing + Solid keyed `<For>`
|
||||
unmount→`destroy()`→`free()`), and several long-open feature requests (mouse,
|
||||
collapsible tool calls, session title/status bar, double-ESC, chronological
|
||||
thinking/tool ordering).
|
||||
|
||||
**Does NOT fix:** the gateway is unchanged — the biggest single hotspot file in
|
||||
triage is `tui_gateway/server.py`, and whole bug clusters are gateway/Python-side
|
||||
(WS write-timeout/RPC pool, MCP-failure startup freezes, shell.exec denylist).
|
||||
The engine swap addresses rendering/input/scroll/memory; **gateway bugs ride
|
||||
along.** The Effect-boundary hardening does make those failures *visible* (typed
|
||||
events → system lines instead of a frozen spinner) and the TUI auto-heals
|
||||
(crash → backoff → respawn → resume, capped 3/60s).
|
||||
|
||||
---
|
||||
|
||||
## 6. Feature parity matrix (vs the Ink TUI)
|
||||
|
||||
Verbatim, detailed, surface-by-surface with `file:line` evidence:
|
||||
**`docs/plans/opentui-ink-parity-matrix.md`** (interactive/filterable version in
|
||||
the HTML write-up). Headline state:
|
||||
|
||||
| Surface | State |
|
||||
|---|---|
|
||||
| Transcript rendering (scrollbox, markdown, code, diffs, collapsible tools, reasoning, chronological order, windowing) | **full parity (9/9)** |
|
||||
| Blocking prompts (approval/clarify/sudo/secret/confirm) | **full parity (5/5)** |
|
||||
| Theming (skins, light/dark, ANSI-256 norm) | **full parity** |
|
||||
| Mouse / copy (tracking, selection, multi-click, OSC52, click-to-expand, wheel accel) | **full parity** |
|
||||
| Resilience (crash auto-heal + resume) | **parity++ (exponential backoff)** |
|
||||
| Composer / input | near parity — **missing: external editor (Ctrl+G → `$EDITOR`)**; ghost-text autosuggest partial |
|
||||
| Slash commands | core parity — **missing: `/setup`, `/redraw`, `/plugins`, `/voice`**; `/undo` prefill + `/image` partial |
|
||||
| Status bar / header chrome | almost all closed — **missing: MCP-servers panel, profile-in-prompt** |
|
||||
| Agent surfaces | most shipped — **missing: voice indicators, browser/CDP indicator** |
|
||||
| Utility commands | **missing: `/redraw`, `/setup`**; rest present |
|
||||
|
||||
> The original PR-draft gap list was **substantially stale** — the WIP since
|
||||
> shipped context %/token bar, cost, compressions, duration, update banner, todos
|
||||
> panel, activity feed, notifications, background-task indicator, **and per-tool
|
||||
> renderers** (the "every tool renders the same" claim is false:
|
||||
> `view/tools/registry.tsx` has dedicated renderers).
|
||||
|
||||
### Genuinely-remaining parity gaps
|
||||
|
||||
- [ ] **External editor (Ctrl+G → `$EDITOR`)** — highest-impact missing composer affordance
|
||||
- [ ] MCP-servers detail panel; profile-in-prompt marker
|
||||
- [ ] Voice indicators (listening/transcribing/REC/STT) + `/voice`
|
||||
- [ ] Browser/CDP connection indicator + `/browser`
|
||||
- [ ] `/setup` wizard handoff, `/redraw`, `/plugins` hub
|
||||
- [ ] Draggable scrollbar; sticky-prompt line
|
||||
- [ ] `/undo` prefill into composer; model-picker persist-global toggle; skills-hub install/manage
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollout, runtime & risks
|
||||
|
||||
- **Runtime:** plain Node 26 (FFI floor 26.3+) — one runtime, no Bun. (Note: the
|
||||
upstream OpenTUI docs say "requires Bun"; this engine deliberately runs on Node
|
||||
26's experimental `node:ffi` instead — that's the load-bearing runtime decision.)
|
||||
- **Rollback:** Ink is untouched and remains the fallback; reverting is a launcher
|
||||
decision, not a code revert.
|
||||
- **Default-engine selection:** auto-picks OpenTUI only when the host is genuinely
|
||||
set up (Node ≥ 26.3 + built bundle), else Ink; explicit env/config bypasses the
|
||||
probe.
|
||||
- **Known sharp edges:** `libopentui.so` native-lib distribution (P1 upstream:
|
||||
copies can fill `/tmp`); the Dockerfile Node major bump needs full-image CI
|
||||
verification; tree-sitter grammars are fetched from GitHub on first use and
|
||||
cached in `~/.hermes/cache/opentui-parsers/` — air-gapped hosts get plain-text
|
||||
highlighting until the cache is pre-seeded (the fetch never blocks boot and
|
||||
never throws).
|
||||
|
||||
## 8. Try it
|
||||
|
||||
```bash
|
||||
hermes # auto-selects OpenTUI when the host supports it
|
||||
HERMES_TUI_ENGINE=opentui hermes # force the native engine
|
||||
HERMES_TUI_ENGINE=ink hermes # force the legacy Ink engine
|
||||
# preview standalone (no backend), Node 26:
|
||||
cd ui-opentui && npm install
|
||||
node scripts/build.mjs scripts/demo.tsx .demo
|
||||
DEMO_TOTAL=120 HERMES_TUI_MAX_MESSAGES=80 \
|
||||
node --experimental-ffi --no-warnings .demo/demo.js # inside a TTY
|
||||
```
|
||||
|
||||
Requires Node 26.3+. On older Node / Windows / Termux it auto-falls-back to Ink.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — source-of-truth files in this repo
|
||||
|
||||
| Topic | File |
|
||||
|---|---|
|
||||
| Non-core change audit (full) | `docs/research/opentui-noncore-change-audit.md` |
|
||||
| Feature parity matrix (verbatim) | `docs/plans/opentui-ink-parity-matrix.md` |
|
||||
| Benchmark report | `docs/plans/opentui-endgame-benchmark-report.md` |
|
||||
| Gut-check verification | `docs/plans/opentui-gutcheck-verification.md` |
|
||||
| Ink↔OpenTUI capture asymmetry | `docs/plans/opentui-ink-asymmetry-note.md` |
|
||||
| UI screenshots | `docs/research/opentui-screenshots/{ink,opentui}-*.png` |
|
||||
| PR description (prose) | `docs/pr-description-main-doc.md` |
|
||||
| Interactive write-up | `~/projects/opentui-perf-writeup/index.html` (out-of-repo) |
|
||||
@@ -0,0 +1,73 @@
|
||||
# Upstream alignment — how we inherit OpenTUI's performance work for free
|
||||
|
||||
Context (maintainer, 2026-06-11): opencode's 100-message cap was a November-era
|
||||
performance workaround, since obsoleted; the **next OpenTUI version ships
|
||||
native yoga** (≥2× layout performance, more improvements building on it);
|
||||
opencode does not use virtualization.
|
||||
|
||||
## The invariant that makes alignment free
|
||||
|
||||
**We are forkless and public-API-only.** The windowing layer (S1+S2) drives the
|
||||
STOCK `<scrollbox>` through documented surface only — `onSizeChange`,
|
||||
`setFrameCallback`, `scrollTop`/`viewport`/`scrollHeight`, Solid `<Show>`
|
||||
mount/unmount. Zero patches to `@opentui/core`. Every upstream release
|
||||
therefore drops in by bumping three pinned versions in `ui-opentui/package.json`
|
||||
(`@opentui/{core,keymap,solid}`, currently 0.4.0). Keep it that way: any new
|
||||
code that needs core behavior goes through a `boundary/` wrapper, never a
|
||||
patched dependency.
|
||||
|
||||
## What native yoga changes for us (and what it doesn't)
|
||||
|
||||
- **Kills the WASM ratchet** (grow-only linear memory → freeable native
|
||||
allocations). This retro-justifies S2 less, but S2's append-time windowing
|
||||
remains correct: transient mounted peaks still cost handles and RSS.
|
||||
- **Does NOT obsolete windowing.** The binding constraint is the 65,535-slot
|
||||
native handle table: ~47 handles/row × 3,000 stored rows ≈ 141k handles —
|
||||
over the table at ANY layout speed. Windowing is what makes the 3,000-row
|
||||
scrollback possible; yoga's backend is irrelevant to that math.
|
||||
- **Makes windowing feel even better**: 2× layout = cheaper margin remounts =
|
||||
smaller window margins viable and less exposure for the one accepted limit
|
||||
(estimate-height snap under scrollbar jumps). After the bump, re-tune margin/
|
||||
hysteresis against the scroll cell.
|
||||
|
||||
## The shim ledger (delete-on-upstream-fix; all in `ui-opentui/src/boundary/`)
|
||||
|
||||
| shim | what it papers over | delete when |
|
||||
|---|---|---|
|
||||
| `ffiSafe.ts` | u32 draw coords go negative under Node FFI (Bun silently wraps) — ERR_INVALID_ARG_VALUE loop | upstream clamps, or Node FFI path is officially supported |
|
||||
| `nativeHandles.ts` | SyntaxStyle exhaustion crashes mid-mount; degrade-to-unstyled | handle table widened (INDEX_BITS>16) or per-kind tables |
|
||||
| `renderer.ts` exit-signal guard | core 0.4.0 treats SIGPIPE (clipboard spawn) as an exit signal; its own uncaughtException handler allocates a handle and dies (exit-7 masking) | both fixed upstream |
|
||||
| `clipboard.ts` hardening | same SIGPIPE incident class | with the above |
|
||||
|
||||
Each is (a) isolated, (b) inert if upstream fixes the behavior, (c) worth
|
||||
reporting upstream — four concrete, reproduced, root-caused issues. Filing them
|
||||
is the cheapest alignment lever we have: it converts our workarounds into
|
||||
upstream regression tests. (Needs glitch's go-ahead — public repo activity.)
|
||||
|
||||
## The upgrade playbook (per upstream release)
|
||||
|
||||
1. Branch `chore/opentui-X.Y.Z`, bump the three pins, `npm ci`.
|
||||
2. `npm run check` (648 tests; the windowing invariants — identical
|
||||
scrollHeight ON/OFF, byte-stable frames across corrections — are literal
|
||||
assertions and will catch behavioral drift).
|
||||
3. Bench acceptance, sequential: `--cell gate` (determinism digest; EXPECT a
|
||||
new digest if upstream changed rendering — eyeball the frame, re-bless),
|
||||
`--cell mem3000 --msgs 2000` + `--cell scroll --msgs 3000` vs current
|
||||
numbers (300–375MB / p99 6–8ms), `--cell pipeline` (frame pacing ≥22fps).
|
||||
4. Shim audit: try each boundary shim OFF; delete the ones upstream fixed.
|
||||
5. Live tmux smoke (scroll sweep / resize / selection-copy), screenshots.
|
||||
6. Windowing re-tune if layout got faster: margins up or hysteresis down,
|
||||
re-run scroll cell, keep p99 ≤ 17ms gate.
|
||||
|
||||
The bench suite IS the upgrade contract — it's exactly the harness that lets
|
||||
us take every upstream improvement within a day of release, with proof.
|
||||
|
||||
## Questions worth relaying to the maintainer
|
||||
|
||||
1. Any plan to widen the 16-bit native handle table (or split per-kind)?
|
||||
That's our hard ceiling, independent of yoga.
|
||||
2. Is the Node `--experimental-ffi` path on their support radar, or Bun-only?
|
||||
(Native yoga adds new FFI surface; we run Node.)
|
||||
3. Would they take the windowing layer's core-agnostic pieces (exact-height
|
||||
spacer pattern, correction-legality rule) as a documented recipe or
|
||||
framework-level utility? We have it production-shaped with tests.
|
||||
@@ -0,0 +1,150 @@
|
||||
# OpenTUI — Background Activity: agents inspection, background panel, notifications + density
|
||||
|
||||
**Status:** SPEC (brainstormed with glitch 2026-06-13) · target branch `feat/opentui-native-engine`
|
||||
**Hard constraint:** TUI-LAYER ONLY (`ui-opentui/`). **Zero changes to `tui_gateway/server.py` or
|
||||
`run_agent.py` core.** Build only on gateway events/RPCs that already exist. Everything below was
|
||||
feasibility-checked against the live gateway surface (see "Gateway surface" §).
|
||||
|
||||
## Why
|
||||
|
||||
Dogfeedback (screenshots `iznq/qxpe/rpiw/rplj`):
|
||||
1. **Agents dashboard is too crowded** (`rplj`) — master rows dump each subagent's full multi-line
|
||||
prompt; the trace pane is squished. Inspection + transcript reading is "not great."
|
||||
2. **Background processes are basically invisible** (`qxpe`) — completions leak into the transcript
|
||||
as plain lines that read like model output; no panel, no badge, notifications are non-existent.
|
||||
3. **Input zone is too crowded** (`rpiw`) — status bar + composer + agents tray + completion menu +
|
||||
shell note stack under the transcript.
|
||||
|
||||
## Design decisions (from the brainstorm)
|
||||
|
||||
- **Two SEPARATE surfaces, ONE shared substrate.** Background *agents* (delegated subagents) and
|
||||
background *work* (detached runs + OS processes) are visually/feature-wise distinct, but share the
|
||||
underlying tracking + notification + badge plumbing.
|
||||
- **Notifications are multi-channel** on every relevant state change:
|
||||
- **(C) inline card** in the transcript — a distinct, colored, collapsed *system card*, clearly
|
||||
NOT model output (replaces today's plain-line leak).
|
||||
- **(A) ambient badge** — a live count in chrome (status-bar `bg:`/the `⚡ N agents` tray) that
|
||||
flashes on change; you pull-to-inspect. Stays visible while things run.
|
||||
- **OSC desktop** — reuse the EXISTING `boundary/termChrome.ts` (`notify`, OSC 9/99/777, already
|
||||
focus-gated so it only fires when the terminal is blurred).
|
||||
- **Agents surface = inspection only.** No foregrounding / "become the subagent" (that would change
|
||||
core subagent UX — explicitly out of scope). Scannable list + a faithful render of the *already-
|
||||
tracked* live activity (goal/model/reasoning/tool calls/progress/summary). No new fetch.
|
||||
- **Background surface = view + stop.** List runs + OS processes with status/uptime; cancel a run
|
||||
(`session.interrupt`/`subagent.interrupt`); **stop-all** OS processes (`process.stop`). Per-process
|
||||
kill and per-process logs are NOT exposed as RPCs → out of scope under the no-core rule (noted).
|
||||
- **Input density is in scope** (own phase).
|
||||
|
||||
## Gateway surface we build on (verified — all already exist)
|
||||
|
||||
| Need | Mechanism (existing) |
|
||||
|---|---|
|
||||
| Background-run lifecycle | `prompt.background` (start), `background.complete` (event) |
|
||||
| Notifications | `notification.show` / `notification.clear` events — payload `{text, level, kind, ttl_ms, key, id}` |
|
||||
| Subagent stream | `subagent.spawn_requested/start/thinking/tool/progress/complete` events (store already consumes) |
|
||||
| List OS processes | `agents.list` RPC → `{processes:[{session_id, command, status, uptime_seconds}]}` |
|
||||
| Stop OS processes | `process.stop` RPC → `kill_all()` (**all**, not per-process) |
|
||||
| Cancel a run / subagent | `session.interrupt`, `subagent.interrupt` |
|
||||
| List active sessions/runs | `session.active_list`, `session.status` |
|
||||
| Subagent trace (archived) | `spawn_tree.list/load` (already used by `/replay`) |
|
||||
| OSC desktop notify | `boundary/termChrome.ts` `notify(TermNotification)` |
|
||||
|
||||
**Honest limits (no-core constraint):** OS processes get list + stop-all only — no per-process kill
|
||||
(`process_registry.kill_process` exists but isn't an RPC) and no per-process log tail
|
||||
(`read_log` isn't an RPC). If the no-core rule is ever relaxed, each is a ~5-line additive `@method`.
|
||||
|
||||
## Architecture (Approach 1 — substrate-first)
|
||||
|
||||
```
|
||||
gateway events ──► store: backgroundActivity slice ──► derived counts/state
|
||||
│ │
|
||||
├─► notificationDispatcher ─────────┼─► (C) inline card (transcript)
|
||||
│ (card + badge + OSC) ├─► (A) ambient badge (statusBar/tray)
|
||||
│ └─► OSC via termChrome.notify
|
||||
├─► Surface 1: AgentsDashboard (revamp) — list + rich activity pane
|
||||
└─► Surface 2: BackgroundPanel (new) — runs + processes, stop
|
||||
```
|
||||
|
||||
### Shared substrate (the "underneath" both surfaces use)
|
||||
|
||||
- **`logic/backgroundActivity.ts`** (new) — pure model + reducers. Types:
|
||||
- `BackgroundRun` (from `prompt.background`/`background.complete`/`session.active_list`):
|
||||
`{ id, label, status: 'running'|'complete'|'failed'|'cancelled', startedAt, summary? }`
|
||||
- `BackgroundProcess` (from `agents.list`): `{ sessionId, command, status, uptimeSeconds }`
|
||||
- `Notification` (from `notification.show`): `{ id, key?, text, level, kind, ttlMs?, at }`
|
||||
- Pure helpers: `applyNotification`, `clearNotification(key)`, counts (`runningCount`),
|
||||
`mergeProcessList`, dedupe by `key`/`id`. Fully unit-testable (no renderer).
|
||||
- **`store.ts`** — a `backgroundActivity` slice + event handlers for `notification.show/clear`,
|
||||
`background.complete`, and a polled `agents.list` snapshot (poll only while a panel/badge is live,
|
||||
or piggyback existing cadence). Existing `subagent.*` handling is untouched.
|
||||
- **`logic/notificationDispatcher.ts`** (new, pure) — given a state-change, decide the channels:
|
||||
returns `{ card?: SystemCard, badge: delta, osc?: TermNotification }`. The boundary calls
|
||||
`termChrome.notify` for the OSC part; the store appends the card + bumps the badge.
|
||||
|
||||
### Surface 1 — Agents inspection overlay (revamp `view/overlays/agentsDashboard.tsx`)
|
||||
|
||||
- **Master list rows = ONE line each:** `<statusGlyph> <truncated goal (truncRight to width)> · <model>`.
|
||||
No multi-line prompt dump. Selected row highlighted (existing `▸` + accent).
|
||||
- **Detail pane = faithful activity transcript** of the selected agent, styled like the main
|
||||
transcript (not flat dumped lines): goal+model header, then the trace rendered by *type*
|
||||
(reasoning / tool-call+result / progress / final summary), newest last, sticky-bottom, PgUp/PgDn.
|
||||
- Requires giving `SubagentInfo.trace` light typing (`{ kind:'tool'|'reasoning'|'progress'|'summary', text }`)
|
||||
instead of `string[]`, populated where `subagent.*` events are reduced. Internal data-shape
|
||||
change only; no gateway change.
|
||||
- Keep Esc/q close, ↑↓ select. Reuse theme + `truncRight` from statusBar.
|
||||
|
||||
### Surface 2 — Background panel (new `view/overlays/backgroundPanel.tsx`)
|
||||
|
||||
- **Two sections:** *Runs* (background agent runs) and *Processes* (OS processes from `agents.list`).
|
||||
- Each row: status glyph + label/command (truncated) + uptime/elapsed + status.
|
||||
- **Actions:** `↑↓` select; on a *run* → `c` cancel (`session.interrupt`/`subagent.interrupt`);
|
||||
global **stop-all processes** (`x` → `process.stop`, confirm). Esc/q close.
|
||||
- **Access:** new client slash `/bg` (alias `/background`, `/jobs`) in `logic/slash.ts` CLIENT set →
|
||||
`store.openBackgroundPanel()`. Also reachable from the ambient badge.
|
||||
- Poll `agents.list` on open + on a light interval while open; stop polling on close.
|
||||
|
||||
### Notifications (the (C)+(A)+OSC wiring)
|
||||
|
||||
- **(C) inline card** — a new transcript element `view/notificationCard.tsx`: a bordered/colored,
|
||||
`selectable:false` system card keyed by `notification.id`, level-tinted (`info/warn/error`),
|
||||
collapsed to one line by default with the `kind` + `text`; clearable by `notification.clear` key.
|
||||
Appended into the message stream as a distinct row type (NOT a plain `system` text line). Replaces
|
||||
the current plain-line leak. (`/details` interplay: cards are chrome, always shown, never windowed.)
|
||||
- **(A) ambient badge** — `statusBar.tsx` `bg: N` segment (already reserved) bound to
|
||||
`runningCount()`; the `agentsTray.tsx` count already exists — extend it to "agents + background."
|
||||
Flash/recolor on a fresh notification (brief).
|
||||
- **OSC** — on `notification.show` with a terminal level (complete/failed), call
|
||||
`termChrome.notify({title, body})` (already focus-gated). No new escape-sequence code.
|
||||
|
||||
### Input-zone density pass (`view/composer.tsx` / `view/App.tsx`)
|
||||
|
||||
- Audit what stacks under the transcript and collapse/gate: the `⚡ N agents` tray line folds into
|
||||
the ambient badge (shrinks one line); ensure the shell-mode note, completion menu, and status bar
|
||||
don't co-stack more than necessary. Concrete rules decided with a tmux density pass (ASCII-mocked,
|
||||
approved) — kept minimal; no behavior change, just fewer competing chrome lines.
|
||||
|
||||
## Phases (implementation order — each gated + tmux-smoked + committed)
|
||||
|
||||
- **P1 — Notification substrate** (`backgroundActivity.ts` + `notificationDispatcher.ts` + store
|
||||
slice + `notificationCard.tsx` + badge wiring + OSC call). Highest visible win; the shared core.
|
||||
- **P2 — Agents inspection revamp** (`agentsDashboard.tsx` + typed `trace`). De-crowds `rplj`.
|
||||
- **P3 — Background panel** (`backgroundPanel.tsx` + `/bg` + actions). New surface.
|
||||
- **P4 — Input density pass.** Folds the tray into the badge; trims co-stacked chrome.
|
||||
|
||||
## Testing / gates (per phase)
|
||||
|
||||
- **Pure logic** (`backgroundActivity`, `notificationDispatcher`, slash `/bg` routing,
|
||||
trace-typing) → vitest unit tests, TDD where natural.
|
||||
- **Views** → headless frame tests (`renderProbe`) for the card, the de-crowded dashboard row
|
||||
format, the background panel sections; + **live tmux smoke** (`tmux-pane-screenshot`) for each
|
||||
surface using a seeded-store harness (the `uxSmoke` pattern: `store.apply`/`applyInfo`/
|
||||
`commitSnapshot` + canned events).
|
||||
- **Gate** `cd ui-opentui && npm run check` green (judge by real exit, not a piped tail) after each
|
||||
phase; rebuild `dist/main.js`; commit `opentui(v6): …` (no attribution) and push per standing instr.
|
||||
|
||||
## Out of scope (explicit)
|
||||
|
||||
- Foregrounding / "becoming" a subagent (B/C from the brainstorm) — would change core subagent UX.
|
||||
- Per-process kill + per-process log tail for OS processes — needs additive gateway RPCs (no-core veto).
|
||||
- "Collect result into transcript" for finished runs — deferred (Q6=B, view+stop only).
|
||||
- Any change to `tui_gateway/server.py` / `run_agent.py`.
|
||||
@@ -0,0 +1,248 @@
|
||||
# Plan — OpenTUI composer/UX batch (10 features)
|
||||
|
||||
> **STATUS: SHIPPED (2026-06-13).** All 10 features implemented, gate green
|
||||
> (ui-opentui 714 tests + 316 gateway + 25 cost tests), F5/F6 verified live via
|
||||
> tmux screenshot. Commits: `f4dacc68e` (F1/F2/F7/F8/F8b/F9/F10), `20d516ae9`
|
||||
> (F4/F5/F6), `9aa5e54be` (F3). Decisions taken: **D1 = cursor-aware onType**
|
||||
> (threaded `ta.cursorOffset`); **D2 = chrome cost is Nous-header-only via a new
|
||||
> `nous_header_cost_usd`, `/usage` page kept full via `real_session_cost_usd`**.
|
||||
> F10 (right-pinned cwd) was added mid-session by the user.
|
||||
|
||||
**Branch:** `feat/opentui-native-engine` · **Engine:** `ui-opentui/` (Node 26)
|
||||
**Gate:** `cd ui-opentui && PATH="$HOME/.local/share/fnm/node-versions/v26.3.0/installation/bin:$PATH" npm run check` → exit 0.
|
||||
|
||||
## TL;DR
|
||||
|
||||
Nine UX fixes for the native composer + clarify prompt. **8 of 9 are front-end-only**
|
||||
in `ui-opentui/`; only F3 (cost) touches the Python gateway. Every backend the new
|
||||
behaviour needs (`shell.exec`, `complete.path` with `@file:`/`@folder:`/fuzzy) **already
|
||||
exists** — most of this is client wiring, not new RPC surface. No new core tools, no new
|
||||
`HERMES_*` env vars, no prompt-cache impact (composer/prompt are client-render only).
|
||||
|
||||
| # | Symptom | Fix site | Backend |
|
||||
|---|---|---|---|
|
||||
| F1 | bare `/` opens the modal | `logic/slash.ts:115` `planCompletion` | none |
|
||||
| F2 | `/abs/path` text triggers slash | `logic/slash.ts:115` + `logic/skillMatch.ts` | none |
|
||||
| F3 | cost wrong / shows for non-Nous | `tui_gateway/server.py` + `agent/usage_pricing.py` | gateway |
|
||||
| F4 | can't paste until composer focused | `view/composer.tsx` onPaste/focus | none |
|
||||
| F5 | clarify ugly (no wrap, weak diff, "Other" is a row) | `view/prompts/clarifyPrompt.tsx` rewrite | none |
|
||||
| F6 | clarify arrows scroll the transcript | same rewrite (preventDefault) | none |
|
||||
| F7 | slash highlight/menu dies after line 1 | `logic/slash.ts:114` | none |
|
||||
| F8 | file mention dies after line 1 | `logic/slash.ts:114` | none |
|
||||
| F8b | `@` should be the ONLY file-mention trigger | `logic/slash.ts:93` `isPathLike` | none |
|
||||
| F9 | `!cmd` → run bash, show result | `entry/main.tsx` submit + new system render | uses existing `shell.exec` |
|
||||
|
||||
---
|
||||
|
||||
## F1 + F2 + F7 + F8 + F8b — the completion trigger (`logic/slash.ts`)
|
||||
|
||||
All five live in one ~10-line function, `planCompletion` (slash.ts:113-121). Current:
|
||||
|
||||
```ts
|
||||
export function planCompletion(text: string): CompletionPlan | null {
|
||||
if (text.includes('\n')) return null // ← F7/F8 die here
|
||||
if (text.startsWith('/')) return { from: 0, method: 'complete.slash', params: { text } } // ← F1/F2
|
||||
const word = /(\S+)$/.exec(text)?.[1]
|
||||
if (word && isPathLike(word)) { ... complete.path ... } // ← F8b: too many triggers
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
### F1/F2 — slash only for a real command token
|
||||
- A bare `/` (no char yet) must **not** query. Require `/` + at least one name char.
|
||||
- A `/abs/path` (slash followed by a path with more `/`) is **not** a command — it's
|
||||
text. The slash menu should only fire when the FIRST token matches the command
|
||||
grammar (`/[A-Za-z0-9][\w.-]*` — the `NAME_RE` already in skillMatch.ts:51, which
|
||||
excludes `/`). `/usr/bin` fails NAME_RE → no slash menu.
|
||||
- Concretely: replace `text.startsWith('/')` with: the text starts with `/`, and the
|
||||
first whitespace-delimited token after the `/` is non-empty AND matches `NAME_RE`
|
||||
(i.e. `/m`, `/model foo` → yes; `/`, `/usr/bin`, `/./x` → no). Reuse `slashTokens`
|
||||
/`NAME_RE` from skillMatch.ts so the trigger and the highlighter share one grammar.
|
||||
|
||||
### F7/F8 — completion must survive newlines (shift+enter)
|
||||
- `if (text.includes('\n')) return null` is the bug. It was a blunt guard so a multi-line
|
||||
paste wouldn't spam path-completion. The right rule operates on the **current line /
|
||||
current token at the cursor**, not the whole buffer.
|
||||
- The composer passes the full `plainText` to `onType`. We don't currently pass the
|
||||
cursor offset. **Decision D1 (below):** either (a) thread the cursor offset into
|
||||
`onType` and complete the token under the cursor, or (b) cheap interim — slice to the
|
||||
**last line** (`text.slice(text.lastIndexOf('\n')+1)`) and run the existing logic on
|
||||
that. (a) is correct (mid-buffer edits), (b) is 1 line and covers the reported case
|
||||
(typing at the end on line N). Recommend (a) for correctness; it also future-proofs
|
||||
@-mention mid-line.
|
||||
- Slash *highlighting* (skillMatch.ts `slashTokens`) **already scans multi-line text
|
||||
correctly** (it iterates the whole string, newline-aware via `nativeCharOffset`). So
|
||||
F7's "highlighting stopped" is really the same `planCompletion` newline bail starving
|
||||
the menu; the highlight token itself still styles. Verify in the live smoke.
|
||||
|
||||
### F8b — `@` is the only mention trigger
|
||||
- `isPathLike` (slash.ts:93) currently returns true for `@`, `~`, `./`, `../`, `/`, or
|
||||
any word containing `/`. The user wants **`@`-only** (drop `~`/`./`/bare paths as
|
||||
mention triggers). Narrow it to `word.startsWith('@')`.
|
||||
- The gateway `complete.path` (server.py:8543) already special-cases `@` richly
|
||||
(`@file:`, `@folder:`, `@diff`, `@staged`, `@url:`, `@git:`, fuzzy basename search).
|
||||
Its `~`/`./` branches become dead trigger paths from this TUI — leave the gateway code
|
||||
(Ink still uses the path forms; it's shared) but stop emitting those queries from
|
||||
ui-opentui. **No gateway change.**
|
||||
- Net: typing `@` (even bare) opens the mention menu via the `@`-bare branch at
|
||||
server.py:8555. Picking splices `@file:rel/path` etc. (existing accept path,
|
||||
`completionFrom` honoured).
|
||||
|
||||
**Tests:** extend `test/slash.test.ts` — `planCompletion('/')` → null; `planCompletion('/usr/bin')`
|
||||
→ null; `planCompletion('/model')` → complete.slash; multi-line `"a\n/mod"` → complete.slash
|
||||
on the trailing token; `"~/foo"` / `"./x"` → null (no longer path-like); `"@foo"` → complete.path.
|
||||
Keep them as behaviour assertions, not snapshots.
|
||||
|
||||
---
|
||||
|
||||
## F3 — cost: Nous-portal headers only (`tui_gateway` + `agent/usage_pricing.py`)
|
||||
|
||||
**Current:** `_get_usage` (server.py:2157-2167) sets `cost_usd` from
|
||||
`real_session_cost_usd(agent)` (usage_pricing.py:887), which sums **two** provider-reported
|
||||
sources:
|
||||
1. `agent.session_actual_cost_usd` — OpenRouter `usage.cost` accumulator.
|
||||
2. `agent.get_credits_spent_micros()` — Nous `x-nous-credits-*` header delta.
|
||||
|
||||
The TUI already **hides** the cost segment when `cost_usd` is absent (statusBar.tsx:241-243,
|
||||
`costText` returns '' when `costUsd === undefined`) — so this is purely "which sources count."
|
||||
|
||||
**User's intent (F3):** cost should come **only from the Nous portal headers**; suppress it
|
||||
for every other route (cache-token pricing is unreliable across the model long tail).
|
||||
|
||||
**Change:** make the OpenRouter accumulator source conditional on the route being Nous, OR
|
||||
drop source #1 entirely so only the header delta (source #2) feeds `cost_usd`. Source #2 is
|
||||
intrinsically Nous-only (the header only exists on Nous-portal responses), so dropping #1
|
||||
achieves "Nous-header-only" with one edit.
|
||||
|
||||
> **DECISION D2 (needs glitch's confirm):** Drop OpenRouter's `session_actual_cost_usd`
|
||||
> source from `real_session_cost_usd`? Trade-off: OpenRouter's `usage.cost` is itself
|
||||
> *provider-reported* (the real charged number, not a Hermes estimate), so OR users lose an
|
||||
> accurate readout. But it removes the cache-token guesswork the user is worried about and
|
||||
> matches "only via the headers when using nous portal" literally.
|
||||
> **Recommended default (implementing unless told otherwise):** gate source #1 so it only
|
||||
> contributes when the active route is the Nous portal (base_url == nous inference api),
|
||||
> else it's dropped. This keeps the segment Nous-only AND avoids touching shared OR/CLI
|
||||
> behaviour for the `/usage` page. If even Nous-route OR-accumulator is unwanted, collapse
|
||||
> to header-only.
|
||||
|
||||
**Scope guard:** `real_session_cost_usd` is also consumed by `/usage` page rendering
|
||||
(server.py:2237) and DB usage totals. Prefer a NEW, status-bar-specific helper
|
||||
(e.g. `nous_header_cost_usd(agent)`) wired only into `_get_usage`'s `cost_usd`, leaving the
|
||||
`/usage` accounting page untouched — so we don't regress the full cost report. Confirm with
|
||||
the gate + a gateway unit test (`tui_gateway` tests) that a non-Nous session yields no
|
||||
`cost_usd`.
|
||||
|
||||
---
|
||||
|
||||
## F4 — paste while composer unfocused (`view/composer.tsx`)
|
||||
|
||||
**Current:** the global keyboard handler reclaims focus on a *printable keystroke*
|
||||
(`isPrintableKey`, composer.tsx:415-417). A **bracketed-paste event is not a keystroke** —
|
||||
it arrives at `onPaste` only if the textarea is focused, so an unfocused composer drops it;
|
||||
the user has to click/type first.
|
||||
|
||||
**Fix:** the renderer delivers paste through the focused renderable. Two options:
|
||||
- (a) Keep focus on the composer more aggressively (opencode keeps the prompt focused via a
|
||||
reactive effect). Risky — fights transcript scroll focus.
|
||||
- (b) **Recommended:** handle paste at the renderer/global level. Check whether OpenTUI
|
||||
exposes a global paste hook (`renderer.on('paste')` or a keyboard event with
|
||||
`key.name === 'paste'` / a paste event type). If a global paste signal exists, on paste:
|
||||
`ta.focus()` then route the bytes into the existing `onPaste` logic (image / placeholder /
|
||||
insert). **Must verify the API in the `opentui` skill before coding** (skill_view
|
||||
references/docs). If only the focused-renderable paste exists, fall back to (a) scoped:
|
||||
refocus the composer whenever no overlay/prompt is open and focus drifted (a
|
||||
`createEffect` watching focus + `store.state.prompt`/overlay state).
|
||||
|
||||
**Verify in live smoke** (tmux + tmux-pane-screenshot): scroll the transcript to drop focus,
|
||||
then paste — text must land without a prior click.
|
||||
|
||||
---
|
||||
|
||||
## F5 + F6 — clarify prompt rewrite (`view/prompts/clarifyPrompt.tsx`)
|
||||
|
||||
Screenshot `/tmp/screenshots/SCR-20260613-iznq.png` confirms: long options run off the right
|
||||
edge (no wrap), options differ only by `▶`/`—` glyphs (no numbers, weak), and "✎ Other…" is
|
||||
a `<select>` row that *switches* to an input on Enter rather than being an inline input.
|
||||
|
||||
**Current:** one native `<select>` over `[...choices, {Other}]` (clarifyPrompt.tsx:61-75).
|
||||
Native `<select>` doesn't wrap long rows and (F6) doesn't `preventDefault` arrows, so they
|
||||
leak to the transcript scrollbox.
|
||||
|
||||
**Rewrite plan (verify renderable API in `opentui` skill first):**
|
||||
- Replace native `<select>` with a **custom keyboard-driven list** (a `For` over options +
|
||||
a `selected` signal + `useKeyboard` with `key.preventDefault()` on up/down/enter — same
|
||||
pattern the composer's `routeMenuKey` uses; F6 fixed by preventDefault so arrows never
|
||||
reach the scrollbox).
|
||||
- **Wrapping (F5):** render each option as a `<text>` that wraps to the box width (no fixed
|
||||
single-line). Indent continuation lines under the option label. Confirm `<text>` soft-wrap
|
||||
behaviour in the opentui skill (it wraps by default within a flex box of bounded width).
|
||||
- **Differentiation (F5):** number every option `1.` `2.` … (digit hotkeys optional, nice-to-
|
||||
have), and give the selected row the themed `selectionBg` + accent fg (the composer's
|
||||
`completionCurrentBg` model), not just a glyph. Number + background + accent = three signals.
|
||||
- **Inline custom answer (F5):** render the `<input>` **inside the same screen, always
|
||||
present** as the last "row" (an `Other:` labeled input), instead of an item that toggles.
|
||||
Selecting/focusing it lets the user type; Enter in it submits the free text. Keep the
|
||||
existing `clarify.respond {answer}` wiring. Arrow-down past the last choice lands on the
|
||||
input; arrow-up from the input returns to the list (focus handoff like the composer↔tray).
|
||||
- Keep Esc/Ctrl+C → cancel (clarifyPrompt.tsx:31-33).
|
||||
|
||||
**Reference:** opencode's selection/list components in `~/github/opencode/packages/tui` for
|
||||
the wrap + highlight + hotkey idiom; the composer dropdown (composer.tsx:441-458) for the
|
||||
in-repo highlight/selectable pattern.
|
||||
|
||||
**Tests:** `test/render.test.tsx`-style headless frame — long option wraps (frame contains the
|
||||
tail of a long choice on a 2nd line), selected row shows numbered + highlighted, custom input
|
||||
present in the same frame, arrow keys don't change scrollTop (assert transcript scroll
|
||||
unchanged), Enter on a choice → onAnswer(choice), Enter in input → onAnswer(typed).
|
||||
|
||||
---
|
||||
|
||||
## F9 — `!cmd` runs bash (`entry/main.tsx` + a system render)
|
||||
|
||||
**Backend exists:** `shell.exec` (server.py:10301) runs the command (30s timeout, dangerous/
|
||||
hardline-command guards, returns `{stdout, stderr, code}`).
|
||||
**Ink parity reference:** `ui-tui/src/app/useSubmission.ts:291` — `full.startsWith('!')` →
|
||||
`shellExec(full.slice(1).trim())` → appends a user line `!cmd` + a system line with output;
|
||||
the prompt glyph flips while the buffer starts with `!` (appLayout.tsx:178).
|
||||
|
||||
**Plan (ui-opentui):**
|
||||
- In the entry `submit` (main.tsx:517-520), add a branch BEFORE the slash check:
|
||||
`if (text.startsWith('!')) { runShell(text.slice(1).trim()); return }`.
|
||||
- `runShell(cmd)`: `store.pushUser('!' + cmd)` (echo the invocation in the transcript), then
|
||||
`gateway.request('shell.exec', { command: cmd })`; on resolve, `store.pushSystem` the
|
||||
combined `stdout`/`stderr` (or the error message / non-zero `code`); on reject,
|
||||
pushSystem the error. Detached `runFork` like `submitPrompt`. No session turn, no model call.
|
||||
- Empty `!` (just the bang) → no-op (or a hint), matching Ink.
|
||||
- **Optional polish (parity, not required):** flip the composer prompt glyph (or tint) while
|
||||
the buffer starts with `!`, like Ink's appLayout. Low-risk; do only if cheap.
|
||||
|
||||
**Tests:** entry-level/logic test that a `!`-prefixed submit routes to `shell.exec` (not
|
||||
`prompt.submit`), and the system line renders stdout. Mirror the slashMenu.test harness
|
||||
(fake gateway capturing the method).
|
||||
|
||||
---
|
||||
|
||||
## Sequencing & fences (subagent-driven; disjoint files)
|
||||
|
||||
Parallel-safe groups (disjoint file fences):
|
||||
1. **slash trigger** — `logic/slash.ts` (+ `logic/skillMatch.ts` reuse) + `test/slash.test.ts`. (F1/F2/F7/F8/F8b)
|
||||
2. **clarify** — `view/prompts/clarifyPrompt.tsx` + a clarify test. (F5/F6)
|
||||
3. **shell-exec** — `entry/main.tsx` (edit DIRECTLY — load-bearing) + system render + test. (F9)
|
||||
4. **paste focus** — `view/composer.tsx` (edit directly; verify opentui paste API first). (F4)
|
||||
5. **cost** — `tui_gateway/server.py` + `agent/usage_pricing.py` + gateway test. (F3) — Python, isolated.
|
||||
|
||||
`entry/main.tsx` and `store.ts` are edited directly, never via subagent (handoff rule).
|
||||
Each renderable change: `skill_view(opentui, references/docs/...)` FIRST. Verify every
|
||||
subagent self-report (re-run `npm run check` exit code, read the diff).
|
||||
|
||||
## Open decisions (need glitch)
|
||||
- **D1 (F7/F8):** thread cursor offset into `onType` (correct) vs. last-line slice (cheap)?
|
||||
Recommend cursor offset.
|
||||
- **D2 (F3):** drop OpenRouter cost source entirely, or gate it to the Nous route? Recommend
|
||||
Nous-route gate via a status-bar-only helper, leaving `/usage` accounting intact.
|
||||
|
||||
## Invariants to preserve
|
||||
- Per-conversation prompt caching untouched (all client-render or post-hoc gateway usage).
|
||||
- No new `HERMES_*` env var (these are behaviour, not secrets).
|
||||
- Strict no change-detector tests — assert behaviour/invariants.
|
||||
- Don't regress the `/usage` accounting page when narrowing the chrome cost source.
|
||||
@@ -0,0 +1,217 @@
|
||||
# OpenTUI — usage/credits notice in the composer chrome
|
||||
|
||||
**Status:** spec (not started) · **Engine:** `ui-opentui/` · **Author:** glitch · 2026-06-14
|
||||
|
||||
## Goal
|
||||
|
||||
Render the gateway's **usage / credits notices** as a persistent, level-tinted
|
||||
**chrome banner pinned at the top of the input zone** (directly above the status
|
||||
bar), with the same lifecycle the Ink engine already has — sticky vs TTL,
|
||||
mid-turn hold + turn-end reveal, and "flash-and-yield" for the usage bands.
|
||||
|
||||
Today the OpenTUI engine **receives** these notices but mis-renders them as
|
||||
scrolling inline transcript cards with no lifecycle. This spec fixes that without
|
||||
touching the gateway or the agent (the data already flows correctly).
|
||||
|
||||
## What already exists (verified)
|
||||
|
||||
### The wire (source of truth — do NOT change)
|
||||
The gateway emits one event for every notice, snake_case payload:
|
||||
|
||||
```
|
||||
notification.show payload { text, level, kind, ttl_ms, key, id } # tui_gateway/server.py:2878
|
||||
notification.clear payload { key } # tui_gateway/server.py:2890
|
||||
```
|
||||
|
||||
These come from `AgentNotice` (`agent/credits_tracker.py:177`). The credits
|
||||
policy (`evaluate_credits_notices`, `agent/credits_tracker.py:245`) emits exactly
|
||||
four notices — the full catalog this feature renders:
|
||||
|
||||
| `key` | `text` (already glyphed by policy) | `level` | `kind` | `ttl_ms` | lifecycle |
|
||||
|-----------------------|-------------------------------------------------|-----------|----------|----------|----------------|
|
||||
| `credits.usage` | `⚠/• Credits N% used · $X cap` (bands 50/75/90) | info/warn | `sticky` | — | flash-and-yield |
|
||||
| `credits.grant_spent` | `• Grant spent · $X top-up left` | info | `sticky` | — | flash-and-yield |
|
||||
| `credits.depleted` | `✕ Credit access paused · run /usage for balance` | error | `sticky` | — | sticky |
|
||||
| `credits.restored` | `✓ Credit access restored` | success | `ttl` | `8000` | TTL self-expire |
|
||||
|
||||
**Load-bearing facts:**
|
||||
- `text` is **already glyphed** (⚠ • ✕ ✓) by the Python policy — the renderer
|
||||
**must not** prepend another glyph. It only tints by `level`.
|
||||
- `level` includes **`success`** (green) — a level the current OpenTUI parser
|
||||
silently drops to `info`.
|
||||
- `kind` is the **lifecycle marker** (`sticky` | `ttl`), NOT a display label.
|
||||
`id` == `key` (stable per kind, not unique per emission).
|
||||
- Notices are **reconciled**: the policy emits `to_clear` (a `notification.clear`)
|
||||
then `to_show`. A band change clears `credits.usage` then re-shows it.
|
||||
|
||||
### The Ink reference behavior (what we're matching)
|
||||
`ui-tui/src/app/turnController.ts` + `appChrome.tsx`:
|
||||
- `showNotice` (`:181`): if **busy**, hold in `pendingNotice` (latest-wins);
|
||||
if idle, apply now.
|
||||
- `applyNotice` (`:213`): set the visible notice; for `kind: 'ttl'` with
|
||||
`ttl_ms > 0`, arm a self-expiry timer (clearing any prior timer first).
|
||||
- `clearNotice(key)` (`:198`): drop the visible **and** pending notice only when
|
||||
the key matches (a stale clear must not wipe a newer notice).
|
||||
- `flushPendingNotice` (`:245`): at **turn end** (only the real end sites) apply
|
||||
the held notice — its TTL clock starts here, when it first becomes visible.
|
||||
- **Flash-and-yield** (`startMessage`, `:917`): at **turn start**, if the visible
|
||||
notice's key is `credits.usage` or `credits.grant_spent`, clear it — "show
|
||||
once, then get out of the way." `credits.depleted` and others stay sticky. The
|
||||
Python `active` latch keeps the key so it won't re-fire next turn.
|
||||
- Session reset clears all notice state so session A's notice can't bleed into B.
|
||||
- Color by level: `error→error`, `warn→warn`, `success→statusGood`,
|
||||
`info→accent` (`noticeColor`, `appChrome.tsx:192`).
|
||||
|
||||
### The OpenTUI side (what we change)
|
||||
- `notification.show` → `parseNotification` → `pushNotification` → **inline card**
|
||||
in the transcript (`store.ts:832`, `notificationCard.tsx`). All kinds, no
|
||||
lifecycle. The Option B process-completion card (`kind: 'process.complete'`)
|
||||
and `background.complete` (`kind: 'background task complete'`) also use this
|
||||
path — **they must keep working unchanged.**
|
||||
- `parseNotification` coerces `level` to `info|warn|error` only
|
||||
(`backgroundActivity.ts:48`) — drops `success`.
|
||||
- Store carries `lastNotification` (OSC seam), `bgTasks`; **no** `notice` slot.
|
||||
- Theme has `accent`, `warn`, `error`, `ok`/`statusGood`, `muted`
|
||||
(`logic/theme.ts`) — `success` maps to `statusGood`.
|
||||
- Input zone layout (`view/App.tsx:140-211`): a top-bordered column —
|
||||
`<StatusBar>` → composer `<Switch>` → `<AgentsTray>`. The new banner mounts at
|
||||
`App.tsx:144`, **directly above `<StatusBar>`** (the topmost line of the chrome).
|
||||
- Turn lifecycle hooks: `case 'message.start'` (`store.ts:779`, sets
|
||||
`info.running = true`) and `case 'message.complete'` (`store.ts:811`, sets
|
||||
`info.running = false`). `clearTranscript` (`store.ts:631`) is the reset site.
|
||||
- `Date.now()` is used freely in the store (`:877`) — `setTimeout` for TTL is fine.
|
||||
|
||||
## The one design decision: routing
|
||||
|
||||
`kind` is the discriminator. **`notification.show` with `kind === 'sticky'` or
|
||||
`kind === 'ttl'` → the new chrome-notice path; every other kind → the existing
|
||||
inline-card path, untouched.** This mirrors Ink's `Notice.kind: 'sticky' | 'ttl'`
|
||||
exactly, and the credits policy sets `kind` to one of those for all four notices,
|
||||
while the process/background cards use label-strings (`process.complete`,
|
||||
`background task complete`) that are neither — so they stay inline cards. No
|
||||
gateway change, no key-prefix sniffing.
|
||||
|
||||
**Divergence from Ink (intentional):** Ink hides the notice while busy because the
|
||||
FaceTicker shares its one status slot. OpenTUI's busy face (`StatusLine`) lives in
|
||||
the transcript area, so the banner has a **dedicated row** and stays visible
|
||||
through a turn (a depletion warning shouldn't vanish mid-turn). We still **hold
|
||||
new notices** that arrive mid-turn (`pendingNotice`) and reveal them at turn end —
|
||||
matching Ink's "don't pop a fresh banner mid-stream" intent.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Phase 1 — parser + type (`logic/backgroundActivity.ts`)
|
||||
1. Widen `ActivityNotification.level` to `'info' | 'warn' | 'error' | 'success'`.
|
||||
2. `coerceLevel`: also accept `'success'` (still fall back to `'info'`).
|
||||
3. Add `export function isChromeNotice(n: ActivityNotification): boolean` →
|
||||
`n.kind === 'sticky' || n.kind === 'ttl'`.
|
||||
4. `parseNotification` already maps `ttl_ms → ttlMs` and preserves `key`/`id` —
|
||||
no shape change beyond the widened level.
|
||||
|
||||
**Tests** (`backgroundActivity.test.ts` or `notificationCard.test.tsx`):
|
||||
`success` survives parse; `kind: 'ttl'` + `ttl_ms` → `ttlMs`; `isChromeNotice`
|
||||
true for sticky/ttl, false for `process.complete`/`''`.
|
||||
|
||||
### Phase 2 — store lifecycle (`logic/store.ts`)
|
||||
Add state + a private (non-reactive) timer handle in `createSessionStore`:
|
||||
- `notice: ActivityNotification | null` (visible chrome notice) — new state field,
|
||||
init `null`.
|
||||
- `pendingNotice: ActivityNotification | null` — held mid-turn, init `null`.
|
||||
- `let noticeTimer: ReturnType<typeof setTimeout> | undefined` (closure var).
|
||||
|
||||
Functions (port of `turnController`):
|
||||
- `showNotice(n)`: `state.info.running ? setState('pendingNotice', n) : applyNotice(n)`
|
||||
(latest-wins — assigning replaces any prior pending).
|
||||
- `applyNotice(n)`: clear `noticeTimer`; `setState('notice', n)`; if
|
||||
`n.kind === 'ttl' && n.ttlMs && n.ttlMs > 0`, arm `setTimeout(n.ttlMs)` that
|
||||
clears `notice` only if `state.notice?.id === n.id` (defensive guard).
|
||||
- `clearNotice(key)`: if `state.pendingNotice?.key === key` → null it; if
|
||||
`state.notice?.key === key` → clear timer + null `notice`.
|
||||
- `flushPendingNotice()`: if `state.pendingNotice` → `applyNotice` it, null pending.
|
||||
- `clearNoticeState()`: null `notice` + `pendingNotice`, clear timer.
|
||||
|
||||
Wire into the event reducer:
|
||||
- `notification.show` (`store.ts:832`): route —
|
||||
`const n = parseNotification(...); if (!n) break; if (isChromeNotice(n)) showNotice(n); else pushNotification(n)`.
|
||||
(Still record `lastNotification` for the OSC seam in **both** paths — extract
|
||||
the `setState('lastNotification', {...n})` so a chrome notice also pings a
|
||||
blurred terminal, matching the inline-card behavior.)
|
||||
- `notification.clear` (`store.ts:837`): call **both** `clearNotificationCards(key)`
|
||||
(cards) **and** `clearNotice(key)` (chrome) — a key only ever lives in one, so
|
||||
calling both is safe and avoids guessing.
|
||||
- `message.start` (`store.ts:779`): flash-and-yield — if
|
||||
`state.notice?.key === 'credits.usage' || === 'credits.grant_spent'` →
|
||||
`clearNotice(state.notice.key)`. (Do this **before** flipping `running` true so
|
||||
the read is clean.)
|
||||
- `message.complete` (`store.ts:811`): call `flushPendingNotice()` (after the
|
||||
`running = false` set, so a held notice reveals on the now-idle bar).
|
||||
- `clearTranscript` (`store.ts:631`) and any session-switch reset:
|
||||
`clearNoticeState()`.
|
||||
|
||||
Export `notice` via the store's state and `showNotice`/`clearNotice` if a test or
|
||||
future slash command needs them.
|
||||
|
||||
**Tests** (`statusNotice.test.ts`, new):
|
||||
- idle `showNotice` → `state.notice` set, no card pushed.
|
||||
- routing: `notification.show` `kind:'sticky'` → `notice` set, **no** transcript
|
||||
card; `kind:'process.complete'` → card pushed, `notice` still null.
|
||||
- mid-turn hold: `message.start` → `showNotice` → `notice` stays null,
|
||||
`pendingNotice` set → `message.complete` → `notice` revealed.
|
||||
- `clearNotice` by key drops visible + pending; non-matching key is a no-op.
|
||||
- TTL: `kind:'ttl', ttlMs:50` auto-clears (vitest fake timers).
|
||||
- flash-and-yield: visible `credits.usage` cleared on `message.start`;
|
||||
`credits.depleted` persists across a start/complete cycle.
|
||||
- `clearTranscript` resets `notice` + `pendingNotice`.
|
||||
- `success` notice keeps its level.
|
||||
|
||||
### Phase 3 — view (`view/noticeBanner.tsx` + `App.tsx`)
|
||||
New `NoticeBanner` (sibling style to `notificationCard.tsx`):
|
||||
- Props: `notice: ActivityNotification | null`, plus terminal width for truncation.
|
||||
- `<Show when={notice}>` — renders nothing when null.
|
||||
- One row, `flexShrink: 0`, `paddingLeft: 1`, `selectable={false}`.
|
||||
- Text rendered **verbatim** (glyph already present), tinted by level:
|
||||
`error→error`, `warn→warn`, `success→statusGood`, `info→accent`.
|
||||
- Truncate to width with `truncRight` (`logic/truncate.ts`) so a long notice can
|
||||
never push the composer or wrap.
|
||||
|
||||
Mount in `App.tsx:144`, the first child of the top-bordered input zone, directly
|
||||
above `<StatusBar store={...} />`:
|
||||
```tsx
|
||||
<box border={['top']} ...>
|
||||
<NoticeBanner notice={props.store.state.notice} /> {/* new */}
|
||||
<StatusBar store={props.store} />
|
||||
...
|
||||
```
|
||||
|
||||
**Tests** (`noticeBanner.test.tsx`, frame): renders the text without adding a
|
||||
glyph; warn→warn color, success→statusGood color; truncates at narrow width;
|
||||
renders an empty frame when `notice` is null.
|
||||
|
||||
### Phase 4 — parity verification + docs
|
||||
- `npm run check` green (prettier + eslint + vitest).
|
||||
- Headless frame dump: a `credits.usage` warn banner above the status bar; a
|
||||
`credits.depleted` error banner surviving a turn; a `credits.restored` success
|
||||
banner that disappears after its TTL.
|
||||
- tmux smoke per `docs/opentui-dev-handoff.md` (inject the three notices via the
|
||||
test harness / a scripted gateway event; screenshot the chrome).
|
||||
- Cross-check the four-notice catalog renders identically in tone to Ink's
|
||||
`appChromeStatusRule` (color-by-level, no double glyph, truncation).
|
||||
|
||||
## Non-goals
|
||||
- No gateway/agent changes — the wire and the policy are the source of truth.
|
||||
- No new notice kinds — render exactly the four the policy emits.
|
||||
- The inline-card path (process/background completions) is **unchanged**.
|
||||
- No status-bar segment changes — the banner is its own row above the bar.
|
||||
|
||||
## Risk / footguns
|
||||
- **Schema decode-at-boundary**: `notification.show` payload is a loose Record
|
||||
read by `parseNotification`, not strict-decoded — a wrong-typed field won't blank
|
||||
the bar (unlike `applyInfo`). Keep the loose reads.
|
||||
- **createStore reference-aliasing**: store `notice` and `pendingNotice` distinct
|
||||
objects; when applying pending, it's already its own object — don't alias it to
|
||||
`lastNotification`. (See `[[solid-createstore-reference-aliasing]]`.)
|
||||
- **Timer leak**: `clearNoticeState` must clear `noticeTimer`; ensure session
|
||||
reset and store dispose clear it so a TTL callback can't fire into a dead store.
|
||||
- **Routing regression**: assert in tests that `process.complete` /
|
||||
`background task complete` still produce **cards**, not banners — the whole
|
||||
feature hinges on the `kind` discriminator.
|
||||
@@ -17,6 +17,57 @@ from utils import atomic_json_write
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DIRECTORY_PATH = get_hermes_home() / "channel_directory.json"
|
||||
# User-maintained friendly-name overlay. The directory is fully regenerated
|
||||
# from live adapters + session data on a timer, so hand-edits to
|
||||
# channel_directory.json don't survive. Aliases declared here are re-applied
|
||||
# on every build AND every load, giving durable human-friendly names (and
|
||||
# letting you pre-name a chat before it has produced any traffic).
|
||||
# Format: {"<platform>": {"<chat_id>": "<friendly name>", ...}, ...}
|
||||
CHANNEL_ALIASES_PATH = get_hermes_home() / "channel_aliases.json"
|
||||
|
||||
|
||||
def _load_channel_aliases() -> Dict[str, Dict[str, str]]:
|
||||
if not CHANNEL_ALIASES_PATH.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(CHANNEL_ALIASES_PATH, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _apply_channel_aliases(platforms: Dict[str, Any]) -> None:
|
||||
"""Overlay friendly names onto directory entries by chat_id.
|
||||
|
||||
Renames matching entries in place; injects a placeholder entry for an
|
||||
aliased id that hasn't been discovered yet (so a freshly-created group is
|
||||
addressable by name before its first message). Mutates *platforms*.
|
||||
"""
|
||||
aliases = _load_channel_aliases()
|
||||
for plat_name, id_map in aliases.items():
|
||||
if not isinstance(id_map, dict):
|
||||
continue
|
||||
entries = platforms.setdefault(plat_name, [])
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for chat_id, friendly in id_map.items():
|
||||
if not isinstance(friendly, str) or not friendly.strip():
|
||||
continue
|
||||
chat_id = str(chat_id)
|
||||
friendly = friendly.strip()
|
||||
matched = False
|
||||
for e in entries:
|
||||
if isinstance(e, dict) and e.get("id") == chat_id:
|
||||
e["name"] = friendly
|
||||
matched = True
|
||||
if not matched:
|
||||
entries.append({
|
||||
"id": chat_id,
|
||||
"name": friendly,
|
||||
"type": "group" if str(chat_id).endswith("@g.us") else "dm",
|
||||
"thread_id": None,
|
||||
})
|
||||
|
||||
|
||||
def _normalize_channel_query(value: str) -> str:
|
||||
@@ -96,6 +147,9 @@ async def build_channel_directory(adapters: Dict[Any, Any]) -> Dict[str, Any]:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Overlay user-maintained friendly names before persisting.
|
||||
_apply_channel_aliases(platforms)
|
||||
|
||||
directory = {
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
"platforms": platforms,
|
||||
@@ -247,12 +301,20 @@ def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:
|
||||
def load_directory() -> Dict[str, Any]:
|
||||
"""Load the cached channel directory from disk."""
|
||||
if not DIRECTORY_PATH.exists():
|
||||
return {"updated_at": None, "platforms": {}}
|
||||
base = {"updated_at": None, "platforms": {}}
|
||||
_apply_channel_aliases(base["platforms"])
|
||||
return base
|
||||
try:
|
||||
with open(DIRECTORY_PATH, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
data = json.load(f)
|
||||
# Re-apply aliases on read so friendly names take effect immediately,
|
||||
# even between timed rebuilds and for brand-new alias entries.
|
||||
_apply_channel_aliases(data.setdefault("platforms", {}))
|
||||
return data
|
||||
except Exception:
|
||||
return {"updated_at": None, "platforms": {}}
|
||||
base = {"updated_at": None, "platforms": {}}
|
||||
_apply_channel_aliases(base["platforms"])
|
||||
return base
|
||||
|
||||
|
||||
def lookup_channel_type(platform_name: str, chat_id: str) -> Optional[str]:
|
||||
|
||||
@@ -32,6 +32,7 @@ from typing import Any
|
||||
|
||||
_GLOBAL_DEFAULTS: dict[str, Any] = {
|
||||
"tool_progress": "all",
|
||||
"tool_progress_grouping": "accumulate", # "accumulate" = edit one bubble; "separate" = one msg per tool
|
||||
"show_reasoning": False,
|
||||
"tool_preview_length": 0,
|
||||
"streaming": None, # None = follow top-level streaming config
|
||||
@@ -238,6 +239,9 @@ def _normalise(setting: str, value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return value.lower() in {"true", "1", "yes", "on"}
|
||||
return bool(value)
|
||||
if setting == "tool_progress_grouping":
|
||||
val = str(value).lower()
|
||||
return val if val in ("accumulate", "separate") else "accumulate"
|
||||
if setting == "tool_preview_length":
|
||||
try:
|
||||
return int(value)
|
||||
|
||||
+25
-24
@@ -77,6 +77,13 @@ def _thread_metadata_for_source(source, reply_to_message_id: str | None = None)
|
||||
return metadata
|
||||
|
||||
|
||||
def _mark_notify_metadata(metadata: dict | None) -> dict:
|
||||
"""Clone metadata and mark a user-visible reply as notify-worthy."""
|
||||
notify_metadata = dict(metadata) if metadata else {}
|
||||
notify_metadata["notify"] = True
|
||||
return notify_metadata
|
||||
|
||||
|
||||
def _reply_anchor_for_event(event) -> str | None:
|
||||
"""Return reply_to id for platforms that need reply semantics.
|
||||
|
||||
@@ -3889,7 +3896,7 @@ class BasePlatformAdapter(ABC):
|
||||
chat_id=event.source.chat_id,
|
||||
content=_text,
|
||||
reply_to=_reply_anchor_for_event(event),
|
||||
metadata=thread_meta,
|
||||
metadata=_mark_notify_metadata(thread_meta),
|
||||
)
|
||||
if _eph_ttl > 0 and _r.success and _r.message_id:
|
||||
self._schedule_ephemeral_delete(
|
||||
@@ -3995,7 +4002,7 @@ class BasePlatformAdapter(ABC):
|
||||
chat_id=event.source.chat_id,
|
||||
content=_text,
|
||||
reply_to=_reply_anchor_for_event(event),
|
||||
metadata=_thread_meta,
|
||||
metadata=_mark_notify_metadata(_thread_meta),
|
||||
)
|
||||
if _eph_ttl > 0 and _r.success and _r.message_id:
|
||||
self._schedule_ephemeral_delete(
|
||||
@@ -4045,7 +4052,7 @@ class BasePlatformAdapter(ABC):
|
||||
chat_id=event.source.chat_id,
|
||||
content=_text,
|
||||
reply_to=_reply_anchor_for_event(event),
|
||||
metadata=_thread_meta,
|
||||
metadata=_mark_notify_metadata(_thread_meta),
|
||||
)
|
||||
if _eph_ttl > 0 and _r.success and _r.message_id:
|
||||
self._schedule_ephemeral_delete(
|
||||
@@ -4268,6 +4275,12 @@ class BasePlatformAdapter(ABC):
|
||||
)
|
||||
text_content = _recovered
|
||||
|
||||
# Final user-visible content (text, TTS, media, files) gets
|
||||
# the existing notify=True marker. Clone once so typing/status
|
||||
# metadata stays unmarked and progress bubbles remain
|
||||
# thread-strict.
|
||||
_final_thread_metadata = _mark_notify_metadata(_thread_metadata)
|
||||
|
||||
# Auto-TTS: if voice message, generate audio FIRST (before sending text)
|
||||
# Gated via ``_should_auto_tts_for_chat``: fires when the chat has
|
||||
# an explicit ``/voice on|tts`` opt-in OR when ``voice.auto_tts`` is
|
||||
@@ -4307,7 +4320,7 @@ class BasePlatformAdapter(ABC):
|
||||
chat_id=event.source.chat_id,
|
||||
audio_path=_tts_path,
|
||||
caption=telegram_tts_caption,
|
||||
metadata=_thread_metadata,
|
||||
metadata=_final_thread_metadata,
|
||||
)
|
||||
_tts_caption_delivered = bool(
|
||||
telegram_tts_caption and getattr(tts_result, "success", False)
|
||||
@@ -4322,23 +4335,11 @@ class BasePlatformAdapter(ABC):
|
||||
if text_content and not _tts_caption_delivered:
|
||||
logger.info("[%s] Sending response (%d chars) to %s", self.name, len(text_content), event.source.chat_id)
|
||||
_reply_anchor = _reply_anchor_for_event(event)
|
||||
# Mark final response messages for notification delivery.
|
||||
# Platform adapters that support per-message notification
|
||||
# control (e.g. Telegram's disable_notification) use this
|
||||
# flag to override silent-mode and ensure the final
|
||||
# response triggers a push notification.
|
||||
# Clone to avoid mutating the metadata shared with the
|
||||
# typing-indicator task (which must remain unmarked).
|
||||
if _thread_metadata is not None:
|
||||
_thread_metadata = dict(_thread_metadata)
|
||||
_thread_metadata["notify"] = True
|
||||
else:
|
||||
_thread_metadata = {"notify": True}
|
||||
result = await self._send_with_retry(
|
||||
chat_id=event.source.chat_id,
|
||||
content=text_content,
|
||||
reply_to=_reply_anchor,
|
||||
metadata=_thread_metadata,
|
||||
metadata=_final_thread_metadata,
|
||||
)
|
||||
_record_delivery(result)
|
||||
|
||||
@@ -4367,7 +4368,7 @@ class BasePlatformAdapter(ABC):
|
||||
await self.send_multiple_images(
|
||||
chat_id=event.source.chat_id,
|
||||
images=images,
|
||||
metadata=_thread_metadata,
|
||||
metadata=_final_thread_metadata,
|
||||
human_delay=human_delay,
|
||||
)
|
||||
except Exception as batch_err:
|
||||
@@ -4409,7 +4410,7 @@ class BasePlatformAdapter(ABC):
|
||||
await self.send_multiple_images(
|
||||
chat_id=event.source.chat_id,
|
||||
images=_batch,
|
||||
metadata=_thread_metadata,
|
||||
metadata=_final_thread_metadata,
|
||||
human_delay=human_delay,
|
||||
)
|
||||
except Exception as batch_err:
|
||||
@@ -4424,19 +4425,19 @@ class BasePlatformAdapter(ABC):
|
||||
media_result = await self.send_voice(
|
||||
chat_id=event.source.chat_id,
|
||||
audio_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
metadata=_final_thread_metadata,
|
||||
)
|
||||
elif ext in _VIDEO_EXTS:
|
||||
media_result = await self.send_video(
|
||||
chat_id=event.source.chat_id,
|
||||
video_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
metadata=_final_thread_metadata,
|
||||
)
|
||||
else:
|
||||
media_result = await self.send_document(
|
||||
chat_id=event.source.chat_id,
|
||||
file_path=media_path,
|
||||
metadata=_thread_metadata,
|
||||
metadata=_final_thread_metadata,
|
||||
)
|
||||
|
||||
if not media_result.success:
|
||||
@@ -4454,13 +4455,13 @@ class BasePlatformAdapter(ABC):
|
||||
await self.send_video(
|
||||
chat_id=event.source.chat_id,
|
||||
video_path=file_path,
|
||||
metadata=_thread_metadata,
|
||||
metadata=_final_thread_metadata,
|
||||
)
|
||||
else:
|
||||
await self.send_document(
|
||||
chat_id=event.source.chat_id,
|
||||
file_path=file_path,
|
||||
metadata=_thread_metadata,
|
||||
metadata=_final_thread_metadata,
|
||||
)
|
||||
except Exception as file_err:
|
||||
logger.error("[%s] Error sending local file %s: %s", self.name, file_path, file_err)
|
||||
|
||||
@@ -678,8 +678,13 @@ class EmailAdapter(BasePlatformAdapter):
|
||||
image_url: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Send an image URL as part of an email body."""
|
||||
"""Send an image URL as part of an email body.
|
||||
|
||||
``metadata`` is accepted to honor the base-class contract; the
|
||||
email body send doesn't use it.
|
||||
"""
|
||||
text = caption or ""
|
||||
text += f"\n\nImage: {image_url}"
|
||||
return await self.send(chat_id, text.strip(), reply_to)
|
||||
|
||||
+140
-21
@@ -419,11 +419,13 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
self._mention_patterns = self._compile_mention_patterns()
|
||||
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
|
||||
self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
|
||||
# Bot API 10.1 Rich Messages: when explicitly enabled, send final
|
||||
# replies via sendRichMessage with the raw agent markdown so
|
||||
# tables/task lists/etc. render natively. Disabled by default because
|
||||
# several Telegram clients accept but render rich messages poorly.
|
||||
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False)
|
||||
# Bot API 10.1 Rich Messages: render constructs the legacy MarkdownV2
|
||||
# path degrades (tables → bullet lists, task lists, <details>, block
|
||||
# math) via sendRichMessage / editMessageText's rich_message param using
|
||||
# the raw agent markdown. Enabled by default; users can opt out for
|
||||
# clients that accept but render rich messages poorly via
|
||||
# platforms.telegram.extra.rich_messages: false.
|
||||
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", True)
|
||||
# Latched off after a capability failure on sendRichMessage /
|
||||
# sendRichMessageDraft (e.g. older python-telegram-bot without the
|
||||
# endpoint) so later sends skip the doomed rich attempt entirely.
|
||||
@@ -979,18 +981,54 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _needs_rich_rendering(self, content: str) -> bool:
|
||||
"""Return True for markdown constructs that the legacy path degrades.
|
||||
|
||||
Keep ordinary replies on the pre-rich MarkdownV2 path so Telegram
|
||||
clients render a consistent font weight/spacing. The rich endpoint is
|
||||
reserved for constructs where raw markdown materially improves output:
|
||||
pipe tables (MarkdownV2 has no table syntax and rewrites them into
|
||||
bullet lists), GFM task lists, collapsible ``<details>`` blocks, and
|
||||
block math. Adapted from #45995 (@YonganZhang).
|
||||
"""
|
||||
if not content:
|
||||
return False
|
||||
if any(_TABLE_SEPARATOR_RE.match(line) for line in content.splitlines()):
|
||||
return True
|
||||
if re.search(r"(?m)^\s*[-*]\s+\[[ xX]\]\s+", content):
|
||||
return True
|
||||
if re.search(r"(?m)^<details\b|^</details>|^<summary\b|^</summary>", content):
|
||||
return True
|
||||
if "$$" in content:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _rich_eligible(self, content: str) -> bool:
|
||||
"""Capability/content eligibility for rich, ignoring ``expect_edits``.
|
||||
|
||||
Shared core of :meth:`_should_attempt_rich` minus the per-call
|
||||
``expect_edits`` metadata gate. The rich EDIT-finalize path
|
||||
(:meth:`_try_edit_rich`) needs this: a streamed preview is sent with
|
||||
``expect_edits=True`` to stay on the editable path mid-stream, but the
|
||||
FINAL edit should still upgrade to rich when the content warrants it.
|
||||
"""
|
||||
return bool(
|
||||
getattr(self, "_rich_messages_enabled", True)
|
||||
and not getattr(self, "_rich_send_disabled", False)
|
||||
and content
|
||||
and content.strip()
|
||||
and self._needs_rich_rendering(content)
|
||||
and not self._has_telegram_desktop_details_math_crash_shape(content)
|
||||
and self._content_fits_rich_limits(content)
|
||||
and self._bot_supports_rich()
|
||||
)
|
||||
|
||||
def _should_attempt_rich(
|
||||
self, content: str, metadata: Optional[Dict[str, Any]] = None
|
||||
) -> bool:
|
||||
return bool(
|
||||
getattr(self, "_rich_messages_enabled", False)
|
||||
and not getattr(self, "_rich_send_disabled", False)
|
||||
and not (metadata or {}).get("expect_edits")
|
||||
and content
|
||||
and content.strip()
|
||||
and not self._has_telegram_desktop_details_math_crash_shape(content)
|
||||
and self._content_fits_rich_limits(content)
|
||||
and self._bot_supports_rich()
|
||||
not (metadata or {}).get("expect_edits")
|
||||
and self._rich_eligible(content)
|
||||
)
|
||||
|
||||
def prefers_fresh_final_streaming(
|
||||
@@ -998,12 +1036,13 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
) -> bool:
|
||||
"""Whether to replace a streamed preview with a fresh rich final.
|
||||
|
||||
Keep this disabled for Telegram. The fresh-final path briefly shows two
|
||||
copies of the final answer, then deletes the streaming preview after the
|
||||
rich send succeeds. That is especially visible on clients that support
|
||||
rich messages well, and it looks like duplicate delivery at the end of
|
||||
every streamed turn. Until Telegram rich edits are wired directly, final
|
||||
streamed replies should edit the existing preview in place.
|
||||
Disabled for Telegram. The fresh-final path briefly shows two copies of
|
||||
the final answer, then deletes the streaming preview after the rich send
|
||||
succeeds — it looks like duplicate delivery at the end of every streamed
|
||||
turn (the reason #46206 reverted it). Rich finalize is instead handled
|
||||
by editing the existing preview in place via Bot API 10.1's
|
||||
``editMessageText`` ``rich_message`` parameter (see
|
||||
:meth:`_try_edit_rich`), so no fresh re-send / delete is needed.
|
||||
"""
|
||||
return False
|
||||
|
||||
@@ -1019,7 +1058,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
streams split exactly as before.
|
||||
"""
|
||||
if (
|
||||
getattr(self, "_rich_messages_enabled", False)
|
||||
getattr(self, "_rich_messages_enabled", True)
|
||||
and not getattr(self, "_rich_send_disabled", False)
|
||||
and self._bot_supports_rich()
|
||||
):
|
||||
@@ -1207,9 +1246,74 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
message_id=str(message_id) if message_id is not None else None,
|
||||
)
|
||||
|
||||
async def _try_edit_rich(
|
||||
self,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
content: str,
|
||||
) -> Optional[SendResult]:
|
||||
"""Edit an existing message in place as a rich message (Bot API 10.1).
|
||||
|
||||
Uses ``editMessageText`` with the ``rich_message`` parameter so a
|
||||
streamed preview can finalize as rich (tables/task lists/details/math)
|
||||
WITHOUT a fresh send + delete — no duplicate preview. Mirrors
|
||||
:meth:`_try_send_rich`'s error contract:
|
||||
|
||||
- success → ``SendResult(success=True, message_id=...)``
|
||||
- permanent / capability error → ``None`` (caller falls back to the
|
||||
legacy MarkdownV2 edit; capability errors latch rich off)
|
||||
- transient / unknown → ``SendResult(success=False)`` with retry
|
||||
semantics (the message may already be edited; do NOT legacy-resend)
|
||||
"""
|
||||
payload: Dict[str, Any] = {
|
||||
"chat_id": int(chat_id),
|
||||
"message_id": int(message_id),
|
||||
"rich_message": self._rich_message_payload(content),
|
||||
}
|
||||
if getattr(self, "_disable_link_previews", False):
|
||||
payload["link_preview_options"] = {"is_disabled": True}
|
||||
try:
|
||||
# Raw Bot API result; do not request return_type=Message (PTB does
|
||||
# not fully model the 10.1 response shape yet — a post-edit parse
|
||||
# error must not be mistaken for a failed edit).
|
||||
await self._bot.do_api_request("editMessageText", api_kwargs=payload)
|
||||
except Exception as exc:
|
||||
if self._is_rich_fallback_error(exc):
|
||||
if self._is_rich_capability_error(exc):
|
||||
self._rich_send_disabled = True
|
||||
# "Message is not modified" — content identical to the current
|
||||
# rich message; treat as a successful no-op so the caller does
|
||||
# not fall through to a redundant legacy edit.
|
||||
if "not modified" in str(exc).lower():
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
logger.debug(
|
||||
"[%s] rich editMessageText rejected (%s) — falling back to MarkdownV2 edit",
|
||||
self.name, exc,
|
||||
)
|
||||
return None
|
||||
if "not modified" in str(exc).lower():
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
err_str = str(exc).lower()
|
||||
try:
|
||||
from telegram.error import TimedOut as _TimedOut
|
||||
except (ImportError, AttributeError):
|
||||
_TimedOut = None
|
||||
is_timeout = (_TimedOut and isinstance(exc, _TimedOut)) or "timed out" in err_str
|
||||
is_connect_timeout = self._looks_like_connect_timeout(exc)
|
||||
logger.warning(
|
||||
"[%s] rich editMessageText transient failure (no legacy resend): %s",
|
||||
self.name, exc,
|
||||
)
|
||||
return SendResult(
|
||||
success=False,
|
||||
error=str(exc),
|
||||
retryable=(is_connect_timeout or not is_timeout),
|
||||
)
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
|
||||
def _should_attempt_rich_draft(self, content: str) -> bool:
|
||||
return bool(
|
||||
getattr(self, "_rich_messages_enabled", False)
|
||||
getattr(self, "_rich_messages_enabled", True)
|
||||
and not getattr(self, "_rich_send_disabled", False)
|
||||
and not getattr(self, "_rich_draft_disabled", False)
|
||||
and content
|
||||
@@ -2555,6 +2659,21 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
if not self._bot:
|
||||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
# Rich finalize (Bot API 10.1): when the completed content has
|
||||
# constructs the legacy MarkdownV2 edit degrades (tables → bullet
|
||||
# lists, task lists, <details>, block math) and rich is available,
|
||||
# edit the preview IN PLACE via editMessageText's rich_message param.
|
||||
# No fresh send + delete → no duplicate preview (the problem #46206
|
||||
# reverted the fresh-final path for). Attempted before the 4,096
|
||||
# overflow pre-flight because the rich text cap is 32,768 — a rich
|
||||
# table that exceeds the MarkdownV2 limit must not be split into legacy
|
||||
# chunks. Falls back to the legacy edit path (overflow split included)
|
||||
# on capability/permanent rejection.
|
||||
if finalize and self._rich_eligible(content):
|
||||
rich_result = await self._try_edit_rich(chat_id, message_id, content)
|
||||
if rich_result is not None:
|
||||
return rich_result
|
||||
|
||||
# Pre-flight: if content already exceeds the limit, split-and-deliver
|
||||
# without round-tripping a doomed edit.
|
||||
if utf16_len(content) > self.MAX_MESSAGE_LENGTH:
|
||||
|
||||
@@ -846,13 +846,20 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
image_url: str,
|
||||
caption: Optional[str] = None,
|
||||
reply_to: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> SendResult:
|
||||
"""Download image URL to cache, send natively via bridge."""
|
||||
"""Download image URL to cache, send natively via bridge.
|
||||
|
||||
``metadata`` is accepted to honor the base-class contract — the
|
||||
batch sender ``send_multiple_images`` passes it through to every
|
||||
send path. The bridge media call doesn't use it, matching the
|
||||
sibling overrides (send_video / send_voice / send_document).
|
||||
"""
|
||||
try:
|
||||
local_path = await cache_image_from_url(image_url)
|
||||
return await self._send_media_to_bridge(chat_id, local_path, "image", caption)
|
||||
except Exception:
|
||||
return await super().send_image(chat_id, image_url, caption, reply_to)
|
||||
return await super().send_image(chat_id, image_url, caption, reply_to, metadata)
|
||||
|
||||
async def send_image_file(
|
||||
self,
|
||||
@@ -1136,6 +1143,15 @@ class WhatsAppAdapter(WhatsAppBehaviorMixin, BasePlatformAdapter):
|
||||
body = data.get("body", "")
|
||||
if data.get("isGroup"):
|
||||
body = self._clean_bot_mention_text(body, data)
|
||||
|
||||
# If this is a reply, include the quoted message text so the agent
|
||||
# knows exactly what the user is responding to (fixes "approve" context issue)
|
||||
quoted_text = str(data.get("quotedText") or "").strip()
|
||||
if quoted_text and data.get("hasQuotedMessage"):
|
||||
# Truncate long quoted text to keep prompts reasonable
|
||||
if len(quoted_text) > 300:
|
||||
quoted_text = quoted_text[:297] + "..."
|
||||
body = f"[Replying to: \"{quoted_text}\"]\n{body}"
|
||||
MAX_TEXT_INJECT_BYTES = 100 * 1024
|
||||
if msg_type == MessageType.DOCUMENT and cached_urls:
|
||||
for doc_path in cached_urls:
|
||||
|
||||
+274
-36
@@ -402,6 +402,68 @@ async def _send_or_update_status_coro(adapter, chat_id, status_key, content, met
|
||||
return await adapter.send(chat_id, content, metadata=metadata)
|
||||
|
||||
|
||||
def _resolve_progress_thread_id(platform: Any, source_thread_id: Any, event_message_id: Any) -> Optional[str]:
|
||||
"""Return thread/root ID that progress/status bubbles should target."""
|
||||
platform_value = getattr(platform, "value", platform)
|
||||
platform_key = str(platform_value or "").lower()
|
||||
if source_thread_id:
|
||||
return str(source_thread_id)
|
||||
if platform_key in {"slack", "mattermost"} and event_message_id:
|
||||
return str(event_message_id)
|
||||
return None
|
||||
|
||||
|
||||
def _has_platform_display_override(user_config: dict, platform_key: str, setting: str) -> bool:
|
||||
"""Return True when display.platforms.<platform> explicitly sets setting."""
|
||||
display = user_config.get("display") if isinstance(user_config, dict) else None
|
||||
if not isinstance(display, dict):
|
||||
return False
|
||||
platforms = display.get("platforms")
|
||||
if not isinstance(platforms, dict):
|
||||
return False
|
||||
platform_cfg = platforms.get(platform_key)
|
||||
return isinstance(platform_cfg, dict) and setting in platform_cfg
|
||||
|
||||
|
||||
def _resolve_gateway_display_bool(
|
||||
user_config: dict,
|
||||
platform_key: str,
|
||||
setting: str,
|
||||
*,
|
||||
default: bool = False,
|
||||
platform: Any = None,
|
||||
require_platform_override_for: set[Any] | None = None,
|
||||
) -> bool:
|
||||
"""Resolve a boolean display setting with optional platform-only opt-in.
|
||||
|
||||
Some display features expose assistant scratch text rather than deliberate
|
||||
user-facing output. For high-noise threaded chat surfaces such as
|
||||
Mattermost, a global opt-in is too broad: they must be enabled with an
|
||||
explicit display.platforms.<platform>.<setting> override.
|
||||
"""
|
||||
current_platform = _gateway_platform_value(platform or platform_key)
|
||||
platform_only = {
|
||||
_gateway_platform_value(candidate)
|
||||
for candidate in (require_platform_override_for or set())
|
||||
}
|
||||
if (
|
||||
current_platform in platform_only
|
||||
and not _has_platform_display_override(user_config, platform_key, setting)
|
||||
):
|
||||
return False
|
||||
|
||||
from gateway.display_config import resolve_display_setting
|
||||
|
||||
value = resolve_display_setting(user_config, platform_key, setting, default)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"true", "yes", "1", "on"}
|
||||
if value is None:
|
||||
return bool(default)
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _telegramize_command_mentions(text: str, platform: Any) -> str:
|
||||
"""Rewrite slash-command mentions to Telegram-valid command names.
|
||||
|
||||
@@ -1921,9 +1983,42 @@ def _format_gateway_process_notification(evt: dict) -> "str | None":
|
||||
text += "]"
|
||||
return text
|
||||
|
||||
if evt_type == "async_delegation":
|
||||
# Reuse the shared rich formatter (self-contained task-source block).
|
||||
from tools.process_registry import format_process_notification
|
||||
return format_process_notification(evt)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _drain_gateway_watch_events(completion_queue) -> "list[dict]":
|
||||
"""Drain gateway-owned watch events without spinning on requeued events.
|
||||
|
||||
Watch events are handled by the post-turn gateway drain. Process
|
||||
completions are owned by their per-process watcher task, and async
|
||||
delegation completions are owned by ``_async_delegation_watcher``.
|
||||
Requeueing async events inside ``while not queue.empty()`` would make the
|
||||
loop non-terminating, so detach the current batch first, then requeue any
|
||||
events this drain does not own after the queue is empty.
|
||||
"""
|
||||
watch_events: list[dict] = []
|
||||
requeue: list[dict] = []
|
||||
while not completion_queue.empty():
|
||||
try:
|
||||
evt = completion_queue.get_nowait()
|
||||
except Exception:
|
||||
break
|
||||
evt_type = evt.get("type", "completion")
|
||||
if evt_type in {"watch_match", "watch_disabled"}:
|
||||
watch_events.append(evt)
|
||||
elif evt_type == "async_delegation":
|
||||
requeue.append(evt)
|
||||
# else: process completion events are handled by the watcher task
|
||||
for evt in requeue:
|
||||
completion_queue.put(evt)
|
||||
return watch_events
|
||||
|
||||
|
||||
# Module-level weak reference to the active GatewayRunner instance.
|
||||
# Used by tools (e.g. send_message) that need to route through a live
|
||||
# adapter for plugin platforms. Set in GatewayRunner.__init__().
|
||||
@@ -5353,6 +5448,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
# turn so the agent kicks off the new chat.
|
||||
asyncio.create_task(self._handoff_watcher())
|
||||
|
||||
# Start background async-delegation watcher — drains completion events
|
||||
# from delegate_task(background=true) subagents and injects each
|
||||
# result back into its originating session as a new turn, covering the
|
||||
# idle case where the subagent finishes with no agent turn running.
|
||||
asyncio.create_task(self._async_delegation_watcher())
|
||||
|
||||
logger.info("Press Ctrl+C to stop")
|
||||
|
||||
return True
|
||||
@@ -5989,6 +6090,16 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.debug("process_registry.kill_all (%s) error: %s", phase, _e)
|
||||
try:
|
||||
from tools.async_delegation import interrupt_all as _interrupt_async
|
||||
_async_n = _interrupt_async(reason=f"gateway shutdown ({phase})")
|
||||
if _async_n:
|
||||
logger.info(
|
||||
"Shutdown (%s): interrupted %d background delegation(s)",
|
||||
phase, _async_n,
|
||||
)
|
||||
except Exception as _e:
|
||||
logger.debug("async interrupt_all (%s) error: %s", phase, _e)
|
||||
try:
|
||||
from tools.terminal_tool import cleanup_all_environments
|
||||
cleanup_all_environments()
|
||||
@@ -7554,6 +7665,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if canonical == "resume":
|
||||
return await self._handle_resume_command(event)
|
||||
|
||||
if canonical == "sessions":
|
||||
return await self._handle_sessions_command(event)
|
||||
|
||||
if canonical == "branch":
|
||||
return await self._handle_branch_command(event)
|
||||
|
||||
@@ -8926,17 +9040,24 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
source, session_entry, reason="agent-result-compression",
|
||||
)
|
||||
|
||||
# Prepend reasoning/thinking if display is enabled (per-platform)
|
||||
# Prepend reasoning/thinking if display is enabled (per-platform).
|
||||
# Mattermost requires explicit per-platform opt-in because this is
|
||||
# scratch text, not ordinary final-answer content.
|
||||
try:
|
||||
from gateway.display_config import resolve_display_setting as _rds
|
||||
_show_reasoning_effective = _rds(
|
||||
_show_reasoning_effective = _resolve_gateway_display_bool(
|
||||
_load_gateway_config(),
|
||||
_platform_config_key(source.platform),
|
||||
"show_reasoning",
|
||||
getattr(self, "_show_reasoning", False),
|
||||
default=bool(getattr(self, "_show_reasoning", False)),
|
||||
platform=source.platform,
|
||||
require_platform_override_for={Platform.MATTERMOST},
|
||||
)
|
||||
except Exception:
|
||||
_show_reasoning_effective = getattr(self, "_show_reasoning", False)
|
||||
_show_reasoning_effective = (
|
||||
False
|
||||
if source.platform == Platform.MATTERMOST
|
||||
else getattr(self, "_show_reasoning", False)
|
||||
)
|
||||
if _show_reasoning_effective and response and not _intentional_silence:
|
||||
last_reasoning = agent_result.get("last_reasoning")
|
||||
if last_reasoning:
|
||||
@@ -8992,18 +9113,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
logger.error("Process watcher setup error: %s", e)
|
||||
|
||||
# Drain watch pattern notifications that arrived during the agent run.
|
||||
# Watch events and completions share the same queue; completions are
|
||||
# already handled by the per-process watcher task above, so we only
|
||||
# inject watch-type events here.
|
||||
# Watch events and completions share the same queue; process
|
||||
# completions are already handled by the per-process watcher task
|
||||
# above, so we only inject watch-type events here.
|
||||
#
|
||||
# Async-delegation completions ALSO ride this shared queue but are
|
||||
# owned by the dedicated _async_delegation_watcher (started at
|
||||
# boot), which covers both the idle and post-turn cases with a
|
||||
# single consumer — so we leave them on the queue here.
|
||||
try:
|
||||
from tools.process_registry import process_registry as _pr
|
||||
_watch_events = []
|
||||
while not _pr.completion_queue.empty():
|
||||
evt = _pr.completion_queue.get_nowait()
|
||||
evt_type = evt.get("type", "completion")
|
||||
if evt_type in {"watch_match", "watch_disabled"}:
|
||||
_watch_events.append(evt)
|
||||
# else: completion events are handled by the watcher task
|
||||
_watch_events = _drain_gateway_watch_events(_pr.completion_queue)
|
||||
for evt in _watch_events:
|
||||
synth_text = _format_gateway_process_notification(evt)
|
||||
if synth_text:
|
||||
@@ -12262,6 +12382,74 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
except Exception as e:
|
||||
logger.error("Watch notification injection error: %s", e)
|
||||
|
||||
def _enrich_async_delegation_routing(self, evt: dict) -> None:
|
||||
"""Fill platform/chat_id/thread_id/chat_type on an async-delegation event.
|
||||
|
||||
Async-delegation completion events only carry ``session_key`` (the
|
||||
daemon worker has no access to the per-message routing metadata the
|
||||
terminal background watcher captures at spawn time). Parse the
|
||||
session_key into the routing fields ``_build_process_event_source``
|
||||
expects. Best-effort: a CLI-origin event (empty session_key) is left
|
||||
as-is and simply won't route on the gateway.
|
||||
"""
|
||||
if evt.get("platform"):
|
||||
return # already enriched
|
||||
parsed = _parse_session_key(evt.get("session_key", "") or "")
|
||||
if not parsed:
|
||||
return
|
||||
evt["platform"] = parsed.get("platform", "")
|
||||
evt["chat_type"] = parsed.get("chat_type", "")
|
||||
evt["chat_id"] = parsed.get("chat_id", "")
|
||||
if parsed.get("thread_id"):
|
||||
evt["thread_id"] = parsed["thread_id"]
|
||||
|
||||
async def _async_delegation_watcher(self, interval: float = 2.0) -> None:
|
||||
"""Drain async-delegation completions and inject them as new turns.
|
||||
|
||||
Background subagents (``delegate_task(background=true)``) run on the
|
||||
async-delegation daemon executor — they have no per-process watcher
|
||||
task, so their completion events would only be seen by the post-turn
|
||||
queue drain. This watcher covers the IDLE case: when a background
|
||||
subagent finishes while no agent turn is running, its result still
|
||||
re-enters the originating session promptly.
|
||||
|
||||
Mirrors the CLI's idle ``process_loop`` drain. Stays silent when the
|
||||
queue has nothing for us; ignores non-async event types (those are
|
||||
handled by ``_run_process_watcher`` / the post-turn drain).
|
||||
"""
|
||||
await asyncio.sleep(3) # let platforms finish connecting
|
||||
from tools.process_registry import process_registry as _pr
|
||||
while self._running:
|
||||
try:
|
||||
# Peek the queue for async-delegation events. We must NOT
|
||||
# consume watch/completion events here (other drains own them),
|
||||
# so requeue anything that isn't ours.
|
||||
requeue = []
|
||||
async_events = []
|
||||
while not _pr.completion_queue.empty():
|
||||
try:
|
||||
evt = _pr.completion_queue.get_nowait()
|
||||
except Exception:
|
||||
break
|
||||
if evt.get("type") == "async_delegation":
|
||||
async_events.append(evt)
|
||||
else:
|
||||
requeue.append(evt)
|
||||
for evt in requeue:
|
||||
_pr.completion_queue.put(evt)
|
||||
for evt in async_events:
|
||||
self._enrich_async_delegation_routing(evt)
|
||||
synth_text = _format_gateway_process_notification(evt)
|
||||
if not synth_text:
|
||||
continue
|
||||
try:
|
||||
await self._inject_watch_notification(synth_text, evt)
|
||||
except Exception as e:
|
||||
logger.error("Async delegation injection error: %s", e)
|
||||
except Exception as e:
|
||||
logger.debug("Async delegation watcher error: %s", e)
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
async def _run_process_watcher(self, watcher: dict) -> None:
|
||||
"""
|
||||
Periodically check a background process and push updates to the user.
|
||||
@@ -12318,7 +12506,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if session.exited:
|
||||
# --- Agent-triggered completion: inject synthetic message ---
|
||||
# Skip if the agent already consumed the result via wait/poll/log
|
||||
from tools.process_registry import process_registry as _pr_check
|
||||
from tools.process_registry import format_process_notification, process_registry as _pr_check
|
||||
if agent_notify and not _pr_check.is_completion_consumed(session_id):
|
||||
from tools.ansi_strip import strip_ansi
|
||||
_raw = strip_ansi(session.output_buffer) if session.output_buffer else ""
|
||||
@@ -12334,12 +12522,17 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_out = f"[… output truncated — showing last {len(_tail)} chars]\n{_tail}"
|
||||
else:
|
||||
_out = _raw
|
||||
synth_text = (
|
||||
f"[IMPORTANT: Background process {session_id} completed "
|
||||
f"(exit code {session.exit_code}).\n"
|
||||
f"Command: {session.command}\n"
|
||||
f"Output:\n{_out}]"
|
||||
)
|
||||
synth_text = format_process_notification({
|
||||
"type": "completion",
|
||||
"session_id": session_id,
|
||||
"command": session.command,
|
||||
"exit_code": session.exit_code,
|
||||
"completion_reason": getattr(session, "completion_reason", "exited"),
|
||||
"termination_source": getattr(session, "termination_source", ""),
|
||||
"output": _out,
|
||||
})
|
||||
if not synth_text:
|
||||
break
|
||||
source = self._build_process_event_source({
|
||||
"session_id": session_id,
|
||||
"session_key": session_key,
|
||||
@@ -13489,6 +13682,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if _env_tp and not _tool_progress_configured
|
||||
else (_resolved_tp or _env_tp or "all")
|
||||
)
|
||||
# Tool progress grouping: "accumulate" (edit one bubble) or "separate" (one msg per tool)
|
||||
progress_grouping = resolve_display_setting(user_config, platform_key, "tool_progress_grouping") or "accumulate"
|
||||
# Disable tool progress for webhooks - they don't support message editing,
|
||||
# so each progress line would be sent as a separate message.
|
||||
from gateway.config import Platform
|
||||
@@ -13498,18 +13693,32 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
# in chat platforms while opting into concise mid-turn updates.
|
||||
interim_assistant_messages_enabled = (
|
||||
source.platform != Platform.WEBHOOK
|
||||
and bool(
|
||||
resolve_display_setting(
|
||||
user_config,
|
||||
platform_key,
|
||||
"interim_assistant_messages",
|
||||
True,
|
||||
)
|
||||
and _resolve_gateway_display_bool(
|
||||
user_config,
|
||||
platform_key,
|
||||
"interim_assistant_messages",
|
||||
default=True,
|
||||
platform=source.platform,
|
||||
require_platform_override_for={Platform.MATTERMOST},
|
||||
)
|
||||
)
|
||||
|
||||
# thinking_progress is independent — if enabled, we need the progress
|
||||
# queue even when tool_progress is off (thinking relay uses same infra).
|
||||
# Mattermost requires a per-platform opt-in: global scratch-text display
|
||||
# is too easy to leak into busy public threads.
|
||||
_thinking_enabled = _resolve_gateway_display_bool(
|
||||
user_config,
|
||||
platform_key,
|
||||
"thinking_progress",
|
||||
default=False,
|
||||
platform=source.platform,
|
||||
require_platform_override_for={Platform.MATTERMOST},
|
||||
)
|
||||
needs_progress_queue = tool_progress_enabled or _thinking_enabled
|
||||
|
||||
|
||||
# Queue for progress messages (thread-safe)
|
||||
progress_queue = queue.Queue() if tool_progress_enabled else None
|
||||
progress_queue = queue.Queue() if needs_progress_queue else None
|
||||
last_tool = [None] # Mutable container for tracking in closure
|
||||
last_progress_msg = [None] # Track last message for dedup
|
||||
repeat_count = [0] # How many times the same message repeated
|
||||
@@ -13615,6 +13824,24 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
logger.debug("tool-progress onboarding hint failed: %s", _hint_err)
|
||||
return
|
||||
|
||||
# "_thinking" is assistant scratch text between tool calls. It
|
||||
# is never ordinary tool progress: only relay it when the platform
|
||||
# explicitly opted into thinking_progress. Handle both legacy
|
||||
# callback shapes: ("_thinking", text) and
|
||||
# ("reasoning.available", "_thinking", text, ...).
|
||||
if event_type == "_thinking" or tool_name == "_thinking":
|
||||
if not _thinking_enabled:
|
||||
return
|
||||
thinking_text = preview if tool_name == "_thinking" else tool_name
|
||||
msg = f"💬 {thinking_text}" if thinking_text else None
|
||||
if msg:
|
||||
progress_queue.put(msg)
|
||||
return
|
||||
|
||||
# If tool_progress is off, only _thinking passes through (above).
|
||||
# Regular tool calls are suppressed.
|
||||
if not tool_progress_enabled:
|
||||
return
|
||||
|
||||
# Only act on tool.started events (ignore tool.completed, reasoning.available, etc.)
|
||||
if event_type not in {"tool.started",}:
|
||||
@@ -13760,10 +13987,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
# - Feishu only honors reply_in_thread when sending a reply, so topic
|
||||
# progress uses the triggering event message as the reply target
|
||||
# - Other platforms should use explicit source.thread_id only
|
||||
if source.platform == Platform.SLACK:
|
||||
_progress_thread_id = source.thread_id or event_message_id
|
||||
else:
|
||||
_progress_thread_id = source.thread_id
|
||||
_progress_thread_id = _resolve_progress_thread_id(
|
||||
source.platform, source.thread_id, event_message_id,
|
||||
)
|
||||
_progress_metadata = (
|
||||
self._thread_metadata_for_source(source, event_message_id)
|
||||
if _progress_thread_id == source.thread_id
|
||||
@@ -13796,7 +14022,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
|
||||
progress_lines = [] # Accumulated tool lines for the CURRENT editable bubble
|
||||
progress_msg_id = None # ID of the current progress message to edit
|
||||
can_edit = True # False once an edit fails (platform doesn't support it)
|
||||
can_edit = progress_grouping != "separate" # "separate" = one message per tool (pre-v0.9 behavior)
|
||||
_last_edit_ts = 0.0 # Throttle edits to avoid Telegram flood control
|
||||
_PROGRESS_EDIT_INTERVAL = 1.5 # Minimum seconds between edits
|
||||
|
||||
@@ -14563,6 +14789,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_pdc = getattr(_status_adapter, "_post_delivery_callbacks", None)
|
||||
if _pdc is not None:
|
||||
_pdc[session_key] = _release_bg_review_messages
|
||||
# Memory update notifications in chat. Config: display.memory_notifications
|
||||
# off — no chat notification (still logged to stdout)
|
||||
# on — generic "💾 Memory updated" (default)
|
||||
# verbose — content preview: "💾 Memory ➕ Hermes Repo..."
|
||||
_mem_notif = user_config.get("display", {}).get("memory_notifications")
|
||||
if isinstance(_mem_notif, bool):
|
||||
_mem_notif = "on" if _mem_notif else "off"
|
||||
agent.memory_notifications = str(_mem_notif).lower() if _mem_notif else "on"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Clarify callback: present a clarify prompt and block on a response.
|
||||
@@ -14639,6 +14873,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
|
||||
agent.clarify_callback = _clarify_callback_sync
|
||||
|
||||
# Show assistant thinking between tool calls — independent of
|
||||
# tool_progress mode. Mattermost needs an explicit per-platform
|
||||
# opt-in so global scratch-text display does not leak into threads.
|
||||
agent.thinking_progress = _thinking_enabled
|
||||
# Store agent reference for interrupt support
|
||||
agent_holder[0] = agent
|
||||
# Capture the full tool definitions for transcript logging
|
||||
|
||||
+150
-8
@@ -394,20 +394,35 @@ class GatewaySlashCommandsMixin:
|
||||
|
||||
async def _handle_status_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /status command."""
|
||||
from gateway.run import _AGENT_PENDING_SENTINEL, _load_gateway_config, _resolve_gateway_model
|
||||
|
||||
source = event.source
|
||||
session_entry = self.session_store.get_or_create_session(source)
|
||||
|
||||
connected_platforms = [p.value for p in self.adapters.keys()]
|
||||
|
||||
# Check if there's an active agent
|
||||
# Check if there's an active agent. Keep the sentinel distinct: a
|
||||
# starting/pending run should not be treated as a fully usable agent for
|
||||
# model/context display, but it still occupies the session slot.
|
||||
session_key = session_entry.session_key
|
||||
is_running = session_key in self._running_agents
|
||||
agent = self._running_agents.get(session_key)
|
||||
is_running = agent is not None and agent is not _AGENT_PENDING_SENTINEL
|
||||
|
||||
# Count pending /queue follow-ups (slot + overflow).
|
||||
adapter = self.adapters.get(source.platform) if source else None
|
||||
queue_depth = self._queue_depth(session_key, adapter=adapter)
|
||||
|
||||
def _clean_str(value: Any) -> str:
|
||||
return value.strip() if isinstance(value, str) and value.strip() else ""
|
||||
|
||||
def _int_value(value: Any) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
title = None
|
||||
session_row: dict[str, Any] = {}
|
||||
# Pull token totals from the SQLite session DB rather than the
|
||||
# in-memory SessionStore. The agent's per-turn token deltas are
|
||||
# persisted into sessions_db (run_agent.py), not into SessionEntry,
|
||||
@@ -422,17 +437,92 @@ class GatewaySlashCommandsMixin:
|
||||
title = None
|
||||
try:
|
||||
row = self._session_db.get_session(session_entry.session_id)
|
||||
if row:
|
||||
if isinstance(row, dict):
|
||||
session_row = row
|
||||
db_total_tokens = (
|
||||
(row.get("input_tokens") or 0)
|
||||
+ (row.get("output_tokens") or 0)
|
||||
+ (row.get("cache_read_tokens") or 0)
|
||||
+ (row.get("cache_write_tokens") or 0)
|
||||
+ (row.get("reasoning_tokens") or 0)
|
||||
_int_value(row.get("input_tokens"))
|
||||
+ _int_value(row.get("output_tokens"))
|
||||
+ _int_value(row.get("cache_read_tokens"))
|
||||
+ _int_value(row.get("cache_write_tokens"))
|
||||
+ _int_value(row.get("reasoning_tokens"))
|
||||
)
|
||||
except Exception:
|
||||
db_total_tokens = 0
|
||||
|
||||
# Resolve model/context for cockpit-style status. Prefer the live or
|
||||
# cached agent because it carries the actual runtime route and context
|
||||
# compressor. Fall back to persisted SessionDB metadata plus the
|
||||
# SessionStore's last_prompt_tokens so /status remains useful between
|
||||
# turns without making billing/account calls.
|
||||
status_agent = agent if is_running else None
|
||||
if status_agent is None:
|
||||
cache_lock = getattr(self, "_agent_cache_lock", None)
|
||||
cache = getattr(self, "_agent_cache", None)
|
||||
if cache_lock is not None and cache is not None:
|
||||
try:
|
||||
with cache_lock:
|
||||
cached = cache.get(session_key)
|
||||
if cached:
|
||||
status_agent = cached[0]
|
||||
except Exception:
|
||||
status_agent = None
|
||||
|
||||
model_name = ""
|
||||
provider_name = ""
|
||||
base_url = ""
|
||||
context_used = 0
|
||||
context_total = 0
|
||||
if status_agent is not None and status_agent is not _AGENT_PENDING_SENTINEL:
|
||||
model_name = _clean_str(getattr(status_agent, "model", ""))
|
||||
provider_name = _clean_str(getattr(status_agent, "provider", ""))
|
||||
base_url = _clean_str(getattr(status_agent, "base_url", ""))
|
||||
ctx = getattr(status_agent, "context_compressor", None)
|
||||
if ctx is not None:
|
||||
context_used = _int_value(getattr(ctx, "last_prompt_tokens", 0))
|
||||
context_total = _int_value(getattr(ctx, "context_length", 0))
|
||||
|
||||
model_name = model_name or _clean_str(session_row.get("model"))
|
||||
provider_name = provider_name or _clean_str(session_row.get("billing_provider"))
|
||||
base_url = base_url or _clean_str(session_row.get("billing_base_url"))
|
||||
context_used = context_used or _int_value(getattr(session_entry, "last_prompt_tokens", 0))
|
||||
|
||||
user_config: dict[str, Any] = {}
|
||||
if not model_name or not provider_name or not context_total:
|
||||
try:
|
||||
user_config = _load_gateway_config()
|
||||
except Exception:
|
||||
user_config = {}
|
||||
if not model_name:
|
||||
model_name = _resolve_gateway_model(user_config)
|
||||
if not provider_name:
|
||||
model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {}
|
||||
if isinstance(model_cfg, dict):
|
||||
provider_name = _clean_str(model_cfg.get("provider"))
|
||||
if not context_total:
|
||||
model_cfg = user_config.get("model", {}) if isinstance(user_config, dict) else {}
|
||||
configured_context = model_cfg.get("context_length") if isinstance(model_cfg, dict) else None
|
||||
if isinstance(configured_context, int) and configured_context > 0:
|
||||
context_total = configured_context
|
||||
|
||||
model_line = ""
|
||||
if model_name:
|
||||
if provider_name:
|
||||
model_line = t("gateway.status.model_provider", model=model_name, provider=provider_name)
|
||||
else:
|
||||
model_line = t("gateway.status.model", model=model_name)
|
||||
|
||||
context_line = ""
|
||||
if context_total:
|
||||
pct = min(100, round((context_used / context_total) * 100)) if context_total else 0
|
||||
context_line = t(
|
||||
"gateway.status.context",
|
||||
used=f"{context_used:,}",
|
||||
total=f"{context_total:,}",
|
||||
pct=f"{pct}",
|
||||
)
|
||||
elif context_used:
|
||||
context_line = t("gateway.status.context_used", used=f"{context_used:,}")
|
||||
|
||||
lines = [
|
||||
t("gateway.status.header"),
|
||||
"",
|
||||
@@ -443,6 +533,12 @@ class GatewaySlashCommandsMixin:
|
||||
lines.extend([
|
||||
t("gateway.status.created", timestamp=session_entry.created_at.strftime('%Y-%m-%d %H:%M')),
|
||||
t("gateway.status.last_activity", timestamp=session_entry.updated_at.strftime('%Y-%m-%d %H:%M')),
|
||||
])
|
||||
if model_line:
|
||||
lines.append(model_line)
|
||||
if context_line:
|
||||
lines.append(context_line)
|
||||
lines.extend([
|
||||
t("gateway.status.tokens", tokens=f"{db_total_tokens:,}"),
|
||||
t("gateway.status.agent_running", state=t("gateway.status.state_yes") if is_running else t("gateway.status.state_no")),
|
||||
])
|
||||
@@ -2845,6 +2941,52 @@ class GatewaySlashCommandsMixin:
|
||||
return t("gateway.resume.resumed_one", title=title, count=msg_count)
|
||||
return t("gateway.resume.resumed_many", title=title, count=msg_count)
|
||||
|
||||
async def _handle_sessions_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /sessions — list previous sessions for gateway chats."""
|
||||
if not self._session_db:
|
||||
from hermes_state import format_session_db_unavailable
|
||||
return format_session_db_unavailable(prefix=t("gateway.shared.session_db_unavailable_prefix"))
|
||||
|
||||
from hermes_cli.session_listing import (
|
||||
format_gateway_session_listing,
|
||||
parse_session_listing_args,
|
||||
query_session_listing,
|
||||
)
|
||||
|
||||
source = event.source
|
||||
raw_args = event.get_command_args().strip()
|
||||
try:
|
||||
include_all, include_unnamed, target = parse_session_listing_args(raw_args)
|
||||
except ValueError as exc:
|
||||
return t("gateway.resume.parse_error", error=exc)
|
||||
|
||||
if target:
|
||||
resume_event = dataclasses.replace(event, text=f"/resume {target}")
|
||||
return await self._handle_resume_command(resume_event)
|
||||
|
||||
current_entry = self.session_store.get_or_create_session(source)
|
||||
rows = query_session_listing(
|
||||
self._session_db,
|
||||
source=source.platform.value if source.platform else None,
|
||||
current_session_id=current_entry.session_id,
|
||||
include_all_sources=include_all,
|
||||
include_unnamed=include_unnamed,
|
||||
limit=10,
|
||||
exclude_sources=["tool"],
|
||||
)
|
||||
if source.platform == Platform.MATRIX and not include_all:
|
||||
rows = [
|
||||
row for row in rows
|
||||
if self._same_matrix_room(
|
||||
source, self._gateway_session_origin_for_id(str(row.get("id") or ""))
|
||||
)
|
||||
]
|
||||
return format_gateway_session_listing(
|
||||
rows,
|
||||
include_source=include_all,
|
||||
title="Sessions" if include_unnamed else "Named Sessions",
|
||||
)
|
||||
|
||||
async def _handle_branch_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /branch [name] — fork the current session into a new independent copy.
|
||||
|
||||
|
||||
@@ -643,6 +643,21 @@ def acquire_scoped_lock(scope: str, identity: str, metadata: Optional[dict[str,
|
||||
live_cmdline = _read_process_cmdline(existing_pid)
|
||||
if live_cmdline is not None or not _record_looks_like_gateway(existing):
|
||||
stale = True
|
||||
# Secondary defence against boot-time PID+start_time collisions:
|
||||
# systemd spawns core services deterministically, so an unrelated
|
||||
# process (e.g. cron) can land on the exact same PID and jiffy
|
||||
# count as a previous gateway. If both start_times are known and
|
||||
# match but the live process is not a gateway, and we can confirm
|
||||
# that by reading its cmdline, the lock is stale.
|
||||
if (
|
||||
not stale
|
||||
and existing.get("start_time") is not None
|
||||
and current_start is not None
|
||||
and not _looks_like_gateway_process(existing_pid)
|
||||
):
|
||||
live_cmdline = _read_process_cmdline(existing_pid)
|
||||
if live_cmdline is not None:
|
||||
stale = True
|
||||
# Check if process is stopped (Ctrl+Z / SIGTSTP) — stopped
|
||||
# processes still appear alive to _pid_exists but are not
|
||||
# actually running. Treat them as stale so --replace works.
|
||||
|
||||
+43
-10
@@ -197,6 +197,30 @@ class GatewayStreamConsumer:
|
||||
# this response and route through edit-based for graceful degradation.
|
||||
self._draft_failures = 0
|
||||
|
||||
def _metadata_for_send(
|
||||
self,
|
||||
*,
|
||||
final: bool = False,
|
||||
expect_edits: bool = False,
|
||||
) -> dict | None:
|
||||
"""Return per-send metadata for stream-created messages.
|
||||
|
||||
Mattermost treats notify-worthy sends as user-visible final content
|
||||
when deciding whether a broken thread root may fall back flat. Preview
|
||||
and progress sends keep their original metadata and remain thread-strict.
|
||||
|
||||
``expect_edits`` preserves the upstream Telegram streaming contract:
|
||||
preview messages that may be edited later must stay on the editable
|
||||
legacy send path, while fresh/fallback final sends can still use richer
|
||||
final-message delivery.
|
||||
"""
|
||||
meta = dict(self.metadata) if self.metadata else {}
|
||||
if expect_edits:
|
||||
meta["expect_edits"] = True
|
||||
if final:
|
||||
meta["notify"] = True
|
||||
return meta or None
|
||||
|
||||
@property
|
||||
def already_sent(self) -> bool:
|
||||
"""True if at least one message was sent or edited during the run."""
|
||||
@@ -513,7 +537,11 @@ class GatewayStreamConsumer:
|
||||
chunks_delivered = False
|
||||
reply_to = self._message_id or self._initial_reply_to_id
|
||||
for chunk in chunks:
|
||||
new_id = await self._send_new_chunk(chunk, reply_to)
|
||||
new_id = await self._send_new_chunk(
|
||||
chunk,
|
||||
reply_to,
|
||||
final=got_done,
|
||||
)
|
||||
if new_id is not None and new_id != reply_to:
|
||||
chunks_delivered = True
|
||||
self._accumulated = ""
|
||||
@@ -749,7 +777,13 @@ class GatewayStreamConsumer:
|
||||
# Strip trailing whitespace/newlines but preserve leading content
|
||||
return cleaned.rstrip()
|
||||
|
||||
async def _send_new_chunk(self, text: str, reply_to_id: Optional[str]) -> Optional[str]:
|
||||
async def _send_new_chunk(
|
||||
self,
|
||||
text: str,
|
||||
reply_to_id: Optional[str],
|
||||
*,
|
||||
final: bool = False,
|
||||
) -> Optional[str]:
|
||||
"""Send a new message chunk, optionally threaded to a previous message.
|
||||
|
||||
Returns the message_id so callers can thread subsequent chunks.
|
||||
@@ -758,15 +792,11 @@ class GatewayStreamConsumer:
|
||||
if not text.strip():
|
||||
return reply_to_id
|
||||
try:
|
||||
meta = dict(self.metadata) if self.metadata else {}
|
||||
# This chunk becomes the next edit target — adapters that support
|
||||
# rich final sends (Telegram) must keep it on the editable path.
|
||||
meta["expect_edits"] = True
|
||||
result = await self.adapter.send(
|
||||
chat_id=self.chat_id,
|
||||
content=text,
|
||||
reply_to=reply_to_id,
|
||||
metadata=meta,
|
||||
metadata=self._metadata_for_send(final=final, expect_edits=True),
|
||||
)
|
||||
if result.success and result.message_id:
|
||||
self._message_id = str(result.message_id)
|
||||
@@ -885,7 +915,7 @@ class GatewayStreamConsumer:
|
||||
result = await self.adapter.send(
|
||||
chat_id=self.chat_id,
|
||||
content=chunk,
|
||||
metadata=self.metadata,
|
||||
metadata=self._metadata_for_send(final=True),
|
||||
)
|
||||
if result.success:
|
||||
break
|
||||
@@ -1242,7 +1272,7 @@ class GatewayStreamConsumer:
|
||||
result = await self.adapter.send(
|
||||
chat_id=self.chat_id,
|
||||
content=text,
|
||||
metadata=self.metadata,
|
||||
metadata=self._metadata_for_send(final=True),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Fresh-final send failed, falling back to edit: %s", e)
|
||||
@@ -1532,7 +1562,10 @@ class GatewayStreamConsumer:
|
||||
chat_id=self.chat_id,
|
||||
content=text,
|
||||
reply_to=self._initial_reply_to_id,
|
||||
metadata={**(self.metadata or {}), "expect_edits": True},
|
||||
metadata=self._metadata_for_send(
|
||||
final=finalize,
|
||||
expect_edits=True,
|
||||
),
|
||||
)
|
||||
if result.success:
|
||||
if result.message_id:
|
||||
|
||||
+16
-2
@@ -145,8 +145,16 @@ def build_top_level_parser():
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION",
|
||||
# nargs="?" + const=True: bare `--resume` parses to the sentinel True,
|
||||
# which `hermes --tui` turns into the session picker
|
||||
# (HERMES_TUI_RESUME=picker). `--resume <id|title>` is unchanged.
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
help="Resume a previous session by ID or title",
|
||||
help=(
|
||||
"Resume a previous session by ID or title. With --tui, bare "
|
||||
"--resume (no argument) opens the session picker."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--continue",
|
||||
@@ -301,8 +309,14 @@ def build_top_level_parser():
|
||||
"--resume",
|
||||
"-r",
|
||||
metavar="SESSION_ID",
|
||||
# Same bare-flag picker sentinel as the top-level --resume.
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=argparse.SUPPRESS,
|
||||
help="Resume a previous session by ID (shown on exit)",
|
||||
help=(
|
||||
"Resume a previous session by ID (shown on exit). With --tui, "
|
||||
"bare --resume opens the session picker."
|
||||
),
|
||||
)
|
||||
chat_parser.add_argument(
|
||||
"--continue",
|
||||
|
||||
+255
-15
@@ -103,7 +103,12 @@ XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:acces
|
||||
XAI_OAUTH_REDIRECT_HOST = "127.0.0.1"
|
||||
XAI_OAUTH_REDIRECT_PORT = 56121
|
||||
XAI_OAUTH_REDIRECT_PATH = "/callback"
|
||||
XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
||||
# xAI/Grok OAuth access tokens are intentionally short-lived (about 6h in
|
||||
# current SuperGrok flows). A two-minute refresh window is too narrow for
|
||||
# gateway/cron workloads that may only touch the provider every 30 minutes,
|
||||
# leaving brief but noisy credential-expiry gaps. Refresh up to one hour
|
||||
# early so ordinary runtime calls keep the token warm without user reauth.
|
||||
XAI_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 3600
|
||||
QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"
|
||||
QWEN_OAUTH_TOKEN_URL = "https://chat.qwen.ai/api/v1/oauth2/token"
|
||||
QWEN_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
||||
@@ -1079,8 +1084,13 @@ def _load_auth_store(auth_file: Optional[Path] = None) -> Dict[str, Any]:
|
||||
return {"version": AUTH_STORE_VERSION, "providers": {}}
|
||||
|
||||
|
||||
def _save_auth_store(auth_store: Dict[str, Any]) -> Path:
|
||||
auth_file = _auth_file_path()
|
||||
def _save_auth_store(auth_store: Dict[str, Any], target_path: Optional[Path] = None) -> Path:
|
||||
# target_path=None preserves the existing contract (write the active
|
||||
# store at _auth_file_path()). An explicit path lets callers persist a
|
||||
# specific store — e.g. the global-root write-through for rotating xAI
|
||||
# OAuth grants (#43589) — reusing this function's atomic O_EXCL + 0o600
|
||||
# write so the root auth.json gets the same TOCTOU-safe treatment.
|
||||
auth_file = target_path if target_path is not None else _auth_file_path()
|
||||
auth_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Tighten parent dir to 0o700 so siblings can't traverse to creds.
|
||||
# No-op on Windows (POSIX mode bits not enforced); ignore failures.
|
||||
@@ -3796,6 +3806,26 @@ def resolve_codex_runtime_credentials(
|
||||
"last_refresh": None,
|
||||
"auth_mode": "chatgpt",
|
||||
}
|
||||
pool_rate_limit = _codex_pool_rate_limit_status()
|
||||
if pool_rate_limit:
|
||||
reset_at = pool_rate_limit.get("reset_at")
|
||||
if isinstance(reset_at, (int, float)) and reset_at > time.time():
|
||||
remaining = int(reset_at - time.time())
|
||||
message = (
|
||||
f"Codex provider quota exhausted (429); retry after {remaining}s. "
|
||||
"Credentials are still valid."
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
"Codex provider quota exhausted (429). Credentials are still valid; "
|
||||
"retry after the usage limit resets."
|
||||
)
|
||||
raise AuthError(
|
||||
message,
|
||||
provider="openai-codex",
|
||||
code=CODEX_RATE_LIMITED_CODE,
|
||||
relogin_required=False,
|
||||
)
|
||||
if read_error is not None:
|
||||
raise read_error
|
||||
raise AuthError(
|
||||
@@ -3842,6 +3872,79 @@ def resolve_codex_runtime_credentials(
|
||||
}
|
||||
|
||||
|
||||
def _codex_pool_rate_limit_status() -> Optional[Dict[str, Any]]:
|
||||
"""Return metadata for a pool-only Codex credential in quota cooldown."""
|
||||
def _parse_reset_at(value: Any) -> Optional[float]:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
numeric = float(value)
|
||||
if numeric <= 0:
|
||||
return None
|
||||
return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric
|
||||
if isinstance(value, str):
|
||||
raw = value.strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
numeric = float(raw)
|
||||
except ValueError:
|
||||
numeric = None
|
||||
if numeric is not None:
|
||||
return numeric / 1000.0 if numeric > 1_000_000_000_000 else numeric
|
||||
try:
|
||||
return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
try:
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
pool = auth_store.get("credential_pool")
|
||||
if not isinstance(pool, dict):
|
||||
return None
|
||||
entries = pool.get("openai-codex")
|
||||
if not isinstance(entries, list):
|
||||
return None
|
||||
now = time.time()
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
token = entry.get("access_token")
|
||||
if not isinstance(token, str) or not token.strip():
|
||||
continue
|
||||
if entry.get("last_status") != "exhausted":
|
||||
continue
|
||||
code = entry.get("last_error_code")
|
||||
reason = str(entry.get("last_error_reason") or "").lower()
|
||||
message = str(entry.get("last_error_message") or "").lower()
|
||||
is_rate_limited = (
|
||||
code == 429
|
||||
or "rate_limit" in reason
|
||||
or "usage_limit" in reason
|
||||
or "quota" in reason
|
||||
or "rate limit" in message
|
||||
or "usage limit" in message
|
||||
or "quota" in message
|
||||
)
|
||||
if not is_rate_limited:
|
||||
continue
|
||||
reset_at = _parse_reset_at(entry.get("last_error_reset_at"))
|
||||
if reset_at is not None and reset_at <= now:
|
||||
continue
|
||||
return {
|
||||
"label": entry.get("label"),
|
||||
"last_refresh": entry.get("last_refresh"),
|
||||
"reset_at": reset_at,
|
||||
"reason": entry.get("last_error_reason"),
|
||||
"message": entry.get("last_error_message"),
|
||||
}
|
||||
except Exception:
|
||||
logger.debug("Codex pool rate-limit lookup failed", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _pool_codex_access_token() -> str:
|
||||
"""Return the most-recent usable access_token from the openai-codex pool.
|
||||
|
||||
@@ -3886,13 +3989,64 @@ def _pool_codex_access_token() -> str:
|
||||
# xAI Grok OAuth — tokens stored in ~/.hermes/auth.json
|
||||
# =============================================================================
|
||||
|
||||
def _xai_oauth_state_from_store(auth_store: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Return usable xAI OAuth state from provider state or credential pool."""
|
||||
state = _load_provider_state(auth_store, "xai-oauth")
|
||||
tokens = state.get("tokens") if isinstance(state, dict) else None
|
||||
if isinstance(tokens, dict):
|
||||
access_token = str(tokens.get("access_token", "") or "").strip()
|
||||
refresh_token = str(tokens.get("refresh_token", "") or "").strip()
|
||||
if access_token and refresh_token:
|
||||
return state
|
||||
|
||||
credential_pool = auth_store.get("credential_pool")
|
||||
entries = (
|
||||
credential_pool.get("xai-oauth")
|
||||
if isinstance(credential_pool, dict)
|
||||
else None
|
||||
)
|
||||
if isinstance(entries, list):
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
access_token = str(entry.get("access_token", "") or "").strip()
|
||||
refresh_token = str(entry.get("refresh_token", "") or "").strip()
|
||||
if not access_token or not refresh_token:
|
||||
continue
|
||||
merged = dict(state or {})
|
||||
merged["tokens"] = {
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": str(entry.get("token_type") or "Bearer"),
|
||||
}
|
||||
if entry.get("last_refresh"):
|
||||
merged["last_refresh"] = entry.get("last_refresh")
|
||||
merged.setdefault("auth_mode", "oauth_pkce")
|
||||
return merged
|
||||
|
||||
return state if isinstance(state, dict) else None
|
||||
|
||||
|
||||
def _xai_oauth_state_has_usable_tokens(state: Optional[Dict[str, Any]]) -> bool:
|
||||
tokens = state.get("tokens") if isinstance(state, dict) else None
|
||||
return (
|
||||
isinstance(tokens, dict)
|
||||
and bool(str(tokens.get("access_token", "") or "").strip())
|
||||
and bool(str(tokens.get("refresh_token", "") or "").strip())
|
||||
)
|
||||
|
||||
|
||||
def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]:
|
||||
if _lock:
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
else:
|
||||
auth_store = _load_auth_store()
|
||||
state = _load_provider_state(auth_store, "xai-oauth")
|
||||
state = _xai_oauth_state_from_store(auth_store)
|
||||
if not _xai_oauth_state_has_usable_tokens(state):
|
||||
global_state = _xai_oauth_state_from_store(_load_global_auth_store())
|
||||
if _xai_oauth_state_has_usable_tokens(global_state):
|
||||
state = global_state
|
||||
if not state:
|
||||
raise AuthError(
|
||||
"No xAI OAuth credentials stored. Select xAI Grok OAuth (SuperGrok / Premium+) in `hermes model`.",
|
||||
@@ -3932,6 +4086,62 @@ def _read_xai_oauth_tokens(*, _lock: bool = True) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _profile_has_own_xai_oauth_state(auth_store: Dict[str, Any]) -> bool:
|
||||
"""True when this store has its OWN ``providers.xai-oauth`` block.
|
||||
|
||||
Distinguishes a profile that genuinely shadows the root xAI grant from
|
||||
one that only *reads* root via ``_load_provider_state``'s fallback. Only
|
||||
the latter needs the refresh write-through below.
|
||||
"""
|
||||
providers = auth_store.get("providers")
|
||||
return isinstance(providers, dict) and isinstance(providers.get("xai-oauth"), dict)
|
||||
|
||||
|
||||
def _write_through_xai_oauth_to_global_root(state: Dict[str, Any]) -> None:
|
||||
"""Persist a rotated xAI OAuth ``state`` into the global-root auth.json.
|
||||
|
||||
Best-effort write-through for the multi-profile rotation hazard (#43589):
|
||||
xAI rotates the refresh_token on every refresh, so when a profile session
|
||||
refreshes a grant it resolved from the root fallback, the rotated chain
|
||||
must land back in root. Otherwise root keeps a now-revoked refresh token
|
||||
and every other profile reading the stale root grant dies with
|
||||
``invalid_grant`` once its access token expires.
|
||||
|
||||
Only updates ``providers.xai-oauth`` in the root store; never touches the
|
||||
profile store (the caller already saved that). Swallows all errors — a
|
||||
failed write-through degrades to the pre-existing behavior (root stale),
|
||||
it must never break the profile's own successful save.
|
||||
"""
|
||||
global_path = _global_auth_file_path()
|
||||
if global_path is None:
|
||||
# Classic mode (profile == root); the profile save already hit root.
|
||||
return
|
||||
# Seat belt: under pytest, refuse to write the real user's
|
||||
# ~/.hermes/auth.json even when HERMES_HOME points at a profile path
|
||||
# (mirrors the read-side guard in _load_global_auth_store). Uses the
|
||||
# unmodified HOME env, not Path.home() which fixtures may monkeypatch.
|
||||
if os.environ.get("PYTEST_CURRENT_TEST"):
|
||||
real_home_env = os.environ.get("HOME", "")
|
||||
if real_home_env:
|
||||
real_root = Path(real_home_env) / ".hermes" / "auth.json"
|
||||
try:
|
||||
if global_path.resolve(strict=False) == real_root.resolve(strict=False):
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
if global_path.exists():
|
||||
global_store = _load_auth_store(global_path)
|
||||
else:
|
||||
global_store = {}
|
||||
if not isinstance(global_store, dict):
|
||||
return
|
||||
_store_provider_state(global_store, "xai-oauth", dict(state), set_active=False)
|
||||
_save_auth_store(global_store, global_path)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.debug("xAI OAuth: write-through to global root failed: %s", exc)
|
||||
|
||||
|
||||
def _save_xai_oauth_tokens(
|
||||
tokens: Dict[str, Any],
|
||||
*,
|
||||
@@ -3943,6 +4153,11 @@ def _save_xai_oauth_tokens(
|
||||
last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
# A profile that lacks its own xai-oauth block is reading the root
|
||||
# grant through _load_provider_state's fallback. When such a profile
|
||||
# refreshes the (rotating) grant, we must write the rotated chain back
|
||||
# to root too, or root is left holding a revoked refresh token (#43589).
|
||||
write_through_to_root = not _profile_has_own_xai_oauth_state(auth_store)
|
||||
state = _load_provider_state(auth_store, "xai-oauth") or {}
|
||||
state["tokens"] = tokens
|
||||
state["last_refresh"] = last_refresh
|
||||
@@ -3953,6 +4168,8 @@ def _save_xai_oauth_tokens(
|
||||
state["redirect_uri"] = redirect_uri
|
||||
_save_provider_state(auth_store, "xai-oauth", state)
|
||||
_save_auth_store(auth_store)
|
||||
if write_through_to_root:
|
||||
_write_through_xai_oauth_to_global_root(state)
|
||||
|
||||
|
||||
def _xai_access_token_is_expiring(access_token: str, skew_seconds: int = 0) -> bool:
|
||||
@@ -5639,18 +5856,24 @@ def _snapshot_nous_pool_status() -> Dict[str, Any]:
|
||||
# subscription-feature checks) call it many times per render — `hermes tools` → "All Platforms"
|
||||
# was firing the refresh ~31× during one menu paint, racking up >13s of HTTP and burning
|
||||
# single-use refresh tokens. Cache the snapshot for a few seconds, keyed on the auth.json
|
||||
# mtime so that `hermes auth login/logout/add/remove` invalidate naturally on the next call.
|
||||
# path + mtime so that profile switches do not share a process memo and
|
||||
# `hermes auth login/logout/add/remove` invalidate naturally on the next call.
|
||||
_NOUS_AUTH_STATUS_CACHE_TTL = 15.0 # seconds
|
||||
_nous_auth_status_cache: Optional[Tuple[float, Optional[float], Dict[str, Any]]] = None
|
||||
_nous_auth_status_cache: Optional[Tuple[float, str, Optional[float], Dict[str, Any]]] = None
|
||||
|
||||
|
||||
def _auth_file_mtime() -> Optional[float]:
|
||||
def _auth_file_cache_key() -> Tuple[str, Optional[float]]:
|
||||
auth_file = _auth_file_path()
|
||||
try:
|
||||
return _auth_file_path().stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
auth_file_key = str(auth_file.resolve(strict=False))
|
||||
except Exception:
|
||||
return None
|
||||
auth_file_key = str(auth_file)
|
||||
try:
|
||||
return auth_file_key, auth_file.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
return auth_file_key, None
|
||||
except Exception:
|
||||
return auth_file_key, None
|
||||
|
||||
|
||||
def invalidate_nous_auth_status_cache() -> None:
|
||||
@@ -5682,18 +5905,19 @@ def get_nous_auth_status() -> Dict[str, Any]:
|
||||
"""
|
||||
global _nous_auth_status_cache
|
||||
now = time.monotonic()
|
||||
mtime = _auth_file_mtime()
|
||||
auth_file_key, mtime = _auth_file_cache_key()
|
||||
cached = _nous_auth_status_cache
|
||||
if cached is not None:
|
||||
cached_at, cached_mtime, cached_status = cached
|
||||
cached_at, cached_auth_file_key, cached_mtime, cached_status = cached
|
||||
if (
|
||||
cached_mtime == mtime
|
||||
cached_auth_file_key == auth_file_key
|
||||
and cached_mtime == mtime
|
||||
and (now - cached_at) < _NOUS_AUTH_STATUS_CACHE_TTL
|
||||
):
|
||||
return dict(cached_status)
|
||||
|
||||
status = _compute_nous_auth_status()
|
||||
_nous_auth_status_cache = (now, mtime, dict(status))
|
||||
_nous_auth_status_cache = (now, auth_file_key, mtime, dict(status))
|
||||
return status
|
||||
|
||||
|
||||
@@ -5776,6 +6000,22 @@ def get_codex_auth_status() -> Dict[str, Any]:
|
||||
"source": f"pool:{getattr(entry, 'label', 'unknown')}",
|
||||
"api_key": api_key,
|
||||
}
|
||||
rate_limit = _codex_pool_rate_limit_status()
|
||||
if rate_limit:
|
||||
return {
|
||||
"logged_in": True,
|
||||
"auth_store": str(_auth_file_path()),
|
||||
"last_refresh": rate_limit.get("last_refresh"),
|
||||
"auth_mode": "chatgpt",
|
||||
"source": f"pool:{rate_limit.get('label') or 'unknown'}",
|
||||
"rate_limited": True,
|
||||
"error_code": CODEX_RATE_LIMITED_CODE,
|
||||
"error": (
|
||||
rate_limit.get("message")
|
||||
or "Codex provider quota exhausted; retry after the usage limit resets."
|
||||
),
|
||||
"reset_at": rate_limit.get("reset_at"),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -510,6 +510,7 @@ _QUICK_STATE_FILES = (
|
||||
"cron/jobs.json",
|
||||
"gateway_state.json",
|
||||
"channel_directory.json",
|
||||
"channel_aliases.json",
|
||||
"processes.json",
|
||||
# Pairing stores (generic + per-platform JSONs outside state.db)
|
||||
"pairing", # legacy location (gateway/pairing.py)
|
||||
|
||||
@@ -225,7 +225,8 @@ class CLICommandsMixin:
|
||||
print(" Usage: /snapshot [list|create [label]|restore <id>|prune [N]]")
|
||||
|
||||
def _handle_stop_command(self):
|
||||
"""Handle /stop — kill all running background processes.
|
||||
"""Handle /stop — kill all running background processes and
|
||||
background (async) delegations.
|
||||
|
||||
Inspired by OpenAI Codex's separation of interrupt (stop current turn)
|
||||
from /stop (clean up background processes). See openai/codex#14602.
|
||||
@@ -235,13 +236,26 @@ class CLICommandsMixin:
|
||||
processes = process_registry.list_sessions()
|
||||
running = [p for p in processes if p.get("status") == "running"]
|
||||
|
||||
if not running:
|
||||
# Background subagents dispatched via delegate_task(background=true)
|
||||
# live in their own registry, not the process registry.
|
||||
try:
|
||||
from tools.async_delegation import active_count, interrupt_all
|
||||
n_async = active_count()
|
||||
except Exception:
|
||||
n_async = 0
|
||||
interrupt_all = None
|
||||
|
||||
if not running and not n_async:
|
||||
print(" No running background processes.")
|
||||
return
|
||||
|
||||
print(f" Stopping {len(running)} background process(es)...")
|
||||
killed = process_registry.kill_all()
|
||||
print(f" ✅ Stopped {killed} process(es).")
|
||||
if running:
|
||||
print(f" Stopping {len(running)} background process(es)...")
|
||||
killed = process_registry.kill_all()
|
||||
print(f" ✅ Stopped {killed} process(es).")
|
||||
if n_async and interrupt_all is not None:
|
||||
stopped = interrupt_all(reason="/stop")
|
||||
print(f" ✅ Interrupted {stopped} background delegation(s).")
|
||||
|
||||
def _handle_agents_command(self):
|
||||
"""Handle /agents — show background processes and agent status."""
|
||||
@@ -261,6 +275,22 @@ class CLICommandsMixin:
|
||||
if finished:
|
||||
_cprint(f" Recently finished: {len(finished)}")
|
||||
|
||||
# Background (async) delegations — delegate_task(background=true)
|
||||
try:
|
||||
from tools.async_delegation import list_async_delegations
|
||||
delegations = list_async_delegations()
|
||||
except Exception:
|
||||
delegations = []
|
||||
running_d = [d for d in delegations if d.get("status") == "running"]
|
||||
if delegations:
|
||||
_cprint(f" Background delegations: {len(running_d)} running")
|
||||
for d in delegations:
|
||||
goal = (d.get("goal") or "")[:60]
|
||||
_cprint(
|
||||
f" {d.get('delegation_id', '?')} · "
|
||||
f"{d.get('status', '?')} · {goal}"
|
||||
)
|
||||
|
||||
agent_running = getattr(self, "_agent_running", False)
|
||||
_cprint(f" Agent: {'running' if agent_running else 'idle'}")
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
args_hint="[text | pause | resume | clear | status]"),
|
||||
CommandDef("subgoal", "Add or manage extra criteria on the active goal", "Session",
|
||||
args_hint="[text | remove N | clear]"),
|
||||
CommandDef("status", "Show session info", "Session"),
|
||||
CommandDef("status", "Show session, model, token, and context info", "Session"),
|
||||
CommandDef("whoami", "Show your slash command access (admin / user)", "Info"),
|
||||
CommandDef("profile", "Show active profile name and home directory", "Info"),
|
||||
CommandDef("sethome", "Set this chat as the home channel", "Session",
|
||||
@@ -1053,7 +1053,8 @@ _SLACK_PRIORITY_ALIASES = ("btw", "bg")
|
||||
# the telegram-parity test reads it so an entry here is a deliberate
|
||||
# "Slack-via-/hermes" decision, not a silent clamp.
|
||||
# - credits: the billing/top-up surface; reached via /hermes credits on Slack.
|
||||
_SLACK_VIA_HERMES_ONLY = frozenset({"credits"})
|
||||
# - debug: the log/report upload surface; reached via /hermes debug on Slack.
|
||||
_SLACK_VIA_HERMES_ONLY = frozenset({"credits", "debug"})
|
||||
|
||||
|
||||
def _sanitize_slack_name(raw: str) -> str:
|
||||
|
||||
+14
-1
@@ -1428,6 +1428,12 @@ DEFAULT_CONFIG = {
|
||||
"tui_agents_nudge": True,
|
||||
"bell_on_complete": False,
|
||||
"show_reasoning": False,
|
||||
# Background self-improvement review notifications surfaced in chat.
|
||||
# "off" — no chat notification (the review still runs and writes)
|
||||
# "on" — generic "💾 Memory updated" line (default)
|
||||
# "verbose" — include a compact content preview of what changed
|
||||
# Per-platform overrides via display.platforms.<platform>.memory_notifications.
|
||||
"memory_notifications": "on",
|
||||
"streaming": False,
|
||||
"timestamps": False, # Show [HH:MM] on user and assistant labels
|
||||
"final_response_markdown": "strip", # render | strip | raw
|
||||
@@ -1479,6 +1485,12 @@ DEFAULT_CONFIG = {
|
||||
"tool_progress_command": False, # Enable /verbose command in messaging gateway
|
||||
"tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead
|
||||
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
|
||||
# How gateway tool-progress is grouped on platforms that support message
|
||||
# editing: "accumulate" (default) edits one bubble in place; "separate"
|
||||
# sends one message per tool (the pre-v0.9 behavior, noisier). Only
|
||||
# applies where tool_progress is already enabled. Per-platform override
|
||||
# via display.platforms.<platform>.tool_progress_grouping.
|
||||
"tool_progress_grouping": "accumulate",
|
||||
# Auto-delete system-notice replies (e.g. "✨ New session started!",
|
||||
# "♻ Restarting gateway…", "⚡ Stopped…") after N seconds on platforms
|
||||
# that support message deletion (currently Telegram; other platforms
|
||||
@@ -1775,6 +1787,7 @@ DEFAULT_CONFIG = {
|
||||
"reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium",
|
||||
# "low", "minimal", "none" (empty = inherit parent's level)
|
||||
"max_concurrent_children": 3, # max parallel children per batch; floor of 1 enforced, no ceiling
|
||||
"max_async_children": 3, # max concurrent background (background=true) subagents; new dispatches rejected at capacity
|
||||
# Orchestrator role controls (see tools/delegate_tool.py:_get_max_spawn_depth
|
||||
# and _get_orchestrator_enabled). Floored at 1, no upper ceiling —
|
||||
# raise deliberately, each level multiplies API cost.
|
||||
@@ -1990,7 +2003,7 @@ DEFAULT_CONFIG = {
|
||||
"channel_prompts": {}, # Per-chat/topic ephemeral system prompts (topics inherit from parent group)
|
||||
"allowed_chats": "", # If set, bot ONLY responds in these group/supergroup chat IDs (whitelist)
|
||||
"extra": {
|
||||
"rich_messages": False, # Opt in to Bot API 10.1 rich messages; default uses legacy MarkdownV2
|
||||
"rich_messages": True, # Bot API 10.1 rich messages (tables/task lists/details/math) render natively; set False to force legacy MarkdownV2
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -28,11 +28,14 @@ from typing import Literal, Sequence
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Only this prior state triggers automatic restart. Everything else
|
||||
# Only this desired state triggers automatic restart. Everything else
|
||||
# (startup_failed, starting, stopped, missing) registers the slot in
|
||||
# the down state and waits for explicit user action — this avoids the
|
||||
# crash-loop where a broken gateway keeps being restarted across
|
||||
# `docker restart` cycles.
|
||||
# `docker restart` cycles. Older installs only have gateway_state;
|
||||
# newer lifecycle commands persist desired_state separately so a transient
|
||||
# runtime state (draining/startup_failed) does not erase the operator's
|
||||
# durable start/stop intent across pod/container recreation.
|
||||
_AUTOSTART_STATES = frozenset({"running"})
|
||||
|
||||
# Stale runtime files we sweep before recreating service slots. These
|
||||
@@ -104,7 +107,7 @@ def reconcile_profile_gateways(
|
||||
container_argv=container_argv,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
default_prior_state = legacy_default_state or _read_prior_state(hermes_home)
|
||||
default_prior_state = legacy_default_state or _read_desired_state(hermes_home)
|
||||
default_should_start = default_prior_state in _AUTOSTART_STATES
|
||||
if not dry_run:
|
||||
_cleanup_stale_runtime_files(hermes_home)
|
||||
@@ -139,7 +142,7 @@ def reconcile_profile_gateways(
|
||||
)
|
||||
continue
|
||||
|
||||
prior_state = _read_prior_state(entry)
|
||||
prior_state = _read_desired_state(entry)
|
||||
should_start = prior_state in _AUTOSTART_STATES
|
||||
|
||||
if not dry_run:
|
||||
@@ -188,6 +191,7 @@ def _maybe_migrate_legacy_gateway_run_state(
|
||||
import time
|
||||
state_file.write_text(json.dumps({
|
||||
"gateway_state": "running",
|
||||
"desired_state": "running",
|
||||
"timestamp": int(time.time()),
|
||||
"migrated_from": "legacy-container-cmd",
|
||||
}) + "\n")
|
||||
@@ -203,8 +207,15 @@ def _read_container_argv() -> tuple[str, ...]:
|
||||
return tuple(part.decode("utf-8", "replace") for part in raw.split(b"\0") if part)
|
||||
|
||||
|
||||
def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool:
|
||||
"""Return True for Docker commands equivalent to `gateway run`."""
|
||||
def _strip_container_argv_prefix(argv: Sequence[str]) -> list[str]:
|
||||
"""Strip the s6/wrapper prefix off PID 1 argv, leaving the hermes args.
|
||||
|
||||
The container PID 1 argv looks like
|
||||
``/init /opt/hermes/docker/main-wrapper.sh <subcommand> [args...]`` and
|
||||
the wrapper re-execs ``hermes <subcommand>``. Peel ``init`` →
|
||||
``main-wrapper.sh`` → ``hermes`` so callers can match on the bare
|
||||
subcommand. Shared by the legacy-gateway and dashboard role detectors.
|
||||
"""
|
||||
args = list(argv)
|
||||
if args and Path(args[0]).name == "init":
|
||||
args = args[1:]
|
||||
@@ -212,20 +223,58 @@ def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool:
|
||||
args = args[1:]
|
||||
if args and Path(args[0]).name == "hermes":
|
||||
args = args[1:]
|
||||
return args
|
||||
|
||||
|
||||
def _is_legacy_gateway_run_request(argv: Sequence[str]) -> bool:
|
||||
"""Return True for Docker commands equivalent to `gateway run`."""
|
||||
args = _strip_container_argv_prefix(argv)
|
||||
if "--no-supervise" in args:
|
||||
return False
|
||||
return len(args) >= 2 and args[0] == "gateway" and args[1] == "run"
|
||||
|
||||
|
||||
def _read_prior_state(profile_dir: Path) -> str | None:
|
||||
"""Read gateway_state.json's ``gateway_state`` field, or None if
|
||||
missing or unparseable. Unparseable counts as "no prior state" so
|
||||
we don't bork the whole reconciliation on a corrupt file."""
|
||||
def _is_dashboard_container(argv: Sequence[str]) -> bool:
|
||||
"""Return True when the container's command is the dashboard.
|
||||
|
||||
A dashboard-only container (``hermes dashboard ...``) never spawns or
|
||||
supervises per-profile gateways — that is the gateway container's job.
|
||||
Reconciling profile gateway s6 slots there is not just wasted work: when
|
||||
the gateway and dashboard containers share a bind-mounted HERMES_HOME,
|
||||
both race to ``flock()`` the same ``logs/gateways/<profile>/lock`` files,
|
||||
producing "Resource busy" failures and an s6-log restart storm. So the
|
||||
dashboard container skips reconciliation entirely.
|
||||
|
||||
Detected from PID 1 argv (``/proc/1/cmdline``) rather than an operator
|
||||
flag: the role is a fact about the container's command, not a tunable,
|
||||
and a flag can be forgotten in a hand-written compose/k8s manifest —
|
||||
reintroducing the exact storm this prevents. Mirrors the argv handling
|
||||
in :func:`_is_legacy_gateway_run_request`.
|
||||
"""
|
||||
args = _strip_container_argv_prefix(argv)
|
||||
return bool(args) and args[0] == "dashboard"
|
||||
|
||||
|
||||
def _read_desired_state(profile_dir: Path) -> str | None:
|
||||
"""Read the persisted gateway desired state for reconciliation.
|
||||
|
||||
Newer state files carry ``desired_state``: operator intent written by
|
||||
s6 lifecycle commands. Older files only carry ``gateway_state``; keep
|
||||
that as a compatibility fallback so existing running/stopped profiles
|
||||
preserve their behavior until the next explicit start/stop.
|
||||
|
||||
Missing or unparseable files count as "no desired state" so we don't
|
||||
bork the whole reconciliation on a corrupt file.
|
||||
"""
|
||||
state_file = profile_dir / "gateway_state.json"
|
||||
if not state_file.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(state_file.read_text()).get("gateway_state")
|
||||
data = json.loads(state_file.read_text())
|
||||
desired_state = data.get("desired_state")
|
||||
if desired_state is not None:
|
||||
return desired_state
|
||||
return data.get("gateway_state")
|
||||
except (OSError, json.JSONDecodeError):
|
||||
log.warning(
|
||||
"could not read %s; treating as no prior state", state_file,
|
||||
@@ -378,6 +427,22 @@ _LOG_ROTATE_BYTES = 256 * 1024
|
||||
|
||||
def main() -> int:
|
||||
"""Entry point invoked from /etc/cont-init.d/02-reconcile-profiles."""
|
||||
# A dashboard-only container never spawns or supervises per-profile
|
||||
# gateways, so reconciling their s6 slots here is pure waste — and
|
||||
# actively harmful: when the gateway and dashboard containers share a
|
||||
# bind-mounted HERMES_HOME, both race to flock() the same s6-log lock
|
||||
# files under logs/gateways/<profile>/lock, producing "Resource busy"
|
||||
# failures and a restart storm. Detect the role from PID 1 argv and
|
||||
# skip reconciliation in the dashboard container. No operator flag:
|
||||
# the role is a fact about the container's command, and a flag can be
|
||||
# forgotten in a hand-written manifest, reintroducing the storm.
|
||||
if _is_dashboard_container(_read_container_argv()):
|
||||
print(
|
||||
"reconcile: skipping (dashboard container — does not need "
|
||||
"per-profile gateways)"
|
||||
)
|
||||
return 0
|
||||
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", "/opt/data"))
|
||||
scandir = Path(os.environ.get("S6_PROFILE_GATEWAY_SCANDIR", "/run/service"))
|
||||
actions = reconcile_profile_gateways(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user