Compare commits

..

1 Commits

Author SHA1 Message Date
Brooklyn Nicholson
a6d8ed484e fix(desktop): allow worktree node_modules in vite dev server
`hgui` symlinks a worktree's node_modules to the main checkout. Vite
realpaths those before enforcing server.fs.allow, so codicon/font assets
resolved outside the worktree root and 404'd — codicons silently failed
to render in any worktree dev session. Whitelist the real node_modules
locations so the fix holds from any checkout.
2026-06-06 14:32:25 -05:00
590 changed files with 17593 additions and 53621 deletions

View File

@@ -59,22 +59,12 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Rebuild the unified catalog. The file is gitignored, so a fresh
# checkout starts without it and we want the freshest crawl in
# every deploy.
#
# This MUST be fatal. build_skills_index.py runs a health check and
# exits non-zero WITHOUT writing the output file when a source
# collapses (e.g. a GitHub API rate limit zeroes the github /
# claude-marketplace / well-known taps all at once). Letting the
# deploy continue would either (a) ship a degenerate index missing
# whole hubs — the June 2026 regression where OpenAI/Anthropic/
# HuggingFace/NVIDIA tabs vanished — or (b) fall through to a
# local-only catalog. Failing here keeps the last good deployment
# live (GitHub Pages serves the previous build) instead of
# publishing a broken catalog. Re-run the workflow once the
# transient rate limit clears.
python3 scripts/build_skills_index.py
# Always rebuild the file isn't committed (gitignored), so a
# fresh checkout starts without it and we want the freshest crawl
# in every deploy. Failure is non-fatal: extract-skills.py will
# fall back to the legacy snapshot cache and the Skills Hub page
# still renders, just without the latest community catalog.
python3 scripts/build_skills_index.py || echo "Skills index build failed (non-fatal)"
- name: Extract skill metadata for dashboard
run: python3 website/scripts/extract-skills.py

View File

@@ -75,10 +75,9 @@ jobs:
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)"
# Ensure only nix files were modified — prevents accidental
# self-triggering if fix-lockfiles ever touches package files.
unexpected="$(git diff --name-only | grep -Ev '^nix/(tui|web)\.nix$' || true)"
if [ -n "$unexpected" ]; then
echo "::error::Unexpected modified files: $unexpected"
exit 1
@@ -90,7 +89,7 @@ jobs:
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 add nix/tui.nix nix/web.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"
@@ -217,7 +216,7 @@ jobs:
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 add nix/tui.nix nix/web.nix
git commit -m "fix(nix): refresh npm lockfile hashes"
git push

View File

@@ -10,7 +10,6 @@
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
<a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Built%20by-Nous%20Research-blueviolet?style=for-the-badge" alt="Built by Nous Research"></a>
<a href="README.zh-CN.md"><img src="https://img.shields.io/badge/Lang-中文-red?style=for-the-badge" alt="中文"></a>
<a href="README.ur-pk.md"><img src="https://img.shields.io/badge/Lang-اردو-green?style=for-the-badge" alt="اردو"></a>
</p>
**The self-improving AI agent built by [Nous Research](https://nousresearch.com).** It's the only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, searches its own past conversations, and builds a deepening model of who you are across sessions. Run it on a $5 VPS, a GPU cluster, or serverless infrastructure that costs nearly nothing when idle. It's not tied to your laptop — talk to it from Telegram while it works on a cloud VM.
@@ -53,7 +52,7 @@ If you already have Git installed, the installer detects it and uses that instea
> **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies.
>
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux.
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively).
After installation:

View File

@@ -1,261 +0,0 @@
<div dir="rtl">
<p align="center">
<img src="assets/banner.png" alt="Hermes Agent" width="100%">
</p>
# ہرمیس ایجنٹ ☤ (Hermes Agent)
<p align="center">
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
<a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Built%20by-Nous%20Research-blueviolet?style=for-the-badge" alt="Built by Nous Research"></a>
<a href="README.md"><img src="https://img.shields.io/badge/Lang-English-lightgrey?style=for-the-badge" alt="English"></a>
<a href="README.zh-CN.md"><img src="https://img.shields.io/badge/Lang-中文-red?style=for-the-badge" alt="中文"></a>
</p>
**[نوس ریسرچ (Nous Research)](https://nousresearch.com) کا تیار کردہ خود کو بہتر بنانے والا اے آئی (AI) ایجنٹ۔** یہ واحد ایجنٹ ہے جس میں سیکھنے کا عمل (learning loop) پہلے سے موجود ہے — یہ اپنے تجربات سے نئی مہارتیں (skills) بناتا ہے، استعمال کے دوران ان کو بہتر کرتا ہے، معلومات کو محفوظ رکھنے کے لیے خود کو یاد دہانی کرواتا ہے، اپنی پرانی بات چیت کو تلاش کر سکتا ہے، اور مختلف سیشنز کے دوران آپ کے بارے میں ایک گہری سمجھ پیدا کرتا ہے۔ اسے $5 والے VPS پر چلائیں، GPU کلسٹر پر، یا سرور لیس (serverless) انفراسٹرکچر پر جس کی قیمت استعمال نہ ہونے پر تقریباً صفر ہے۔ یہ آپ کے لیپ ٹاپ تک محدود نہیں ہے — آپ ٹیلی گرام (Telegram) سے اس کے ساتھ بات چیت کر سکتے ہیں جبکہ یہ کلاؤڈ VM پر کام کر رہا ہو۔
آپ اپنی مرضی کا کوئی بھی ماڈل استعمال کر سکتے ہیں — [Nous Portal](https://portal.nousresearch.com)، [OpenRouter](https://openrouter.ai) (200 سے زائد ماڈلز)، [NovitaAI](https://novita.ai) (ماڈل API، ایجنٹ سینڈ باکس، اور GPU کلاؤڈ کے لیے اے آئی مقامی کلاؤڈ)، [NVIDIA NIM](https://build.nvidia.com) (Nemotron)، [Xiaomi MiMo](https://platform.xiaomimimo.com)، [z.ai/GLM](https://z.ai)، [Kimi/Moonshot](https://platform.moonshot.ai)، [MiniMax](https://www.minimax.io)، [Hugging Face](https://huggingface.co)، OpenAI، یا اپنا حسب ضرورت اینڈ پوائنٹ (endpoint) استعمال کریں۔ ماڈل تبدیل کرنے کے لیے صرف `hermes model` استعمال کریں — کسی کوڈ کو تبدیل کرنے کی ضرورت نہیں، کوئی پابندی نہیں۔
<table>
<tr><td><b>حقیقی ٹرمینل انٹرفیس</b></td><td>مکمل TUI جس میں ملٹی لائن ایڈیٹنگ، سلیش-کمانڈ آٹو کمپلیٹ، بات چیت کی ہسٹری، انٹرپٹ اور ری ڈائریکٹ، اور سٹریمنگ ٹول آؤٹ پٹ شامل ہے۔</td></tr>
<tr><td><b>یہ وہاں موجود ہے جہاں آپ ہیں</b></td><td>ٹیلی گرام، ڈسکارڈ (Discord)، سلیک (Slack)، واٹس ایپ (WhatsApp)، سگنل (Signal)، اور CLI — سب ایک ہی گیٹ وے پروسیس سے کام کرتے ہیں۔ وائس میمو (Voice memo) ٹرانسکرپشن، کراس پلیٹ فارم بات چیت کا تسلسل۔</td></tr>
<tr><td><b>سیکھنے کا ایک مکمل عمل</b></td><td>ایجنٹ کی اپنی ترتیب دی گئی میموری، جس میں وہ خود کو وقتاً فوقتاً یاد دہانی کرواتا ہے۔ پیچیدہ کاموں کے بعد خود کار طریقے سے مہارت (skill) کی تخلیق۔ استعمال کے دوران مہارتوں میں بہتری۔ LLM سمرائزیشن کے ساتھ FTS5 سیشن سرچ تاکہ پرانے سیشنز کی یاددہانی کی جا سکے۔ <a href="https://github.com/plastic-labs/honcho">Honcho</a> کے ذریعے صارف کی ماڈلنگ۔ <a href="https://agentskills.io">agentskills.io</a> اوپن سٹینڈرڈ کے ساتھ مکمل مطابقت۔</td></tr>
<tr><td><b>شیڈول کی گئی خودکار کارروائیاں</b></td><td>بلٹ ان (Built-in) کرون (cron) شیڈیولر جو کسی بھی پلیٹ فارم پر ڈیلیوری کے لیے استعمال ہو سکتا ہے۔ روزانہ کی رپورٹس، رات کے بیک اپس، ہفتہ وار آڈٹس — یہ سب کچھ قدرتی زبان (natural language) میں اور بغیر کسی نگرانی کے کام کرتا ہے۔</td></tr>
<tr><td><b>کام کی تقسیم اور متوازی عمل</b></td><td>متوازی (parallel) کاموں کے لیے الگ سے ذیلی ایجنٹس (subagents) بنائیں۔ پائتھون (Python) سکرپٹس لکھیں جو RPC کے ذریعے ٹولز کو استعمال کریں، تاکہ کئی مراحل پر مشتمل کاموں کو بغیر کسی سیاق و سباق (context) کے خرچ کے، ایک ہی باری میں انجام دیا جا سکے۔</td></tr>
<tr><td><b>کہیں بھی چلائیں، صرف اپنے لیپ ٹاپ پر نہیں</b></td><td>چھ (Six) ٹرمینل بیک اینڈز — لوکل، Docker، SSH، Singularity، Modal، اور Daytona۔ ڈیٹونا (Daytona) اور موڈل (Modal) سرور لیس (serverless) فعالیت پیش کرتے ہیں — جب آپ کا ایجنٹ فارغ ہوتا ہے تو اس کا ماحول سلیپ (hibernate) ہو جاتا ہے اور ضرورت پڑنے پر خود بخود جاگ جاتا ہے، جس کی وجہ سے سیشنز کے درمیان لاگت تقریباً صفر رہتی ہے۔ اسے $5 والے VPS یا GPU کلسٹر پر چلائیں۔</td></tr>
<tr><td><b>تحقیق کے لیے تیار</b></td><td>بیچ (Batch) ٹریجیکٹری (trajectory) جنریشن، اگلی نسل کے ٹول کالنگ ماڈلز کی تربیت کے لیے ٹریجیکٹری کمپریشن۔</td></tr>
</table>
---
## فوری انسٹالیشن (Quick Install)
### لینکس (Linux)، میک او ایس (macOS)، ڈبلیو ایس ایل ٹو (WSL2)، ٹرمکس (Termux)
<div dir="ltr">
```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
```
</div>
### ونڈوز (نیٹو، پاور شیل)
> **توجہ فرمائیں:** مقامی ونڈوز (Native Windows) پر ہرمیس بغیر WSL کے چلتا ہے — CLI، گیٹ وے، TUI، اور ٹولز سب مقامی طور پر کام کرتے ہیں۔ اگر آپ WSL2 استعمال کرنا پسند کرتے ہیں، تو اوپر دی گئی لینکس/میک او ایس کی کمانڈ وہاں بھی کام کرے گی۔ کوئی مسئلہ نظر آیا؟ براہ کرم [مسائل (issues) درج کریں](https://github.com/NousResearch/hermes-agent/issues)۔
اسے پاور شیل (PowerShell) میں چلائیں:
<div dir="ltr">
```powershell
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
```
</div>
انسٹالر سب کچھ خود سنبھالتا ہے: uv، Python 3.11، Node.js، ripgrep، ffmpeg، **اور ایک پورٹ ایبل (portable) گٹ بیش (Git Bash)** (یعنی MinGit، جو `%LOCALAPPDATA%\hermes\git` میں ان پیک ہوتا ہے — اس کے لیے ایڈمن کی اجازت درکار نہیں، اور یہ سسٹم کے کسی بھی گٹ انسٹال سے بالکل الگ ہے)۔ ہرمیس اس بنڈل شدہ گٹ بیش کو شیل کمانڈز چلانے کے لیے استعمال کرتا ہے۔
اگر آپ کے پاس پہلے سے گٹ (Git) انسٹال ہے، تو انسٹالر اسے شناخت کر لیتا ہے اور اسے ہی استعمال کرتا ہے۔ بصورت دیگر آپ کو صرف ~45MB کے MinGit ڈاؤنلوڈ کی ضرورت ہوگی — یہ آپ کے سسٹم کے گٹ پر کوئی اثر نہیں ڈالے گا۔
> **اینڈرائیڈ (Android) / ٹرمکس (Termux):** ٹیسٹ کیا گیا مینوئل طریقہ [Termux گائیڈ](https://hermes-agent.nousresearch.com/docs/getting-started/termux) میں موجود ہے۔ ٹرمکس پر ہرمیس ایک مخصوص `.[termux]` ایکسٹرا انسٹال کرتا ہے کیونکہ مکمل `.[all]` ایکسٹرا میں ایسی وائس ڈیپینڈینسیز شامل ہیں جو اینڈرائیڈ کے ساتھ مطابقت نہیں رکھتیں۔
>
> **ونڈوز (Windows):** مقامی ونڈوز کی مکمل سپورٹ موجود ہے — اوپر دی گئی پاور شیل کی کمانڈ سب کچھ انسٹال کر دیتی ہے۔ اگر آپ WSL2 استعمال کرنا چاہتے ہیں، تو لینکس کی کمانڈ وہاں کام کرتی ہے۔ مقامی ونڈوز میں انسٹالیشن `%LOCALAPPDATA%\hermes` میں ہوتی ہے؛ جبکہ WSL2 میں لینکس کی طرح `~/.hermes` میں ہوتی ہے۔ ہرمیس کا وہ واحد فیچر جسے فی الحال خاص طور پر WSL2 کی ضرورت ہے وہ براؤزر پر مبنی ڈیش بورڈ چیٹ پین ہے (یہ POSIX PTY استعمال کرتا ہے — کلاسک CLI اور گیٹ وے دونوں مقامی طور پر چلتے ہیں)۔
انسٹالیشن کے بعد:
<div dir="ltr">
```bash
source ~/.bashrc # شیل کو ری لوڈ کریں (یا: source ~/.zshrc)
hermes # بات چیت شروع کریں!
```
</div>
---
## آغاز کریں (Getting Started)
<div dir="ltr">
```bash
hermes # انٹرایکٹو CLI — بات چیت شروع کریں
hermes model # اپنا LLM پرووائیڈر اور ماڈل منتخب کریں
hermes tools # کنفیگر کریں کہ کون سے ٹولز ایکٹو ہیں
hermes config set # انفرادی کنفگ (config) ویلیوز سیٹ کریں
hermes gateway # میسجنگ گیٹ وے شروع کریں (ٹیلی گرام، ڈسکارڈ، وغیرہ)
hermes setup # مکمل سیٹ اپ وزرڈ چلائیں (یہ سب کچھ ایک ساتھ کنفیگر کر دے گا)
hermes claw migrate # OpenClaw سے مائیگریٹ کریں (اگر آپ OpenClaw سے آ رہے ہیں)
hermes update # لیٹسٹ ورژن پر اپ ڈیٹ کریں
hermes doctor # کسی بھی مسئلے کی تشخیص کریں
```
</div>
📖 **[مکمل دستاویزات →](https://hermes-agent.nousresearch.com/docs/)**
---
## API-کیز اکٹھی کرنے سے بچیں — Nous Portal
ہرمیس آپ کے پسندیدہ پرووائیڈر کے ساتھ کام کرتا ہے — یہ چیز تبدیل نہیں ہو رہی۔ لیکن اگر آپ ماڈل، ویب سرچ، امیج جنریشن، TTS، اور کلاؤڈ براؤزر کے لیے پانچ الگ الگ API کیز جمع نہیں کرنا چاہتے، تو **[Nous Portal](https://portal.nousresearch.com)** ان سب کو ایک ہی سبسکرپشن کے تحت کور کرتا ہے:
- **300+ ماڈلز** — ان میں سے کوئی بھی ماڈل `/model <name>` کے ذریعے منتخب کریں
- **ٹول گیٹ وے (Tool Gateway)** — ویب سرچ (Firecrawl)، امیج جنریشن (FAL)، ٹیکسٹ ٹو سپیچ (OpenAI)، کلاؤڈ براؤزر (Browser Use)، یہ سب آپ کی سبسکرپشن کے ذریعے چلتے ہیں۔ کسی اضافی اکاؤنٹ کی ضرورت نہیں۔
نئی انسٹالیشن کے بعد بس ایک کمانڈ کی ضرورت ہے:
<div dir="ltr">
```bash
hermes setup --portal
```
</div>
یہ آپ کو OAuth کے ذریعے لاگ ان کرواتا ہے، Nous کو آپ کا پرووائیڈر مقرر کرتا ہے، اور ٹول گیٹ وے کو آن کر دیتا ہے۔ `hermes portal info` کمانڈ استعمال کر کے آپ کسی بھی وقت چیک کر سکتے ہیں کہ کون کون سی سروسز منسلک ہیں۔ مکمل تفصیلات [Tool Gateway دستاویزات کے صفحے](https://hermes-agent.nousresearch.com/docs/user-guide/features/tool-gateway) پر موجود ہیں۔
آپ اب بھی کسی بھی ٹول کے لیے اپنی مرضی کی API کیز استعمال کر سکتے ہیں — گیٹ وے ہر سروس کے لیے الگ الگ کام کرتا ہے، ایسا نہیں کہ یا تو سب کچھ استعمال کریں یا کچھ بھی نہیں۔
---
## CLI بمقابلہ میسجنگ فوری حوالہ
ہرمیس کے دو بنیادی انٹر فیس ہیں: آپ ٹرمینل UI کو `hermes` کے ساتھ شروع کریں، یا گیٹ وے چلا کر اس کے ساتھ ٹیلی گرام، ڈسکارڈ، سلیک، واٹس ایپ، سگنل، یا ای میل کے ذریعے بات کریں۔ جب آپ کسی بات چیت میں ہوتے ہیں، تو بہت سی سلیش (slash) کمانڈز دونوں انٹرفیسز میں ایک جیسی ہوتی ہیں۔
<div dir="ltr">
| کارروائی (Action) | سی ایل آئی (CLI) | میسجنگ پلیٹ فارمز (Messaging platforms) |
| --------------------------------------- | --------------------------------------------- | -------------------------------------------------------------------------------- |
| بات چیت شروع کریں | `hermes` | `hermes gateway setup` اور `hermes gateway start` چلائیں، پھر بوٹ کو میسج بھیجیں |
| نئی بات چیت شروع کریں | `/new` یا `/reset` | `/new` یا `/reset` |
| ماڈل تبدیل کریں | `/model [provider:model]` | `/model [provider:model]` |
| پرسنلٹی (Personality) سیٹ کریں | `/personality [name]` | `/personality [name]` |
| پچھلی باری کو دوبارہ یا منسوخ (undo) کریں | `/retry`، `/undo` | `/retry`، `/undo` |
| کانٹیکسٹ (context) کمپریس کریں / استعمال چیک کریں | `/compress`، `/usage`، `/insights [--days N]` | `/compress`، `/usage`، `/insights [days]` |
| مہارتیں (Skills) براؤز کریں | `/skills` یا `/<skill-name>` | `/<skill-name>` |
| موجودہ کام کو روکیں | `Ctrl+C` دبائیں یا نیا میسج بھیجیں | `/stop` یا نیا میسج بھیجیں |
| پلیٹ فارم کے لحاظ سے سٹیٹس | `/platforms` | `/status`، `/sethome` |
</div>
مکمل کمانڈ لسٹ کے لیے، [CLI گائیڈ](https://hermes-agent.nousresearch.com/docs/user-guide/cli) اور [میسجنگ گیٹ وے گائیڈ](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) دیکھیں۔
---
## دستاویزات (Documentation)
تمام دستاویزات **[hermes-agent.nousresearch.com/docs](https://hermes-agent.nousresearch.com/docs/)** پر موجود ہیں:
<div dir="ltr">
| سیکشن (Section) | تفصیل (What's Covered) |
| --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| [فوری آغاز (Quickstart)](https://hermes-agent.nousresearch.com/docs/getting-started/quickstart) | انسٹالیشن → سیٹ اپ → 2 منٹ میں پہلی بات چیت شروع کریں |
| [CLI کا استعمال](https://hermes-agent.nousresearch.com/docs/user-guide/cli) | کمانڈز، کی بائنڈنگز (keybindings)، پرسنلٹیز (personalities)، سیشنز |
| [کنفیگریشن (Configuration)](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | کنفگ فائل، پرووائیڈرز، ماڈلز، اور تمام آپشنز |
| [میسجنگ گیٹ وے](https://hermes-agent.nousresearch.com/docs/user-guide/messaging) | ٹیلی گرام، ڈسکارڈ، سلیک، واٹس ایپ، سگنل، ہوم اسسٹنٹ |
| [سیکیورٹی (Security)](https://hermes-agent.nousresearch.com/docs/user-guide/security) | کمانڈ کی منظوری، DM پیئرنگ (pairing)، کنٹینر آئسولیشن |
| [ٹولز اور ٹول سیٹس](https://hermes-agent.nousresearch.com/docs/user-guide/features/tools) | 40 سے زائد ٹولز، ٹول سیٹ سسٹم، ٹرمینل بیک اینڈز |
| [مہارتوں کا سسٹم (Skills System)](https://hermes-agent.nousresearch.com/docs/user-guide/features/skills)| پروسیجرل (Procedural) میموری، سکلز ہب، نئی مہارتیں بنانا |
| [میموری (Memory)](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | مستقل میموری، یوزر پروفائلز، بہترین طریقہ کار |
| [MCP انضمام (Integration)](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | صلاحیتوں کو بڑھانے کے لیے کسی بھی MCP سرور کو جوڑیں |
| [کرون (Cron) شیڈیولنگ](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | پلیٹ فارم ڈیلیوری کے ساتھ شیڈول کیے گئے کام |
| [کانٹیکسٹ (Context) فائلز](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files)| پروجیکٹ کا سیاق و سباق (context) جو ہر بات چیت پر اثر انداز ہوتا ہے |
| [آرکیٹیکچر (Architecture)](https://hermes-agent.nousresearch.com/docs/developer-guide/architecture) | پروجیکٹ کا ڈھانچہ، ایجنٹ لوپ، اہم کلاسز |
| [تعاون (Contributing)](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) | ڈیویلپمنٹ سیٹ اپ، PR کا طریقہ کار، کوڈنگ کا انداز |
| [CLI حوالہ جات (Reference)](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | تمام کمانڈز اور فلیگز (flags) |
| [انوائرمنٹ ویری ایبلز](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | مکمل انوائرمنٹ ویری ایبل حوالہ جات |
</div>
---
## OpenClaw سے منتقلی
اگر آپ OpenClaw سے منتقل ہو رہے ہیں، تو ہرمیس آپ کی سیٹنگز، یادیں (memories)، مہارتیں (skills)، اور API کیز کو خود بخود امپورٹ کر سکتا ہے۔
**پہلی بار سیٹ اپ کے دوران:** سیٹ اپ وزرڈ (`hermes setup`) خود بخود `~/.openclaw` کو پہچان لیتا ہے اور کنفیگریشن شروع ہونے سے پہلے مائیگریٹ (migrate) کرنے کا آپشن دیتا ہے۔
**انسٹالیشن کے بعد کسی بھی وقت:**
<div dir="ltr">
```bash
hermes claw migrate # انٹرایکٹو مائیگریشن (مکمل پری سیٹ)
hermes claw migrate --dry-run # جائزہ لیں کہ کیا کیا مائیگریٹ ہوگا
hermes claw migrate --preset user-data # حساس معلومات (secrets) کے بغیر مائیگریٹ کریں
hermes claw migrate --overwrite # موجودہ متصادم فائلوں کو اوور رائٹ کریں
```
</div>
جو چیزیں امپورٹ ہوتی ہیں:
- **SOUL.md** — پرسونا (persona) فائل
- **میموریز (Memories)** — MEMORY.md اور USER.md کی اندراجات
- **مہارتیں (Skills)** — صارف کی بنائی گئی مہارتیں → `~/.hermes/skills/openclaw-imports/`
- **کمانڈ الاؤ لسٹ (allowlist)** — منظوری کے پیٹرنز (approval patterns)
- **میسجنگ سیٹنگز** — پلیٹ فارم کنفیگریشنز، اجازت یافتہ صارفین، ورکنگ ڈائریکٹری
- **API کیز** — الاؤ لسٹ شدہ حساس معلومات (ٹیلی گرام، OpenRouter، OpenAI، Anthropic، ElevenLabs)
- **TTS اثاثے** — ورک اسپیس کی آڈیو فائلیں
- **ورک اسپیس کی ہدایات** — AGENTS.md (`--workspace-target` کے ساتھ)
تمام آپشنز دیکھنے کے لیے `hermes claw migrate --help` استعمال کریں، یا انٹرایکٹو ایجنٹ کی مدد سے مائیگریٹ کرنے کے لیے `openclaw-migration` سکل کا استعمال کریں (جس میں ڈرائی رن (dry-run) پریویوز شامل ہیں)۔
---
## تعاون کریں (Contributing)
ہم آپ کے تعاون کا خیرمقدم کرتے ہیں! ڈیویلپمنٹ سیٹ اپ، کوڈ کے انداز اور PR کے طریقہ کار کے لیے براہ کرم ہماری [Contributing گائیڈ](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) دیکھیں۔
معاونین (contributors) کے لیے فوری آغاز — کلون (clone) کریں اور `setup-hermes.sh` چلائیں:
<div dir="ltr">
```bash
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
./setup-hermes.sh # uv کو انسٹال کرتا ہے، venv بناتا ہے، .[all] کو انسٹال کرتا ہے، اور ~/.local/bin/hermes کا سیم لنک (symlink) بناتا ہے
./hermes # خود بخود venv کی شناخت کرتا ہے، پہلے `source` کرنے کی ضرورت نہیں
```
</div>
مینوئل طریقہ (اوپر والے طریقے کے مساوی):
<div dir="ltr">
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
scripts/run_tests.sh
```
</div>
---
## کمیونٹی (Community)
- 💬 [ڈسکارڈ (Discord)](https://discord.gg/NousResearch)
- 📚 [سکلز ہب (Skills Hub)](https://agentskills.io)
- 🐛 [مسائل (Issues)](https://github.com/NousResearch/hermes-agent/issues)
- 🔌 [computer-use-linux](https://github.com/avifenesh/computer-use-linux) — ہرمیس اور دیگر MCP ہوسٹس کے لیے لینکس (Linux) ڈیسک ٹاپ کنٹرول MCP سرور، جس میں AT-SPI ایکسیسیبلٹی ٹریز، Wayland/X11 ان پٹ، سکرین شاٹس، اور کمپوزیٹر ونڈو ٹارگیٹنگ شامل ہے۔
- 🔌 [HermesClaw](https://github.com/AaronWong1999/hermesclaw) — کمیونٹی وی چیٹ (WeChat) برج: ہرمیس ایجنٹ اور OpenClaw کو ایک ہی وی چیٹ اکاؤنٹ پر چلائیں۔
---
## لائسنس (License)
MIT — تفصیلات کے لیے [LICENSE](LICENSE) دیکھیں۔
[نوس ریسرچ (Nous Research)](https://nousresearch.com) کی جانب سے تیار کردہ۔
</div>

View File

@@ -10,7 +10,6 @@
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
<a href="https://nousresearch.com"><img src="https://img.shields.io/badge/Built%20by-Nous%20Research-blueviolet?style=for-the-badge" alt="Built by Nous Research"></a>
<a href="README.md"><img src="https://img.shields.io/badge/Lang-English-lightgrey?style=for-the-badge" alt="English"></a>
<a href="README.ur-pk.md"><img src="https://img.shields.io/badge/Lang-اردو-green?style=for-the-badge" alt="اردو"></a>
</p>
**由 [Nous Research](https://nousresearch.com) 构建的自进化 AI 代理。** 它是唯一内置学习闭环的智能代理——从经验中创建技能,在使用中改进技能,主动持久化知识,搜索过往对话,并在跨会话中逐步构建对你的深度理解。可以在 $5 的 VPS 上运行,也可以在 GPU 集群上运行,或者使用几乎零成本的 Serverless 基础设施。它不绑定你的笔记本——你可以在 Telegram 上与它对话,而它在云端 VM 上工作。

View File

@@ -1,127 +0,0 @@
"""Derive ACP session-provenance metadata from the existing compression chain.
This is an additive Hermes extension surfaced under ACP ``_meta.hermes`` so
existing ACP clients ignore it. It carries no new persisted state: everything
is derived on demand from the ``sessions`` table (``parent_session_id`` /
``end_reason``), which already models compression-continuation chains.
The ACP/editor ``session_id`` stays the stable public handle. When context
compression rotates the internal Hermes head, ``build_session_provenance`` lets
a client see the previous/current internal ids and the lineage root without
parsing status text, guessing from token drops, or reading ``state.db``.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
# Bound defensive walks; compression chains this deep are pathological.
_MAX_WALK = 100
def build_session_provenance(
db: Any,
acp_session_id: str,
current_hermes_session_id: str,
*,
previous_hermes_session_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Build ``_meta.hermes.sessionProvenance`` for an ACP session.
Args:
db: A ``SessionDB`` (must expose ``get_session``).
acp_session_id: The stable ACP/editor-facing session handle.
current_hermes_session_id: The live internal Hermes DB session id
(``state.agent.session_id``).
previous_hermes_session_id: The internal id from before the most recent
turn, when known. Supplied by ``prompt()`` to flag a rotation.
Returns:
A dict suitable for ``{"hermes": {"sessionProvenance": <dict>}}`` under
ACP ``_meta``, or ``None`` if the session can't be read.
"""
try:
row = db.get_session(current_hermes_session_id)
except Exception:
return None
if not row:
return None
parent_id = row.get("parent_session_id")
end_reason = row.get("end_reason")
# Walk parents to the lineage root and count compression depth. Only
# compression-split parents (parent.end_reason == 'compression') count
# toward depth — delegate/branch children share the parent_session_id
# column but are not compaction boundaries.
root_id = current_hermes_session_id
compression_depth = 0
cursor_parent = parent_id
seen = {current_hermes_session_id}
for _ in range(_MAX_WALK):
if not cursor_parent or cursor_parent in seen:
break
seen.add(cursor_parent)
try:
prow = db.get_session(cursor_parent)
except Exception:
prow = None
if not prow:
break
root_id = cursor_parent
if prow.get("end_reason") == "compression":
compression_depth += 1
cursor_parent = prow.get("parent_session_id")
# A session is a compression continuation when its parent was ended with
# end_reason='compression'. Determine that from the immediate parent.
is_continuation = False
if parent_id:
try:
immediate_parent = db.get_session(parent_id)
except Exception:
immediate_parent = None
if immediate_parent and immediate_parent.get("end_reason") == "compression":
is_continuation = True
rotated = bool(
previous_hermes_session_id
and previous_hermes_session_id != current_hermes_session_id
)
provenance: Dict[str, Any] = {
"acpSessionId": acp_session_id,
"currentHermesSessionId": current_hermes_session_id,
"rootHermesSessionId": root_id,
"parentHermesSessionId": parent_id,
"sessionKind": "continuation" if is_continuation else "root",
"compressionDepth": compression_depth,
}
if previous_hermes_session_id:
provenance["previousHermesSessionId"] = previous_hermes_session_id
if rotated:
# The head moved during the last turn. The only mechanism that rotates
# the internal id mid-turn is compression-driven session splitting.
provenance["reason"] = "compression"
provenance["creatorKind"] = "compression"
return provenance
def session_provenance_meta(
db: Any,
acp_session_id: str,
current_hermes_session_id: str,
*,
previous_hermes_session_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Return a ready ``_meta`` payload: ``{"hermes": {"sessionProvenance": ...}}``."""
prov = build_session_provenance(
db,
acp_session_id,
current_hermes_session_id,
previous_hermes_session_id=previous_hermes_session_id,
)
if prov is None:
return None
return {"hermes": {"sessionProvenance": prov}}

View File

@@ -71,7 +71,6 @@ from acp_adapter.events import (
make_tool_progress_cb,
)
from acp_adapter.permissions import make_approval_callback
from acp_adapter.provenance import session_provenance_meta
from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets
from acp_adapter.tools import build_tool_complete, build_tool_start
@@ -710,39 +709,8 @@ class HermesACPAgent(acp.Agent):
exc_info=True,
)
def _provenance_meta(
self,
acp_session_id: str,
current_hermes_session_id: str,
previous_hermes_session_id: Optional[str] = None,
) -> Optional[dict]:
"""Best-effort ``_meta.hermes.sessionProvenance`` for an ACP session."""
try:
return session_provenance_meta(
self.session_manager._get_db(),
acp_session_id,
current_hermes_session_id,
previous_hermes_session_id=previous_hermes_session_id,
)
except Exception:
logger.debug(
"Could not build ACP session provenance for %s", acp_session_id, exc_info=True
)
return None
async def _send_session_info_update(
self,
session_id: str,
*,
current_hermes_session_id: Optional[str] = None,
previous_hermes_session_id: Optional[str] = None,
) -> None:
"""Send ACP native session metadata after Hermes changes it.
When the internal Hermes head rotated (e.g. compression-driven session
split during a turn), pass ``previous_hermes_session_id`` so the
attached ``_meta.hermes.sessionProvenance`` flags the rotation reason.
"""
async def _send_session_info_update(self, session_id: str) -> None:
"""Send ACP native session metadata after Hermes changes it."""
if not self._conn:
return
try:
@@ -759,16 +727,10 @@ class HermesACPAgent(acp.Agent):
# the updated_at since we're emitting this notification precisely
# because the title was just refreshed.
updated_at = datetime.now(timezone.utc).isoformat()
meta = self._provenance_meta(
session_id,
current_hermes_session_id or session_id,
previous_hermes_session_id,
)
update = SessionInfoUpdate(
session_update="session_info_update",
title=title if isinstance(title, str) and title.strip() else None,
updated_at=updated_at,
field_meta=meta,
)
try:
await self._conn.session_update(
@@ -1119,9 +1081,6 @@ class HermesACPAgent(acp.Agent):
session_id=state.session_id,
models=self._build_model_state(state),
modes=self._session_modes(state),
field_meta=self._provenance_meta(
state.session_id, getattr(state.agent, "session_id", state.session_id)
),
)
async def load_session(
@@ -1166,9 +1125,6 @@ class HermesACPAgent(acp.Agent):
return LoadSessionResponse(
models=self._build_model_state(state),
modes=self._session_modes(state),
field_meta=self._provenance_meta(
session_id, getattr(state.agent, "session_id", session_id)
),
)
async def resume_session(
@@ -1201,9 +1157,6 @@ class HermesACPAgent(acp.Agent):
return ResumeSessionResponse(
models=self._build_model_state(state),
modes=self._session_modes(state),
field_meta=self._provenance_meta(
state.session_id, getattr(state.agent, "session_id", state.session_id)
),
)
async def cancel(self, session_id: str, **kwargs: Any) -> None:
@@ -1541,11 +1494,6 @@ class HermesACPAgent(acp.Agent):
logger.debug("Could not clear ACP session context", exc_info=True)
try:
# Snapshot the internal Hermes DB session id before the turn so we
# can detect a compression-driven session rotation afterwards. The
# ACP `session_id` stays the stable client handle; agent.session_id
# is the live internal head that compression may rotate.
pre_turn_hermes_id = getattr(state.agent, "session_id", None)
# Wrap the executor call in a fresh copy of the current context so
# concurrent ACP sessions on the shared ThreadPoolExecutor don't
# stomp on each other's ContextVar writes (HERMES_SESSION_KEY in
@@ -1564,41 +1512,8 @@ class HermesACPAgent(acp.Agent):
# Persist updated history so sessions survive process restarts.
self.session_manager.save_session(session_id)
# Detect a compression-driven internal session rotation. If the agent's
# DB head moved during the turn, emit a session_info_update carrying
# _meta.hermes.sessionProvenance so ACP clients can render the boundary
# and keep old/new ids in lineage. The ACP session_id is unchanged.
post_turn_hermes_id = getattr(state.agent, "session_id", None)
if (
conn
and post_turn_hermes_id
and pre_turn_hermes_id
and post_turn_hermes_id != pre_turn_hermes_id
):
try:
await self._send_session_info_update(
session_id,
current_hermes_session_id=post_turn_hermes_id,
previous_hermes_session_id=pre_turn_hermes_id,
)
except Exception:
logger.debug(
"Could not emit ACP provenance update after rotation for %s",
session_id,
exc_info=True,
)
final_response = result.get("final_response", "")
cancelled = bool(state.cancel_event and state.cancel_event.is_set())
interrupted = bool(result.get("interrupted")) or cancelled
# Hermes' local "waiting for model response" interrupt status is metadata,
# not assistant prose — clients get cancellation from stop_reason instead.
from agent.conversation_loop import INTERRUPT_WAITING_FOR_MODEL_PREFIX
suppress_interrupt_response = interrupted and final_response.startswith(
INTERRUPT_WAITING_FOR_MODEL_PREFIX
)
if final_response and not suppress_interrupt_response:
if final_response:
try:
from agent.title_generator import maybe_auto_title
@@ -1619,12 +1534,7 @@ class HermesACPAgent(acp.Agent):
)
except Exception:
logger.debug("Failed to auto-title ACP session %s", session_id, exc_info=True)
if (
final_response
and conn
and not suppress_interrupt_response
and (not streamed_message or result.get("response_transformed"))
):
if final_response and conn and (not streamed_message or result.get("response_transformed")):
# Deliver the final response when streaming did not already send it,
# or when a plugin hook transformed the response after streaming
# finished (e.g. transform_llm_output) — otherwise the appended /
@@ -1666,7 +1576,7 @@ class HermesACPAgent(acp.Agent):
await self._send_usage_update(state)
stop_reason = "cancelled" if cancelled else "end_turn"
stop_reason = "cancelled" if state.cancel_event and state.cancel_event.is_set() else "end_turn"
return PromptResponse(stop_reason=stop_reason, usage=usage)
# ---- Slash commands (headless) -------------------------------------------

View File

@@ -68,24 +68,6 @@ def _ra():
return run_agent
def _build_codex_gpt55_autoraise_notice(autoraise: Dict[str, float]) -> str:
"""Build the one-time notice shown when Codex gpt-5.5 raises compaction.
``autoraise`` is ``{"from": <old_ratio>, "to": <new_ratio>}``. The same
text is printed inline for CLI users and replayed via ``status_callback``
for gateway users, so it must be self-contained and include the exact
opt-back-out command.
"""
from_pct = int(round(autoraise["from"] * 100))
to_pct = int(round(autoraise["to"] * 100))
return (
f" Codex gpt-5.5 caps context at 272K, so auto-compaction was raised "
f"to {to_pct}% (from {from_pct}%) to use more of the window before "
f"summarizing.\n"
f" Opt back out: hermes config set compression.codex_gpt55_autoraise false"
)
def _normalized_custom_base_url(value: Any) -> str:
if not isinstance(value, str):
return ""
@@ -169,7 +151,6 @@ def init_agent(
save_trajectories: bool = False,
verbose_logging: bool = False,
quiet_mode: bool = False,
tool_progress_mode: str = "all",
ephemeral_system_prompt: str = None,
log_prefix_chars: int = 100,
log_prefix: str = "",
@@ -281,7 +262,6 @@ def init_agent(
agent.save_trajectories = save_trajectories
agent.verbose_logging = verbose_logging
agent.quiet_mode = quiet_mode
agent.tool_progress_mode = tool_progress_mode
agent.ephemeral_system_prompt = ephemeral_system_prompt
agent.platform = platform # "cli", "telegram", "discord", "whatsapp", etc.
agent._user_id = user_id # Platform user identifier (gateway sessions)
@@ -887,14 +867,6 @@ def init_agent(
headers["x-anthropic-beta"] = _FINE_GRAINED
client_kwargs["default_headers"] = headers
# User-configured request headers (model.default_headers in
# config.yaml) override provider/SDK defaults. Lets custom
# OpenAI-compatible endpoints behind a gateway/WAF that rejects the
# OpenAI SDK's identifying headers swap in a plain User-Agent. (#40033)
# client_kwargs is the same dict object as agent._client_kwargs, so
# this mutation is reflected in the client built just below.
agent._apply_user_default_headers()
agent.api_key = client_kwargs.get("api_key", "")
agent.base_url = client_kwargs.get("base_url", agent.base_url)
try:
@@ -1268,41 +1240,11 @@ def init_agent(
if not isinstance(_compression_cfg, dict):
_compression_cfg = {}
compression_threshold = float(_compression_cfg.get("threshold", 0.50))
# Per-model/route compaction-threshold override. Codex gpt-5.5 raises to
# 85% (the Codex backend caps the window at 272K, so the default 50% would
# compact at ~136K — half the usable context). Gated by an opt-out config
# flag so the user can fall back to the global threshold; when the override
# fires we stash a one-time notification (replayed on the first turn) that
# tells the user what changed and how to revert.
_codex_gpt55_autoraise = str(
_compression_cfg.get("codex_gpt55_autoraise", True)
).lower() in {"true", "1", "yes"}
agent._compression_threshold_autoraised = None
try:
from agent.auxiliary_client import (
_compression_threshold_for_model as _cthresh_fn,
_is_codex_gpt55 as _is_codex_gpt55_fn,
)
_model_cthresh = _cthresh_fn(
agent.model,
agent.provider,
allow_codex_gpt55_autoraise=_codex_gpt55_autoraise,
)
from agent.auxiliary_client import _compression_threshold_for_model as _cthresh_fn
_model_cthresh = _cthresh_fn(agent.model)
if _model_cthresh is not None:
_prev_threshold = compression_threshold
compression_threshold = _model_cthresh
# Notify only for the Codex gpt-5.5 autoraise (the Arcee Trinity
# override is a long-standing silent default). Skip the notice when
# the user's global threshold already meets/exceeds the raised
# value, since nothing actually changed for them.
if (
_is_codex_gpt55_fn(agent.model, agent.provider)
and _model_cthresh > _prev_threshold + 1e-9
):
agent._compression_threshold_autoraised = {
"from": _prev_threshold,
"to": _model_cthresh,
}
except Exception:
pass
compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"}
@@ -1679,24 +1621,11 @@ def init_agent(
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(compression_threshold*100)}% = {agent.context_compressor.threshold_tokens:,})")
else:
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)")
# One-time notice when the Codex gpt-5.5 autoraise kicked in, with the
# exact opt-back-out command. Printed inline at startup for CLI users;
# gateway users get the same text replayed via _compression_warning on
# turn 1 (set below, after the warning slot is initialized).
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
if _autoraise and compression_enabled:
print(_build_codex_gpt55_autoraise_notice(_autoraise))
# Check immediately so CLI users see the warning at startup.
# Gateway status_callback is not yet wired, so any warning is stored
# in _compression_warning and replayed in the first run_conversation().
agent._compression_warning = None
# Gateway parity for the Codex gpt-5.5 autoraise notice: the startup print
# above only reaches the CLI, so stash the same text here to be replayed
# through status_callback on the first turn (Telegram/Discord/Slack/etc.).
_autoraise = getattr(agent, "_compression_threshold_autoraised", None)
if _autoraise and compression_enabled:
agent._compression_warning = _build_codex_gpt55_autoraise_notice(_autoraise)
# Lazy feasibility check: deferred to the first turn that approaches the
# compression threshold. Running it eagerly here costs ~400ms cold (network
# probe of the auxiliary provider chain + /models lookup) on every agent

View File

@@ -1846,27 +1846,6 @@ def repair_tool_call(agent, tool_name: str) -> str | None:
if not tool_name:
return None
# VolcEngine api/plan workaround (issue #33007): the endpoint's
# protocol-translation layer occasionally leaks raw XML attribute
# fragments into tool_use.name, e.g.
# `terminal" parameter="command" string="true`
# `execute_code" parameter="code" string="true`
# `session_search" parameter="session_id" string="true`
# We trim at the first unambiguous XML/quote character so the rest
# of the repair pipeline (lowercase / snake_case / fuzzy match)
# can resolve the cleaned name to a real tool.
#
# Crucially we DO NOT split on whitespace: legitimate inputs like
# "write file" must keep flowing through ``_norm`` -> ``write_file``
# (covered by test_space_to_underscore in
# tests/run_agent/test_repair_tool_call_name.py).
for _xml_sep in ('"', "'", "<", ">"):
_idx = tool_name.find(_xml_sep)
if _idx > 0:
tool_name = tool_name[:_idx]
if not tool_name:
return None
def _norm(s: str) -> str:
return s.lower().replace("-", "_").replace(" ", "_")

View File

@@ -2301,43 +2301,3 @@ def build_anthropic_kwargs(
kwargs["extra_headers"] = {"anthropic-beta": ",".join(betas)}
return kwargs
# Keys that belong exclusively to the OpenAI Responses / Codex API shape.
# The Anthropic Messages SDK (``messages.create()`` / ``messages.stream()``)
# raises ``TypeError: ... got an unexpected keyword argument`` on any of them.
_RESPONSES_ONLY_KWARGS = frozenset(
{"instructions", "input", "store", "parallel_tool_calls"}
)
def sanitize_anthropic_kwargs(api_kwargs: Any, *, log_prefix: str = "") -> Any:
"""Drop Responses-API-only keys before an Anthropic Messages SDK call.
Defensive boundary guard for #31673: under rare api_mode-flip races
(e.g. a concurrent auxiliary call mutating a shared agent between the
kwargs build and the stream dispatch), a Responses-shaped payload
carrying ``instructions=`` can reach ``messages.stream()`` /
``messages.create()``. The Anthropic SDK rejects it with a
non-retryable ``TypeError`` that nukes the whole turn and propagates
the entire fallback chain.
Mutates ``api_kwargs`` in place and returns it. When a foreign key is
present we log a WARNING so the underlying race stays visible in the
wild instead of being silently papered over.
"""
if not isinstance(api_kwargs, dict):
return api_kwargs
leaked = _RESPONSES_ONLY_KWARGS.intersection(api_kwargs)
if leaked:
for _key in leaked:
api_kwargs.pop(_key, None)
logger.warning(
"%sStripped Responses-only kwarg(s) %s from an Anthropic Messages "
"call (api_mode flip race — see #31673). The call will proceed; "
"this breadcrumb means a kwargs build ran under a Responses "
"api_mode while dispatch ran under anthropic_messages.",
log_prefix,
sorted(leaked),
)
return api_kwargs

View File

@@ -202,35 +202,6 @@ def _is_arcee_trinity_thinking(model: Optional[str]) -> bool:
return bare == "trinity-large-thinking"
# Context window enforced by ChatGPT's Codex OAuth backend for gpt-5.5.
# The raw OpenAI API and OpenRouter expose 1.05M for the same slug, but the
# Codex backend hard-caps at 272K (verified live: a ~330K-token request to
# chatgpt.com/backend-api/codex/responses is rejected with
# ``context_length_exceeded`` while ~250K succeeds). With a 272K ceiling the
# default 50% compaction trigger fires at ~136K — wasteful, since the model
# can hold far more raw context before summarization actually buys anything.
# We raise the trigger to 85% (~231K) on this exact route so Codex gpt-5.5
# sessions use the window they actually have.
_CODEX_GPT55_COMPACTION_THRESHOLD = 0.85
def _is_codex_gpt55(model: Optional[str], provider: Optional[str] = None) -> bool:
"""True for gpt-5.5 accessed through the ChatGPT Codex OAuth backend.
Matches only the Codex OAuth route (provider ``openai-codex``), not the
direct OpenAI API, OpenRouter, or GitHub Copilot paths — those expose a
larger context window for the same slug and must keep the user's default
compaction threshold. ``gpt-5.5-pro`` and dated snapshots
(``gpt-5.5-2026-04-23``) are matched via prefix so the override tracks the
family without re-listing every variant.
"""
prov = (provider or "").strip().lower()
if prov != "openai-codex":
return False
bare = (model or "").strip().lower().rsplit("/", 1)[-1]
return bare == "gpt-5.5" or bare.startswith("gpt-5.5-") or bare.startswith("gpt-5.5.")
def _fixed_temperature_for_model(
model: Optional[str],
base_url: Optional[str] = None,
@@ -253,32 +224,18 @@ def _fixed_temperature_for_model(
return None
def _compression_threshold_for_model(
model: Optional[str],
provider: Optional[str] = None,
*,
allow_codex_gpt55_autoraise: bool = True,
) -> Optional[float]:
def _compression_threshold_for_model(model: Optional[str]) -> Optional[float]:
"""Return a context-compression threshold override for specific models.
The threshold is the fraction of the model's context window that must be
consumed before Hermes triggers summarization. Higher values delay
compression and preserve more raw context.
Per-model/route overrides:
- Arcee Trinity Large Thinking → 0.75 (preserve reasoning context).
- gpt-5.5 on the Codex OAuth route → 0.85, because Codex caps the window
at 272K and the default 50% trigger would compact at ~136K. Gated by
``allow_codex_gpt55_autoraise`` so the user can opt back down to the
global default (the caller passes the config flag through here).
Returns a float in (0, 1] to override the global ``compression.threshold``
config value, or ``None`` to leave the user's config value unchanged.
"""
if _is_arcee_trinity_thinking(model):
return 0.75
if allow_codex_gpt55_autoraise and _is_codex_gpt55(model, provider):
return _CODEX_GPT55_COMPACTION_THRESHOLD
return None
# Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
@@ -357,35 +314,6 @@ _OR_HEADERS_BASE = {
_TRUTHY_ENV_VALUES = frozenset({"1", "true", "yes", "on"})
def _apply_user_default_headers(headers: dict | None) -> dict | None:
"""Merge user-configured ``model.default_headers`` onto resolved headers.
User values take precedence over provider/SDK defaults, mirroring the main
agent client (``AIAgent._apply_user_default_headers``). This lets a
``custom`` OpenAI-compatible endpoint behind a gateway/WAF that rejects the
OpenAI SDK's identifying headers (``User-Agent: OpenAI/Python ...``,
``X-Stainless-*``) override them for auxiliary calls too — otherwise the
main turn would succeed but title/compression/vision calls to the same
endpoint would still fail. (#40033)
Returns the merged dict, or the original ``headers`` (possibly ``None``)
when nothing is configured. No allocation when there are no overrides.
"""
try:
from hermes_cli.config import cfg_get, load_config
user_headers = cfg_get(load_config(), "model", "default_headers")
except Exception:
return headers
if not isinstance(user_headers, dict) or not user_headers:
return headers
merged = dict(headers or {})
for key, value in user_headers.items():
if value is None:
continue
merged[str(key)] = str(value)
return merged or headers
def build_or_headers(or_config: dict | None = None) -> dict:
"""Build OpenRouter headers, optionally including response-cache headers.
@@ -637,6 +565,54 @@ def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str:
# calls to the Codex Responses API so callers don't need any changes.
def _convert_content_for_responses(content: Any) -> Any:
"""Convert chat.completions content to Responses API format.
chat.completions uses:
{"type": "text", "text": "..."}
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
Responses API uses:
{"type": "input_text", "text": "..."}
{"type": "input_image", "image_url": "data:image/png;base64,..."}
If content is a plain string, it's returned as-is (the Responses API
accepts strings directly for text-only messages).
"""
if isinstance(content, str):
return content
if not isinstance(content, list):
return str(content) if content else ""
converted: List[Dict[str, Any]] = []
for part in content:
if not isinstance(part, dict):
continue
ptype = part.get("type", "")
if ptype == "text":
converted.append({"type": "input_text", "text": part.get("text", "")})
elif ptype == "image_url":
# chat.completions nests the URL: {"image_url": {"url": "..."}}
image_data = part.get("image_url", {})
url = image_data.get("url", "") if isinstance(image_data, dict) else str(image_data)
entry: Dict[str, Any] = {"type": "input_image", "image_url": url}
# Preserve detail if specified
detail = image_data.get("detail") if isinstance(image_data, dict) else None
if detail:
entry["detail"] = detail
converted.append(entry)
elif ptype in {"input_text", "input_image"}:
# Already in Responses format — pass through
converted.append(part)
else:
# Unknown content type — try to preserve as text
text = part.get("text", "")
if text:
converted.append({"type": "input_text", "text": text})
return converted or ""
class _CodexCompletionsAdapter:
"""Drop-in shim that accepts chat.completions.create() kwargs and
routes them through the Codex Responses streaming API."""
@@ -649,37 +625,26 @@ class _CodexCompletionsAdapter:
messages = kwargs.get("messages", [])
model = kwargs.get("model", self._model)
# Separate system/instructions from replayable conversation messages,
# then route the rest through the SINGLE shared chat->Responses
# converter used by the main agent transport
# (agent/transports/codex.py). Maintaining a private conversion loop
# here let chat-style messages with role="tool" leak straight into
# Responses input[] — which the Responses API rejects with
# "Invalid value: 'tool'. Supported values are: 'assistant', 'system',
# 'developer', and 'user'." (issue #5709, hit hard by flush_memories()
# / compression replaying real session history that includes assistant
# tool_calls + role="tool" results). The shared converter encodes
# assistant tool calls as `function_call` items and tool results as
# `function_call_output` items with a valid call_id, so every
# Responses path normalizes tool history identically and cannot drift.
from agent.codex_responses_adapter import _chat_messages_to_responses_input
# Separate system/instructions from conversation messages.
# Convert chat.completions multimodal content blocks to Responses
# API format (input_text / input_image instead of text / image_url).
instructions = "You are a helpful assistant."
replay_messages: List[Dict[str, Any]] = []
input_msgs: List[Dict[str, Any]] = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content") or ""
if role == "system":
instructions = content if isinstance(content, str) else str(content)
else:
replay_messages.append(msg)
input_items = _chat_messages_to_responses_input(replay_messages)
input_msgs.append({
"role": role,
"content": _convert_content_for_responses(content),
})
resp_kwargs: Dict[str, Any] = {
"model": model,
"instructions": instructions,
"input": input_items or [{"role": "user", "content": ""}],
"input": input_msgs or [{"role": "user", "content": ""}],
"store": False,
}
@@ -1487,9 +1452,6 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
extra["default_headers"] = dict(_ph_aux.default_headers)
except Exception:
pass
_merged_aux = _apply_user_default_headers(extra.get("default_headers"))
if _merged_aux:
extra["default_headers"] = _merged_aux
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
return _client, model
@@ -1527,9 +1489,6 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
extra["default_headers"] = dict(_ph_aux2.default_headers)
except Exception:
pass
_merged_aux2 = _apply_user_default_headers(extra.get("default_headers"))
if _merged_aux2:
extra["default_headers"] = _merged_aux2
_client = OpenAI(api_key=api_key, base_url=base_url, **extra)
_client = _maybe_wrap_anthropic(_client, model, api_key, raw_base_url)
return _client, model
@@ -1920,13 +1879,6 @@ def _try_custom_endpoint() -> Tuple[Optional[Any], Optional[str]]:
logger.debug("Auxiliary client: custom endpoint (%s, api_mode=%s)", model, custom_mode or "chat_completions")
_clean_base, _dq = _extract_url_query_params(custom_base)
_extra = {"default_query": _dq} if _dq else {}
# User-configured model.default_headers override the SDK's identifying
# headers (User-Agent: OpenAI/Python ..., X-Stainless-*) on this custom
# endpoint's auxiliary calls too — matching the main agent client so the
# whole session reaches a gateway/WAF that rejects the SDK fingerprint. (#40033)
_custom_headers = _apply_user_default_headers(None)
if _custom_headers:
_extra["default_headers"] = _custom_headers
if custom_mode == "codex_responses":
real_client = OpenAI(api_key=custom_key, base_url=_clean_base, **_extra)
return CodexAuxiliaryClient(real_client, model), model
@@ -2476,25 +2428,6 @@ def _is_connection_error(exc: Exception) -> bool:
return False
def _is_transient_transport_error(exc: Exception) -> bool:
"""Return True for a one-off transport blip worth retrying ONCE on the
same provider before any provider/model fallback.
Covers connection/streaming-close errors (via the canonical
``_is_connection_error`` detector, shared so the two cannot drift) plus a
pure 5xx/408 HTTP status. Deliberately narrow: this is the "retry the
same target once" gate, distinct from ``_is_payment_error`` /
``_is_auth_error`` / ``_is_rate_limit_error`` which the except-chain
handles by switching provider, refreshing creds, or rotating the pool.
"""
if _is_connection_error(exc):
return True
status = getattr(exc, "status_code", None) or getattr(
getattr(exc, "response", None), "status_code", None
)
return isinstance(status, int) and (status == 408 or 500 <= status < 600)
def _is_auth_error(exc: Exception) -> bool:
"""Detect auth failures that should trigger provider-specific refresh."""
status = getattr(exc, "status_code", None)
@@ -3315,9 +3248,6 @@ def _to_async_client(sync_client, model: str, is_vision: bool = False):
async_kwargs["default_headers"] = dict(_ph_async.default_headers)
except Exception:
pass
_merged_async = _apply_user_default_headers(async_kwargs.get("default_headers"))
if _merged_async:
async_kwargs["default_headers"] = _merged_async
return AsyncOpenAI(**async_kwargs), model
@@ -3605,9 +3535,6 @@ def resolve_provider_client(
extra["default_headers"] = dict(_ph_custom.default_headers)
except Exception:
pass
_merged_custom = _apply_user_default_headers(extra.get("default_headers"))
if _merged_custom:
extra["default_headers"] = _merged_custom
client = OpenAI(api_key=custom_key, base_url=_clean_base, **extra)
client = _wrap_if_needed(client, final_model, custom_base, custom_key)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
@@ -3684,9 +3611,6 @@ def resolve_provider_client(
raw_base_for_wrap = custom_base
_clean_base2, _dq2 = _extract_url_query_params(openai_base)
_extra2 = {"default_query": _dq2} if _dq2 else {}
_headers2 = _apply_user_default_headers(_extra2.get("default_headers"))
if _headers2:
_extra2["default_headers"] = _headers2
logger.debug(
"resolve_provider_client: named custom provider %r (%s, api_mode=%s)",
provider, final_model, entry_api_mode or "chat_completions")
@@ -3709,9 +3633,6 @@ def resolve_provider_client(
_fallback_base = _to_openai_base_url(custom_base)
_fb_clean, _fb_dq = _extract_url_query_params(_fallback_base)
_fb_extra = {"default_query": _fb_dq} if _fb_dq else {}
_fb_headers = _apply_user_default_headers(_fb_extra.get("default_headers"))
if _fb_headers:
_fb_extra["default_headers"] = _fb_headers
client = OpenAI(api_key=custom_key, base_url=_fb_clean, **_fb_extra)
return (_to_async_client(client, final_model, is_vision=is_vision) if async_mode
else (client, final_model))
@@ -3860,9 +3781,6 @@ def resolve_provider_client(
headers.update(_ph_main.default_headers)
except Exception:
pass
_merged_main = _apply_user_default_headers(headers)
if _merged_main:
headers = _merged_main
client = OpenAI(api_key=api_key, base_url=base_url,
**({"default_headers": headers} if headers else {}))
@@ -5166,28 +5084,8 @@ def call_llm(
# Handle unsupported temperature, max_tokens vs max_completion_tokens retry,
# then payment fallback.
try:
# Retry ONCE on the same provider for a one-off transient transport
# blip (streaming-close / incomplete chunked read / 5xx / 408) before
# the except-chain below escalates to provider/model fallback. A
# single dropped connection shouldn't abandon an otherwise-healthy
# provider. A second failure (or any non-transient error) falls
# through to ``first_err`` and the existing fallback handling
# unchanged. This is the unified home for the transient retry that
# every auxiliary task (compression, memory flush, title-gen,
# session-search, vision) shares. (PR #16587)
try:
return _validate_llm_response(
client.chat.completions.create(**kwargs), task)
except Exception as transient_err:
if not _is_transient_transport_error(transient_err):
raise
logger.info(
"Auxiliary %s: transient transport error; retrying once on "
"the same provider before fallback: %s",
task or "call", transient_err,
)
return _validate_llm_response(
client.chat.completions.create(**kwargs), task)
return _validate_llm_response(
client.chat.completions.create(**kwargs), task)
except Exception as first_err:
if "temperature" in kwargs and _is_unsupported_temperature_error(first_err):
retry_kwargs = dict(kwargs)
@@ -5653,22 +5551,8 @@ async def async_call_llm(
kwargs["messages"] = _convert_openai_images_to_anthropic(kwargs["messages"])
try:
# Retry ONCE on the same provider for a transient transport blip
# before the except-chain escalates to fallback — see call_llm()
# for the rationale. (PR #16587)
try:
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task)
except Exception as transient_err:
if not _is_transient_transport_error(transient_err):
raise
logger.info(
"Auxiliary %s (async): transient transport error; retrying "
"once on the same provider before fallback: %s",
task or "call", transient_err,
)
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task)
return _validate_llm_response(
await client.chat.completions.create(**kwargs), task)
except Exception as first_err:
if "temperature" in kwargs and _is_unsupported_temperature_error(first_err):
retry_kwargs = dict(kwargs)

View File

@@ -449,17 +449,6 @@ def _run_review_in_thread(
# if a future code path bypasses the cache.
review_agent.session_start = agent.session_start
review_agent.session_id = agent.session_id
# Never let the review fork compress. It shares the parent's
# session_id, so if it won a compression race it would rotate the
# parent into a NEW child that the gateway never adopts (the fork
# is single-lifecycle and dies right after this run_conversation).
# The foreground turn would then start from the stale parent and
# compress it again, leaving the same parent with two sibling
# children (issue #38727). Review also needs full context to
# produce a good memory/skill summary — compressing would strip
# detail. Both compression triggers in conversation_loop.py gate on
# agent.compression_enabled, so this short-circuits both paths.
review_agent.compression_enabled = False
from model_tools import get_tool_definitions
from hermes_cli.plugins import (

View File

@@ -34,7 +34,7 @@ from agent.message_sanitization import (
_repair_tool_call_arguments,
)
from tools.terminal_tool import is_persistent_env
from utils import base_url_host_matches, base_url_hostname, env_int
from utils import base_url_host_matches, base_url_hostname
logger = logging.getLogger(__name__)
@@ -139,15 +139,6 @@ def interruptible_api_call(agent, api_kwargs: dict):
result = {"response": None, "error": None}
request_client_holder = {"client": None, "owner_tid": None}
request_client_lock = threading.Lock()
# Request-local cancellation flag. Distinct from agent._interrupt_requested
# because that flag is cleared at run_conversation() turn boundaries, but
# this daemon worker thread can outlive the turn (the gateway caches
# AIAgent instances per session). Tracks whether THIS specific request was
# cancelled by the main thread's interrupt handler, so the transport error
# that is the expected consequence of our own force-close isn't misread as
# a network bug and surfaced to the caller. (PR #6600 — cascading interrupt
# hang.)
_request_cancelled = {"value": False}
def _set_request_client(client):
with request_client_lock:
@@ -238,17 +229,6 @@ def interruptible_api_call(agent, api_kwargs: dict):
)
result["response"] = request_client.chat.completions.create(**api_kwargs)
except Exception as e:
# If the request was cancelled by the main thread's interrupt
# handler, the transport error is the expected consequence of our
# own force-close, NOT a network bug. Swallow it instead of
# surfacing — the main thread raises InterruptedError. (#6600)
if _request_cancelled["value"]:
logger.debug(
"Non-streaming worker caught %s after request cancellation — "
"exiting without surfacing a network error.",
type(e).__name__,
)
return
result["error"] = e
finally:
_close_request_client_once("request_complete")
@@ -526,14 +506,6 @@ def interruptible_api_call(agent, api_kwargs: dict):
break
if agent._interrupt_requested:
# Mark THIS request cancelled before force-closing so the worker's
# exception handler recognizes the forced transport error as a
# cancel and exits cleanly instead of surfacing a network error or
# (in the streaming path) burning full retry cycles. (#6600)
_request_cancelled["value"] = True
logger.debug(
"Force-closing httpx client due to interrupt (not a network error)."
)
# Force-close the in-flight worker-local HTTP connection to stop
# token generation without poisoning the shared client used to
# seed future retries.
@@ -1653,14 +1625,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
result = {"response": None, "error": None, "partial_tool_names": []}
request_client_holder = {"client": None, "diag": None, "owner_tid": None}
request_client_lock = threading.Lock()
# Request-local cancellation flag — see interruptible_api_call for the full
# rationale. The streaming retry loop is where the 7-minute cascading-
# interrupt hang originated: a force-close raised RemoteProtocolError, the
# loop classified it as a transient network error, and burned full retry
# cycles (and emitted "reconnecting" noise) on a request the user already
# cancelled. The token lets the worker recognize its own forced close and
# exit immediately instead of retrying. (PR #6600.)
_request_cancelled = {"value": False}
def _set_request_client(client):
with request_client_lock:
@@ -1972,72 +1936,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
),
))
# Zero-chunk guard: stream yielded nothing usable — a provider/upstream
# error or malformed SSE, not a legitimate empty completion. Raise so the
# retry machinery handles it instead of fabricating a successful turn.
if (
finish_reason is None
and not content_parts
and not reasoning_parts
and not tool_calls_acc
):
raise RuntimeError(
"Provider returned an empty stream with no finish_reason "
"(possible upstream error or malformed SSE response)."
)
# A stream that delivered a tool call but only partial/unparseable
# JSON args splits into two very different cases:
#
# 1. Provider sent finish_reason="length" → a genuine output-cap
# truncation. Boosting max_tokens on retry is the right move.
#
# 2. Provider sent NO finish_reason (the SSE simply stopped after
# the opening "{" with no terminator and no [DONE]) → the
# upstream dropped/stalled the connection mid tool-call. This
# is NOT an output cap — the model never reported hitting one.
# Some dedicated endpoints (e.g. NVIDIA Nemotron Ultra on the
# Nous dedicated endpoint) stall for minutes during large
# tool-arg generation, then close the stream cleanly without a
# finish_reason. Stamping "length" here sends it down the
# max_tokens-boost truncation path, which retries 3× to no
# effect and finally reports the misleading "Response truncated
# due to output length limit" — the red herring this guards
# against. Route it through the partial-stream-stub path
# instead so the loop reports an honest mid-tool-call stream
# drop and fails fast rather than escalating output budget.
_tool_args_dropped_no_finish = has_truncated_tool_args and finish_reason is None
if _tool_args_dropped_no_finish:
_dropped_names = [
(tool_calls_acc[idx]["function"]["name"] or "?")
for idx in sorted(tool_calls_acc)
]
logger.warning(
"Stream ended with no finish_reason while a tool call's "
"arguments were still incomplete (tools=%s); treating as a "
"mid-tool-call stream drop, not an output-length truncation.",
_dropped_names,
)
full_reasoning = "".join(reasoning_parts) or None
mock_message = SimpleNamespace(
role=role,
content=full_content,
tool_calls=None,
reasoning_content=full_reasoning,
)
mock_choice = SimpleNamespace(
index=0,
message=mock_message,
finish_reason=FINISH_REASON_LENGTH,
)
return SimpleNamespace(
id=PARTIAL_STREAM_STUB_ID,
model=model_name,
choices=[mock_choice],
usage=usage_obj,
_dropped_tool_names=_dropped_names or None,
)
effective_finish_reason = finish_reason or "stop"
if has_truncated_tool_args:
effective_finish_reason = "length"
@@ -2076,14 +1974,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# Per-attempt diagnostic dict for the retry block to consume.
_diag = agent._stream_diag_init()
request_client_holder["diag"] = _diag
# Defensive: strip Responses-only kwargs (instructions, input, ...)
# that can leak in under an api_mode-flip race. The Anthropic SDK
# raises a non-retryable TypeError on them, killing the turn. See
# #31673 / sanitize_anthropic_kwargs().
from agent.anthropic_adapter import sanitize_anthropic_kwargs
sanitize_anthropic_kwargs(
api_kwargs, log_prefix=getattr(agent, "log_prefix", "")
)
# Use the Anthropic SDK's streaming context manager
with agent._anthropic_client.messages.stream(**api_kwargs) as stream:
# The Anthropic SDK exposes the raw httpx response on
@@ -2154,7 +2044,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
def _call():
import httpx as _httpx
_max_stream_retries = env_int("HERMES_STREAM_RETRIES", 2)
_max_stream_retries = int(os.getenv("HERMES_STREAM_RETRIES", 2))
try:
for _stream_attempt in range(_max_stream_retries + 1):
@@ -2174,21 +2064,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
result["response"] = _call_chat_completions()
return # success
except Exception as e:
# If the main poll loop force-closed this request because
# of an interrupt, the resulting transport error is the
# expected consequence of our own close — NOT a transient
# network error. Exit immediately: no retry, no fallback,
# no "reconnecting" status. The outer poll loop raises
# InterruptedError. This is the fix for the cascading-
# interrupt hang where doomed retries burned full
# stream-stale-timeout cycles. (#6600)
if _request_cancelled["value"]:
logger.debug(
"Streaming worker caught %s after request "
"cancellation — exiting without retry.",
type(e).__name__,
)
return
_is_timeout = isinstance(
e, (_httpx.ReadTimeout, _httpx.ConnectTimeout, _httpx.PoolTimeout)
)
@@ -2498,15 +2373,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
)
if agent._interrupt_requested:
# Mark THIS request cancelled before force-closing so the worker's
# exception handler recognizes the forced transport error as a
# cancel and exits without retrying or surfacing a network error.
# (#6600)
_request_cancelled["value"] = True
logger.debug(
"Force-closing streaming httpx client due to interrupt "
"(not a network error)."
)
try:
if agent.api_mode == "anthropic_messages":
agent._anthropic_client.close()

View File

@@ -553,22 +553,6 @@ class ContextCompressor(ContextEngine):
self.last_rough_tokens_when_real_prompt_fit = 0
self.awaiting_real_usage_after_compression = False
def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
"""Clear per-session compaction state at a real session boundary.
``_previous_summary`` is per-session iterative-summary state. It is
cleared on ``on_session_reset()`` (/new, /reset), but session *end*
(CLI exit, gateway expiry, session-id rotation) goes through
``on_session_end()`` instead — which inherited a no-op from
``ContextEngine``. Without clearing here, a cron/background session's
summary could survive on a reused compressor instance and leak into the
next live session via the ``_generate_summary()`` iterative-update path
(#38788). ``compress()`` already guards the leak at the point of use;
this is defense-in-depth that drops the stale summary the moment the
owning session ends.
"""
self._previous_summary = None
def update_model(
self,
model: str,
@@ -1263,19 +1247,6 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
summary_budget = self._compute_summary_budget(turns_to_summarize)
content_to_summarize = self._serialize_for_summary(turns_to_summarize)
# Current date for temporal anchoring (see ## Temporal Anchoring below).
# Date-only granularity matches system_prompt.py:337 (PR #20451) and the
# user's configured timezone via hermes_time.now(). The compaction summary
# is a mid-conversation message that is NOT part of the cached prefix, so a
# date here never affects prompt-cache stability. Resolved defensively —
# a clock failure must never block compaction.
try:
from hermes_time import now as _hermes_now
_today_str = _hermes_now().strftime("%Y-%m-%d")
except Exception: # pragma: no cover - clock resolution is best-effort
_today_str = ""
# Preamble shared by both first-compaction and iterative-update prompts.
# Keep the wording deliberately plain: Azure/OpenAI-compatible content
# filters have flagged stronger "injection" / "do not respond" framing.
@@ -1293,24 +1264,6 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
"do not preserve their values."
)
# Temporal anchoring directive. Rewrites relative / still-pending-sounding
# references into absolute, dated, past-tense facts so a resumed
# conversation does not re-issue completed actions. Only emitted when the
# current date resolved successfully; otherwise the rule is omitted so the
# summarizer is never handed an empty date placeholder.
if _today_str:
_temporal_anchoring_rule = (
f"\nTEMPORAL ANCHORING: The current date is {_today_str}. When an "
"action has already been carried out, phrase it as a completed, "
"dated, past-tense fact rather than an open instruction. For "
'example, rewrite "email John about the proposal" as "Sent the '
f'proposal email to John on {_today_str}." Never leave a finished '
"action worded as if it still needs doing, and never invent a date "
"for work that has not happened yet.\n"
)
else:
_temporal_anchoring_rule = ""
# Shared structured template (used by both paths).
_template_sections = f"""## Active Task
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
@@ -1384,7 +1337,7 @@ Be specific with file paths, commands, line numbers, and results.]
[Any specific values, error messages, configuration details, or data that would be lost without explicit preservation. NEVER include API keys, tokens, passwords, or credentials — write [REDACTED] instead.]
Target ~{summary_budget} tokens. Be CONCRETE — include file paths, command outputs, error messages, line numbers, and specific values. Avoid vague descriptions like "made some changes" — say exactly what changed.
{_temporal_anchoring_rule}
Write only the summary body. Do not include any preamble or prefix."""
if self._previous_summary:
@@ -1834,41 +1787,6 @@ The user has requested that this compaction PRIORITISE preserving all informatio
accumulated += msg_tokens
cut_idx = i
# If the backward walk never broke early because the entire transcript
# fits within soft_ceiling, accumulated now holds the total transcript
# size. Without intervention _ensure_last_user_message_in_tail pushes
# cut_idx forward to include the last user message, and the caller's
# compress_start >= compress_end guard either returns unchanged (no-op)
# or compresses a single message — both of which trigger the infinite
# compaction loop described in #40803.
#
# Fix: when the whole transcript fits in soft_ceiling, compute a
# meaningful cut point using the raw (non-inflated) budget so that
# compression actually summarizes a worthwhile middle section.
if cut_idx <= head_end and accumulated <= soft_ceiling and accumulated > 0:
# The entire compressable region fits in the soft ceiling.
# Re-walk with the raw budget (no 1.5x multiplier) to find a
# split that gives the summarizer something useful.
raw_budget = token_budget
raw_accumulated = 0
for j in range(n - 1, head_end - 1, -1):
raw_msg = messages[j]
raw_content = raw_msg.get("content") or ""
raw_len = _content_length_for_budget(raw_content)
raw_tok = raw_len // _CHARS_PER_TOKEN + 10
for tc in raw_msg.get("tool_calls") or []:
if isinstance(tc, dict):
args = tc.get("function", {}).get("arguments", "")
raw_tok += len(args) // _CHARS_PER_TOKEN
if raw_accumulated + raw_tok > raw_budget and (n - j) >= min_tail:
cut_idx = j
break
raw_accumulated += raw_tok
cut_idx = j
# If the raw-budget walk also consumed everything (very small
# transcript), fall through — the existing fallback logic below
# will still force a minimal cut after head_end.
# Ensure we protect at least min_tail messages
fallback_cut = n - min_tail
cut_idx = min(cut_idx, fallback_cut)
@@ -1971,21 +1889,6 @@ The user has requested that this compaction PRIORITISE preserving all informatio
compress_end = self._find_tail_cut_by_tokens(messages, compress_start)
if compress_start >= compress_end:
# No compressable window — the entire transcript fits within
# the tail budget (soft_ceiling). Without recording this as
# an ineffective compression the anti-thrashing guard in
# should_compress() never fires and every subsequent turn
# re-triggers a no-op compression loop. (#40803)
self._ineffective_compression_count += 1
self._last_compression_savings_pct = 0.0
if not self.quiet_mode:
logger.warning(
"Compression skipped: compress_start (%d) >= compress_end (%d) "
"— transcript fits within tail budget, nothing to compress. "
"ineffective_compression_count=%d",
compress_start, compress_end,
self._ineffective_compression_count,
)
return messages
turns_to_summarize = messages[compress_start:compress_end]
@@ -2006,13 +1909,6 @@ The user has requested that this compaction PRIORITISE preserving all informatio
if summary_body and not self._previous_summary:
self._previous_summary = summary_body
turns_to_summarize = messages[max(compress_start, summary_idx + 1):compress_end]
elif self._previous_summary:
# No handoff summary found in the current messages, but
# _previous_summary is non-empty — it was set by a different
# (now-ended) session (e.g., a cron job, a prior /new). Discard
# it so _generate_summary() does not inject cross-session content
# into the summarizer prompt via the iterative-update path.
self._previous_summary = None
if not self.quiet_mode:
logger.info(

View File

@@ -507,29 +507,12 @@ def compress_context(
agent._session_db.end_session(agent.session_id, "compression")
old_session_id = agent.session_id
agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
# Ordering contract: the agent thread updates the contextvar here;
# the gateway propagates to SessionEntry after run_in_executor returns.
try:
from gateway.session_context import set_current_session_id
set_current_session_id(agent.session_id)
except Exception:
os.environ["HERMES_SESSION_ID"] = agent.session_id
# The gateway/tools session context (ContextVar + env) and the
# logging session context are SEPARATE mechanisms. The call above
# moves the former; the ``[session_id]`` tag on log lines comes
# from ``hermes_logging._session_context`` (set once per turn in
# conversation_loop.py). Without this, post-rotation log lines in
# the same turn keep the STALE old id while the message/DB/gateway
# state carry the new one — breaking log correlation exactly at the
# compaction boundary (see #34089). Guarded separately so a logging
# failure can never regress the routing update above.
try:
from hermes_logging import set_session_context
set_session_context(agent.session_id)
except Exception:
pass
agent._session_db_created = False
agent._session_db.create_session(
session_id=agent.session_id,

File diff suppressed because it is too large Load Diff

View File

@@ -91,7 +91,6 @@ AUTH_TYPE_OAUTH = "oauth"
AUTH_TYPE_API_KEY = "api_key"
SOURCE_MANUAL = "manual"
SOURCE_MANUAL_DEVICE_CODE = f"{SOURCE_MANUAL}:device_code"
STRATEGY_FILL_FIRST = "fill_first"
STRATEGY_ROUND_ROBIN = "round_robin"
@@ -375,7 +374,7 @@ def _iter_custom_providers(config: Optional[dict] = None):
yield _normalize_custom_pool_name(name), entry
def get_custom_provider_pool_key(base_url: Optional[str], provider_name: Optional[str] = None) -> Optional[str]:
def get_custom_provider_pool_key(base_url: str, provider_name: Optional[str] = None) -> Optional[str]:
"""Look up the custom_providers list in config.yaml and return 'custom:<name>' for a matching base_url.
When provider_name is given, prefer matching by name first (solving the case where

View File

@@ -375,11 +375,6 @@ CURATOR_REVIEW_PROMPT = (
"into ~/.hermes/skills/.archive/) is the maximum destructive action. "
"Archives are recoverable; deletion is not.\n"
"3. DO NOT touch skills shown as pinned=yes. Skip them entirely.\n"
"3b. DO NOT archive, delete, consolidate, move, or otherwise modify any "
"skill named in the protected built-ins list (currently: plan). These "
"back load-bearing UX (slash-command entry points referenced in docs and "
"tips) and are filtered out of the candidate list below — never resurrect "
"one as an archive or absorb target.\n"
"4. DO NOT use usage counters as a reason to skip consolidation. The "
"counters are new and often mostly zero. Judge overlap on CONTENT, "
"not on use_count. 'use=0' is not evidence a skill is valuable; it's "

View File

@@ -219,35 +219,6 @@ def _supports_vision_override(
coerced = _coerce_capability_bool(per_model.get("supports_vision"))
if coerced is not None:
return coerced
# 2b. Legacy list-style custom_providers. Entries are dicts with a
# "name" key and a nested "models" dict. Match by provider name (which
# may appear as the raw name or "custom:<name>" at runtime).
custom_providers = cfg.get("custom_providers")
if isinstance(custom_providers, list):
# Build candidate names: the provider value and the config provider
# value, both raw and with "custom:" prefix stripped/added.
candidate_names: set = set()
for p in filter(None, (provider, config_provider)):
candidate_names.add(p)
if p.startswith("custom:"):
candidate_names.add(p[len("custom:"):])
else:
candidate_names.add(f"custom:{p}")
for entry_raw in custom_providers:
if not isinstance(entry_raw, dict):
continue
entry_name = str(entry_raw.get("name") or "").strip()
if entry_name not in candidate_names:
continue
models_raw = entry_raw.get("models")
models_cfg = models_raw if isinstance(models_raw, dict) else {}
per_model_raw = models_cfg.get(model)
per_model = per_model_raw if isinstance(per_model_raw, dict) else {}
coerced = _coerce_capability_bool(per_model.get("supports_vision"))
if coerced is not None:
return coerced
return None

View File

@@ -20,17 +20,23 @@ import json
import time
from collections import Counter, defaultdict
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
from agent.usage_pricing import (
CanonicalUsage,
DEFAULT_PRICING,
estimate_usage_cost,
format_duration_compact,
has_known_pricing,
)
_DEFAULT_PRICING = DEFAULT_PRICING
def _has_known_pricing(model_name: str, provider: str = None, base_url: str = None) -> bool:
"""Check if a model has known pricing (vs unknown/custom endpoint)."""
return has_known_pricing(model_name, provider=provider, base_url=base_url)
def _estimate_cost(
session_or_model: Dict[str, Any] | str,
@@ -39,8 +45,8 @@ def _estimate_cost(
*,
cache_read_tokens: int = 0,
cache_write_tokens: int = 0,
provider: Optional[str] = None,
base_url: Optional[str] = None,
provider: str = None,
base_url: str = None,
) -> tuple[float, str]:
"""Estimate the USD cost for a session row or a model/token tuple."""
if isinstance(session_or_model, dict):
@@ -71,6 +77,9 @@ def _estimate_cost(
return float(result.amount_usd or 0.0), result.status
def _format_duration(seconds: float) -> str:
"""Format seconds into a human-readable duration string."""
return format_duration_compact(seconds)
def _bar_chart(values: List[int], max_width: int = 20) -> List[str]:
@@ -426,7 +435,7 @@ class InsightsEngine:
included_cost_sessions += 1
elif status == "unknown":
unknown_cost_sessions += 1
if has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url")):
if _has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url")):
models_with_pricing.add(display)
else:
models_without_pricing.add(display)
@@ -499,7 +508,7 @@ class InsightsEngine:
d["tool_calls"] += s.get("tool_call_count") or 0
estimate, status = _estimate_cost(s)
d["cost"] += estimate
d["has_pricing"] = has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url"))
d["has_pricing"] = _has_known_pricing(model, s.get("billing_provider"), s.get("billing_base_url"))
d["cost_status"] = status
result = [
@@ -670,7 +679,7 @@ class InsightsEngine:
top.append({
"label": "Longest session",
"session_id": longest["id"][:16],
"value": format_duration_compact(dur),
"value": _format_duration(dur),
"date": datetime.fromtimestamp(longest["started_at"]).strftime("%b %d"),
})
@@ -755,7 +764,7 @@ class InsightsEngine:
lines.append(f" Input tokens: {o['total_input_tokens']:<12,} Output tokens: {o['total_output_tokens']:,}")
lines.append(f" Total tokens: {o['total_tokens']:,}")
if o["total_hours"] > 0:
lines.append(f" Active time: ~{format_duration_compact(o['total_hours'] * 3600):<11} Avg session: ~{format_duration_compact(o['avg_session_duration'])}")
lines.append(f" Active time: ~{_format_duration(o['total_hours'] * 3600):<11} Avg session: ~{_format_duration(o['avg_session_duration'])}")
lines.append(f" Avg msgs/session: {o['avg_messages_per_session']:.1f}")
lines.append("")
@@ -870,7 +879,7 @@ class InsightsEngine:
lines.append(f"**Sessions:** {o['total_sessions']} | **Messages:** {o['total_messages']:,} | **Tool calls:** {o['total_tool_calls']:,}")
lines.append(f"**Tokens:** {o['total_tokens']:,} (in: {o['total_input_tokens']:,} / out: {o['total_output_tokens']:,})")
if o["total_hours"] > 0:
lines.append(f"**Active time:** ~{format_duration_compact(o['total_hours'] * 3600)} | **Avg session:** ~{format_duration_compact(o['avg_session_duration'])}")
lines.append(f"**Active time:** ~{_format_duration(o['total_hours'] * 3600)} | **Avg session:** ~{_format_duration(o['avg_session_duration'])}")
lines.append("")
# Models (top 5)

View File

@@ -28,8 +28,6 @@ from __future__ import annotations
import logging
import re
import inspect
import threading
from concurrent.futures import ThreadPoolExecutor
from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider
@@ -37,12 +35,6 @@ from tools.registry import tool_error
logger = logging.getLogger(__name__)
# How long shutdown_all() waits for in-flight background sync/prefetch work
# to drain before abandoning it. A wedged provider must never block process
# teardown indefinitely — the worker threads are daemon, so anything still
# running past this window dies with the interpreter.
_SYNC_DRAIN_TIMEOUT_S = 5.0
# ---------------------------------------------------------------------------
# Context fencing helpers
@@ -260,13 +252,6 @@ class MemoryManager:
self._providers: List[MemoryProvider] = []
self._tool_to_provider: Dict[str, MemoryProvider] = {}
self._has_external: bool = False # True once a non-builtin provider is added
# Background executor for end-of-turn sync/prefetch. Lazily created on
# first use so the common builtin-only path spawns no extra threads.
# A single worker serializes a provider's writes (turn N must land
# before turn N+1) and caps thread growth at one per manager. See
# _submit_background() and the sync_all/queue_prefetch_all rationale.
self._sync_executor: Optional[ThreadPoolExecutor] = None
self._sync_executor_lock = threading.Lock()
# -- Registration --------------------------------------------------------
@@ -296,28 +281,9 @@ class MemoryManager:
self._providers.append(provider)
# Core tool names are reserved — a memory provider must never register
# a tool that shadows a built-in (e.g. ``clarify``, ``delegate_task``).
# Built-ins always win, so such a tool is dropped at agent init and
# would otherwise linger in ``_tool_to_provider`` and hijack dispatch
# (#40466). Reject it here, at the door, so it never enters the routing
# table at all — matching the built-ins-always-win invariant used by
# the TTS/browser/search provider registries.
from toolsets import _HERMES_CORE_TOOLS
_core_tool_names = set(_HERMES_CORE_TOOLS)
# Index tool names → provider for routing
for schema in provider.get_tool_schemas():
tool_name = schema.get("name", "")
if tool_name in _core_tool_names:
logger.warning(
"Memory provider '%s' tool '%s' shadows a reserved core "
"tool name; registration ignored. Core tools always win — "
"rename the provider's tool to something unique.",
provider.name, tool_name,
)
continue
if tool_name and tool_name not in self._tool_to_provider:
self._tool_to_provider[tool_name] = provider
elif tool_name in self._tool_to_provider:
@@ -390,27 +356,15 @@ class MemoryManager:
return "\n\n".join(parts)
def queue_prefetch_all(self, query: str, *, session_id: str = "") -> None:
"""Queue background prefetch on all providers for the next turn.
Provider work is dispatched to a background worker so a slow or
wedged provider can never block the caller. See ``sync_all`` for
the full rationale (agent stuck "running" minutes after a turn).
"""
providers = list(self._providers)
if not providers:
return
def _run() -> None:
for provider in providers:
try:
provider.queue_prefetch(query, session_id=session_id)
except Exception as e:
logger.debug(
"Memory provider '%s' queue_prefetch failed (non-fatal): %s",
provider.name, e,
)
self._submit_background(_run)
"""Queue background prefetch on all providers for the next turn."""
for provider in self._providers:
try:
provider.queue_prefetch(query, session_id=session_id)
except Exception as e:
logger.debug(
"Memory provider '%s' queue_prefetch failed (non-fatal): %s",
provider.name, e,
)
# -- Sync ----------------------------------------------------------------
@@ -434,142 +388,38 @@ class MemoryManager:
session_id: str = "",
messages: Optional[List[Dict[str, Any]]] = None,
) -> None:
"""Sync a completed turn to all providers.
Runs on a background worker thread, NOT inline on the
turn-completion path. A provider's ``sync_turn`` may make a
blocking network/daemon call (a misconfigured Hindsight daemon
was observed blocking ~298s before failing); doing that inline
held ``run_conversation`` open long after the user saw their
response, so every interface (CLI, TUI, gateway) kept the agent
marked "running" for minutes and any follow-up message triggered
an aggressive interrupt. Dispatching off-thread means a slow or
broken provider can never stall the turn — the sync simply
completes (or fails, logged) in the background.
Writes are serialized through a single worker so turn N lands
before turn N+1; provider implementations don't need their own
ordering guarantees.
"""
providers = list(self._providers)
if not providers:
return
def _run() -> None:
for provider in providers:
try:
if messages is not None and self._provider_sync_accepts_messages(provider):
provider.sync_turn(
user_content,
assistant_content,
session_id=session_id,
messages=messages,
)
else:
provider.sync_turn(
user_content,
assistant_content,
session_id=session_id,
)
except Exception as e:
logger.warning(
"Memory provider '%s' sync_turn failed: %s",
provider.name, e,
)
self._submit_background(_run)
# -- Background dispatch -------------------------------------------------
def _submit_background(self, fn) -> None:
"""Run ``fn`` on the manager's background worker.
The executor is created lazily and shared across calls. If the
executor can't be created or has already been shut down, ``fn``
runs inline as a last-resort fallback — losing the async benefit
but never losing the write itself. ``fn`` must do its own
per-provider error handling; this wrapper only guards executor
plumbing.
"""
executor = self._get_sync_executor()
if executor is None:
# Executor unavailable (shut down / creation failed) — run
# inline rather than drop the work. Slow, but correct.
"""Sync a completed turn to all providers."""
for provider in self._providers:
try:
fn()
except Exception as e: # pragma: no cover - fn guards internally
logger.debug("Inline memory background task failed: %s", e)
return
try:
executor.submit(fn)
except RuntimeError:
# Executor was shut down between the get and the submit
# (teardown race). Fall back to inline.
try:
fn()
except Exception as e: # pragma: no cover - fn guards internally
logger.debug("Inline memory background task failed: %s", e)
def _get_sync_executor(self) -> Optional[ThreadPoolExecutor]:
"""Lazily create the single-worker background executor."""
if self._sync_executor is not None:
return self._sync_executor
with self._sync_executor_lock:
if self._sync_executor is None:
try:
self._sync_executor = ThreadPoolExecutor(
max_workers=1,
thread_name_prefix="mem-sync",
if messages is not None and self._provider_sync_accepts_messages(provider):
provider.sync_turn(
user_content,
assistant_content,
session_id=session_id,
messages=messages,
)
except Exception as e: # pragma: no cover - resource exhaustion
logger.warning("Failed to create memory sync executor: %s", e)
return None
return self._sync_executor
def flush_pending(self, timeout: Optional[float] = None) -> bool:
"""Block until queued sync/prefetch work has drained.
Single-worker executor means submitting a sentinel and waiting on
it guarantees every previously-submitted task has run. Returns
True if the barrier completed within ``timeout`` (or no executor
exists), False on timeout. Used at real session boundaries and by
tests that need to assert provider state deterministically.
"""
executor = self._sync_executor
if executor is None:
return True
try:
fut = executor.submit(lambda: None)
except RuntimeError:
# Executor already shut down — nothing pending.
return True
try:
fut.result(timeout=timeout)
return True
except Exception:
return False
else:
provider.sync_turn(
user_content,
assistant_content,
session_id=session_id,
)
except Exception as e:
logger.warning(
"Memory provider '%s' sync_turn failed: %s",
provider.name, e,
)
# -- Tools ---------------------------------------------------------------
def get_all_tool_schemas(self) -> List[Dict[str, Any]]:
"""Collect tool schemas from all providers.
Reserved core tool names (``clarify``, ``delegate_task``, etc.) are
skipped — they are rejected from the routing table in
:meth:`add_provider`, so the manager must not advertise a schema it
will never route. Built-ins always win (#40466).
"""
from toolsets import _HERMES_CORE_TOOLS
_core_tool_names = set(_HERMES_CORE_TOOLS)
"""Collect tool schemas from all providers."""
schemas = []
seen = set()
for provider in self._providers:
try:
for schema in provider.get_tool_schemas():
name = schema.get("name", "")
if name in _core_tool_names:
continue
if name and name not in seen:
schemas.append(schema)
seen.add(name)
@@ -773,15 +623,7 @@ class MemoryManager:
)
def shutdown_all(self) -> None:
"""Shut down all providers (reverse order for clean teardown).
Drains the background sync/prefetch executor first (bounded by
``_SYNC_DRAIN_TIMEOUT_S``) so a turn's final sync has a chance to
land before providers are torn down. The worker threads are
daemon, so anything still wedged past the drain window dies with
the interpreter rather than blocking exit.
"""
self._drain_sync_executor()
"""Shut down all providers (reverse order for clean teardown)."""
for provider in reversed(self._providers):
try:
provider.shutdown()
@@ -791,52 +633,6 @@ class MemoryManager:
provider.name, e,
)
def _drain_sync_executor(self) -> None:
"""Shut down the background executor, waiting briefly for drain.
Bounded by ``_SYNC_DRAIN_TIMEOUT_S``: a wedged provider must never
hang process/session teardown. We stop accepting new work and
cancel anything still queued, then wait at most the drain timeout
for the currently-running task on a watcher thread. The worker is
daemon, so an over-running task dies with the interpreter.
"""
with self._sync_executor_lock:
executor = self._sync_executor
self._sync_executor = None
if executor is None:
return
try:
# Stop accepting new work and drop anything still queued, but
# do NOT block here — cancel_futures cancels not-yet-started
# tasks; the in-flight one keeps running on its daemon thread.
executor.shutdown(wait=False, cancel_futures=True)
except TypeError:
# Older Python without cancel_futures kwarg.
try:
executor.shutdown(wait=False)
except Exception as e: # pragma: no cover
logger.debug("Memory sync executor shutdown failed: %s", e)
return
except Exception as e: # pragma: no cover
logger.debug("Memory sync executor shutdown failed: %s", e)
return
# Give an in-flight sync a bounded chance to finish on a watcher
# thread so we don't block the caller past the drain timeout.
drainer = threading.Thread(
target=lambda: self._bounded_executor_wait(executor),
daemon=True,
name="mem-sync-drain",
)
drainer.start()
drainer.join(timeout=_SYNC_DRAIN_TIMEOUT_S)
@staticmethod
def _bounded_executor_wait(executor: ThreadPoolExecutor) -> None:
try:
executor.shutdown(wait=True)
except Exception as e: # pragma: no cover
logger.debug("Memory sync executor drain wait failed: %s", e)
def initialize_all(self, session_id: str, **kwargs) -> None:
"""Initialize all providers.

View File

@@ -964,10 +964,6 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
is_output_cap_error = (
"max_tokens" in error_lower
and ("available_tokens" in error_lower or "available tokens" in error_lower)
) or (
# OpenRouter/Nous phrasing of the same condition.
"in the output" in error_lower
and "maximum context length" in error_lower
)
if not is_output_cap_error:
return None
@@ -986,19 +982,6 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
tokens = int(match.group(1))
if tokens >= 1:
return tokens
# OpenRouter/Nous format: "maximum context length is N … (A of text input,
# B of tool input, C in the output)". Available output = ctx - text - tool.
_m_ctx = re.search(r'maximum context length is (\d+)', error_lower)
_m_parts = re.search(
r'\((\d+)\s+of text input,\s*(\d+)\s+of tool input,\s*(\d+)\s+in the output\)',
error_lower,
)
if _m_ctx and _m_parts:
_available = int(_m_ctx.group(1)) - int(_m_parts.group(1)) - int(_m_parts.group(2))
if _available >= 1:
return _available
return None
@@ -1684,26 +1667,6 @@ def get_model_context_length(
"in config.yaml to override.",
model, base_url, f"{DEFAULT_FALLBACK_CONTEXT:,}",
)
# 3b. Before falling back to the hard 256K default, consult the
# hardcoded catalog as a last resort. A proxied/custom Anthropic
# gateway (e.g. corporate proxy) fails the Ollama/local probes
# above, but the model name may still match an entry in
# DEFAULT_CONTEXT_LENGTHS (e.g. "claude-opus-4-8" → 1M).
# Without this, the early return here short-circuits the catalog
# lookup at step 8 and silently caps context at 256K.
model_lower = model.lower()
for default_model, length in sorted(
DEFAULT_CONTEXT_LENGTHS.items(),
key=lambda x: len(x[0]),
reverse=True,
):
if default_model in model_lower:
logger.info(
"Using hardcoded context length %s for model %r "
"(custom endpoint, catalog match on %r)",
f"{length:,}", model, default_model,
)
return length
return DEFAULT_FALLBACK_CONTEXT
# 4. Anthropic /v1/models API (only for regular API keys, not OAuth)

View File

@@ -26,7 +26,6 @@ logger = logging.getLogger(__name__)
BUSY_INPUT_FLAG = "busy_input_prompt"
TOOL_PROGRESS_FLAG = "tool_progress_prompt"
OPENCLAW_RESIDUE_FLAG = "openclaw_residue_cleanup"
PROFILE_BUILD_FLAG = "profile_build_offered"
# -------------------------------------------------------------------------
@@ -127,62 +126,6 @@ def detect_openclaw_residue(home: Optional[Path] = None) -> bool:
return False
# -------------------------------------------------------------------------
# Onboarding profile-build path (opt-in, consent-gated)
# -------------------------------------------------------------------------
def profile_build_mode(config: Mapping[str, Any]) -> str:
"""Resolve the onboarding profile-build mode from config.
Returns one of:
``"ask"`` — on first contact, OFFER to build a profile (default).
``"off"`` — never offer; the first-message note stays a plain intro.
Read from ``config.onboarding.profile_build``. Unknown / missing values
fall back to ``"ask"`` so the default experience offers the flow. Any
network/account lookups inside the flow are separately consented to in
conversation — this setting only governs whether the offer is made.
"""
if not isinstance(config, Mapping):
return "ask"
onboarding = config.get("onboarding")
if not isinstance(onboarding, Mapping):
return "ask"
mode = onboarding.get("profile_build")
if isinstance(mode, str) and mode.strip().lower() == "off":
return "off"
return "ask"
def profile_build_directive() -> str:
"""System-note directive appended to the very first message ever.
Instructs the agent to run a short, opt-in, consent-gated profile-build
flow and persist confirmed facts to the user-profile memory store
(``memory`` tool, ``target="user"``). Phrased so the agent ASKS before any
lookup and never silently reads connected accounts — directly addressing
the privacy concern that reading email/accounts unprompted feels invasive.
"""
return (
"\n\n[System note: This is the user's very first message ever. "
"After a one-sentence introduction (mention /help shows commands), "
"OFFER — do not assume — to build a short profile of them so you can "
"be more useful, and explain they can decline or do it later. If and "
"ONLY IF they accept:\n"
" 1. Ask for whatever they're comfortable sharing (name, what they "
"do, how they like you to work). Volunteered facts come first.\n"
" 2. Before ANY external lookup, say what you intend to look up and "
"get explicit consent for that step. Never read their connected "
"accounts (email, calendar, etc.) silently — ask each time.\n"
" 3. With consent, you may use web_search to confirm public details "
"(e.g. employer, public profiles) from the data points they gave.\n"
" 4. Save each confirmed, durable fact with the memory tool using "
"target=\"user\" — keep entries compact and high-signal.\n"
"If they decline at any point, stop immediately and continue normally. "
"Keep the whole exchange light and conversational, not an interrogation.]"
)
# -------------------------------------------------------------------------
# State read / write
# -------------------------------------------------------------------------
@@ -239,15 +182,12 @@ __all__ = [
"BUSY_INPUT_FLAG",
"TOOL_PROGRESS_FLAG",
"OPENCLAW_RESIDUE_FLAG",
"PROFILE_BUILD_FLAG",
"busy_input_hint_gateway",
"busy_input_hint_cli",
"tool_progress_hint_gateway",
"tool_progress_hint_cli",
"openclaw_residue_hint_cli",
"detect_openclaw_residue",
"profile_build_mode",
"profile_build_directive",
"is_seen",
"mark_seen",
]

View File

@@ -324,11 +324,8 @@ def install_bws(*, force: bool = False) -> Path:
with zipfile.ZipFile(zip_path) as zf:
member = _pick_zip_member(zf, _platform_binary_name())
# Zip-slip guard: a malicious archive can carry member names like
# ``../../etc/cron.d/x`` or absolute paths. ``ZipFile.extract``
# joins the member onto ``tmp`` without verifying the result stays
# inside it, so validate containment before touching the disk.
extracted = _safe_extract_member(zf, member, tmp)
zf.extract(member, tmp)
extracted = tmp / member
# Move into place atomically. We write to a sibling tempfile in
# the final directory so the rename can't cross filesystems.
@@ -398,33 +395,6 @@ def _pick_zip_member(zf: zipfile.ZipFile, binary_name: str) -> str:
return candidates[0]
def _safe_extract_member(
zf: zipfile.ZipFile, member: str, dest_dir: Path
) -> Path:
"""Extract a single archive member, refusing path traversal.
``ZipFile.extract`` will happily honour member names containing
``../`` or absolute paths, letting a malicious archive write outside
``dest_dir`` (a "zip-slip"). We resolve the would-be target and
confirm it stays within ``dest_dir`` before extracting.
"""
dest_root = os.path.realpath(dest_dir)
target = os.path.realpath(os.path.join(dest_root, member))
# ``commonpath`` raises ValueError for e.g. different drives on
# Windows; treat that as an escape too.
try:
contained = os.path.commonpath([dest_root, target]) == dest_root
except ValueError:
contained = False
if not contained or target == dest_root:
raise RuntimeError(
f"Refusing to extract unsafe archive member {member!r}: "
f"it escapes the extraction directory"
)
zf.extract(member, dest_root)
return Path(target)
# ---------------------------------------------------------------------------
# Secret fetch + apply
# ---------------------------------------------------------------------------

View File

@@ -702,7 +702,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
if agent._should_emit_quiet_tool_messages():
cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result)
agent._safe_print(f" {cute_msg}")
elif getattr(agent, "tool_progress_mode", "all") != "off":
elif not agent.quiet_mode:
_preview_str = _multimodal_text_summary(function_result)
if agent.verbose_logging:
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s")

View File

@@ -1,388 +0,0 @@
"""Per-turn setup for ``run_conversation`` (the turn prologue).
``run_conversation`` opened with ~470 lines of straight-line setup before the
tool-calling loop ever started: stdio guarding, runtime-main wiring, retry-counter
resets, user-message sanitization, todo/nudge-counter hydration, system-prompt
restore-or-build, crash-resilience persistence, preflight context compression, the
``pre_llm_call`` plugin hook, and external-memory prefetch.
All of that is *prologue* — it runs once per turn, has no back-references into the
loop, and produces a fixed set of values the loop then consumes. ``TurnContext``
captures those produced values; ``build_turn_context`` performs the setup work and
returns one. ``run_conversation`` is left to unpack the context and run the loop,
shrinking the orchestrator by the full prologue.
The builder still mutates ``agent`` heavily (counters, thread id, cached prompt,
session DB) exactly as the inline code did — those side effects are the point. The
``TurnContext`` it returns carries only the *locals* the loop reads back.
Behavior is identical to the original inline prologue; this is a pure
move-and-name refactor with no semantic change.
"""
from __future__ import annotations
import logging
import threading
import uuid
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
from agent.iteration_budget import IterationBudget
from agent.model_metadata import estimate_request_tokens_rough
logger = logging.getLogger(__name__)
@dataclass
class TurnContext:
"""Values produced by the turn prologue and consumed by the turn loop."""
# Sanitized inbound message (surrogates stripped).
user_message: str
# Clean message preserved for transcripts / memory queries (no nudge injection).
original_user_message: Any
# Working message list for this turn (loop appends to it).
messages: List[Dict[str, Any]]
# May be reset to None by preflight compression (new session created).
conversation_history: Optional[List[Dict[str, Any]]]
# Cached system prompt active for this turn (may be rebuilt by compression).
active_system_prompt: Optional[str]
# Task / turn identifiers.
effective_task_id: str
turn_id: str
# Index of the current user turn within ``messages``.
current_turn_user_idx: int
# Whether the post-turn memory review should fire.
should_review_memory: bool = False
# Context contributed by ``pre_llm_call`` plugins (appended to user message).
plugin_user_context: str = ""
# External-memory prefetch result, reused across loop iterations.
ext_prefetch_cache: str = ""
def build_turn_context(
agent,
user_message: str,
system_message: Optional[str],
conversation_history: Optional[List[Dict[str, Any]]],
task_id: Optional[str],
stream_callback,
persist_user_message: Optional[str],
*,
restore_or_build_system_prompt,
install_safe_stdio,
sanitize_surrogates,
summarize_user_message_for_log,
set_session_context,
set_current_write_origin,
ra,
) -> TurnContext:
"""Run the once-per-turn setup and return the loop's input context.
The callables/helpers the original prologue referenced from the
``conversation_loop`` module are passed in explicitly to keep this module
free of an import cycle with ``agent.conversation_loop``.
"""
# Guard stdio against OSError from broken pipes (systemd/headless/daemon).
install_safe_stdio()
agent._ensure_db_session()
# Tell auxiliary_client what the live main provider/model are for this turn.
try:
from agent.auxiliary_client import set_runtime_main
set_runtime_main(
getattr(agent, "provider", "") or "",
getattr(agent, "model", "") or "",
base_url=getattr(agent, "base_url", "") or "",
api_key=getattr(agent, "api_key", "") or "",
api_mode=getattr(agent, "api_mode", "") or "",
)
except Exception:
pass
# Tag log records on this thread with the session ID for ``hermes logs``.
set_session_context(agent.session_id)
# Bind the skill write-origin ContextVar for this thread.
set_current_write_origin(getattr(agent, "_memory_write_origin", "assistant_tool"))
# Restore the primary runtime if the previous turn activated fallback.
agent._restore_primary_runtime()
# Sanitize surrogate characters from user input.
if isinstance(user_message, str):
user_message = sanitize_surrogates(user_message)
if isinstance(persist_user_message, str):
persist_user_message = sanitize_surrogates(persist_user_message)
# Store stream callback for _interruptible_api_call to pick up.
agent._stream_callback = stream_callback
agent._persist_user_message_idx = None
agent._persist_user_message_override = persist_user_message
# Generate unique task_id if not provided to isolate VMs between tasks.
effective_task_id = task_id or str(uuid.uuid4())
agent._current_task_id = effective_task_id
turn_id = f"{agent.session_id or 'session'}:{effective_task_id}:{uuid.uuid4().hex[:8]}"
agent._current_turn_id = turn_id
agent._current_api_request_id = ""
# Reset retry counters and iteration budget at the start of each turn.
agent._invalid_tool_retries = 0
agent._invalid_json_retries = 0
agent._empty_content_retries = 0
agent._incomplete_scratchpad_retries = 0
agent._codex_incomplete_retries = 0
agent._thinking_prefill_retries = 0
agent._post_tool_empty_retried = False
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
agent._mute_post_response = False
agent._unicode_sanitization_passes = 0
agent._tool_guardrails.reset_for_turn()
agent._tool_guardrail_halt_decision = None
agent._vision_supported = True
# Pre-turn connection health check: clean up dead TCP connections.
if agent.api_mode != "anthropic_messages":
try:
if agent._cleanup_dead_connections():
agent._emit_status(
"🔌 Detected stale connections from a previous provider "
"issue — cleaned up automatically. Proceeding with fresh "
"connection."
)
except Exception:
pass
# Replay compression warning through status_callback for gateway platforms.
if agent._compression_warning:
agent._replay_compression_warning()
agent._compression_warning = None # send once
# NOTE: _turns_since_memory and _iters_since_skill are NOT reset here.
agent.iteration_budget = IterationBudget(agent.max_iterations)
# Log conversation turn start for debugging/observability.
_preview_text = summarize_user_message_for_log(user_message)
_msg_preview = (_preview_text[:80] + "...") if len(_preview_text) > 80 else _preview_text
_msg_preview = _msg_preview.replace("\n", " ")
logger.info(
"conversation turn: session=%s model=%s provider=%s platform=%s history=%d msg=%r",
agent.session_id or "none", agent.model, agent.provider or "unknown",
agent.platform or "unknown", len(conversation_history or []),
_msg_preview,
)
# Initialize conversation (copy to avoid mutating the caller's list).
messages = list(conversation_history) if conversation_history else []
# Hydrate todo store from conversation history.
if conversation_history and not agent._todo_store.has_items():
agent._hydrate_todo_store(conversation_history)
# Hydrate per-session nudge counters from persisted history (issue #22357).
if conversation_history and agent._user_turn_count == 0:
prior_user_turns = sum(
1 for m in conversation_history if m.get("role") == "user"
)
if prior_user_turns > 0:
agent._user_turn_count = prior_user_turns
if agent._memory_nudge_interval > 0 and agent._turns_since_memory == 0:
agent._turns_since_memory = prior_user_turns % agent._memory_nudge_interval
# Track user turns for memory flush and periodic nudge logic.
agent._user_turn_count += 1
# Reset the streaming context scrubber at the top of each turn.
scrubber = getattr(agent, "_stream_context_scrubber", None)
if scrubber is not None:
scrubber.reset()
# Reset the think scrubber for the same reason.
think_scrubber = getattr(agent, "_stream_think_scrubber", None)
if think_scrubber is not None:
think_scrubber.reset()
# Preserve the original user message (no nudge injection).
original_user_message = persist_user_message if persist_user_message is not None else user_message
# Track memory nudge trigger (turn-based, checked here).
should_review_memory = False
if (agent._memory_nudge_interval > 0
and "memory" in agent.valid_tool_names
and agent._memory_store):
agent._turns_since_memory += 1
if agent._turns_since_memory >= agent._memory_nudge_interval:
should_review_memory = True
agent._turns_since_memory = 0
# Add user message.
user_msg = {"role": "user", "content": user_message}
messages.append(user_msg)
current_turn_user_idx = len(messages) - 1
agent._persist_user_message_idx = current_turn_user_idx
if not agent.quiet_mode:
_print_preview = summarize_user_message_for_log(user_message)
agent._safe_print(
f"💬 Starting conversation: '{_print_preview[:60]}"
f"{'...' if len(_print_preview) > 60 else ''}'"
)
# ── System prompt (cached per session for prefix caching) ──
if agent._cached_system_prompt is None:
restore_or_build_system_prompt(agent, system_message, conversation_history)
active_system_prompt = agent._cached_system_prompt
# Crash-resilience: persist the inbound user turn as soon as the session row exists.
try:
agent._persist_session(messages, conversation_history)
except Exception:
logger.warning(
"Early turn-start session persistence failed for session=%s",
agent.session_id or "none",
exc_info=True,
)
# ── Preflight context compression ──
if (
agent.compression_enabled
and len(messages) > agent.context_compressor.protect_first_n
+ agent.context_compressor.protect_last_n + 1
):
_preflight_tokens = estimate_request_tokens_rough(
messages,
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
_compressor = agent.context_compressor
_defer_preflight = getattr(
_compressor,
"should_defer_preflight_to_real_usage",
lambda _tokens: False,
)
_preflight_deferred = _defer_preflight(_preflight_tokens)
if not _preflight_deferred:
_last = _compressor.last_prompt_tokens
# Do NOT overwrite the -1 sentinel (#36718).
if _last >= 0 and _preflight_tokens > _last:
_compressor.last_prompt_tokens = _preflight_tokens
if _preflight_deferred:
logger.info(
"Skipping preflight compression: rough estimate ~%s >= %s, "
"but last real provider prompt was %s after compression",
f"{_preflight_tokens:,}",
f"{_compressor.threshold_tokens:,}",
f"{_compressor.last_real_prompt_tokens:,}",
)
elif _compressor.should_compress(_preflight_tokens):
logger.info(
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
f"{_preflight_tokens:,}",
f"{_compressor.threshold_tokens:,}",
agent.model,
f"{_compressor.context_length:,}",
)
agent._emit_status(
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
f">= {_compressor.threshold_tokens:,} threshold. "
"This may take a moment."
)
for _pass in range(3):
_orig_len = len(messages)
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=_preflight_tokens,
task_id=effective_task_id,
)
if len(messages) >= _orig_len:
break # Cannot compress further
conversation_history = None
agent._empty_content_retries = 0
agent._thinking_prefill_retries = 0
agent._last_content_with_tools = None
agent._last_content_tools_all_housekeeping = False
agent._mute_post_response = False
_preflight_tokens = estimate_request_tokens_rough(
messages,
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
if not _compressor.should_compress(_preflight_tokens):
break
# Plugin hook: pre_llm_call (context injected into user message, not system prompt).
plugin_user_context = ""
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_pre_results = _invoke_hook(
"pre_llm_call",
session_id=agent.session_id,
task_id=effective_task_id,
turn_id=turn_id,
user_message=original_user_message,
conversation_history=list(messages),
is_first_turn=(not bool(conversation_history)),
model=agent.model,
platform=getattr(agent, "platform", None) or "",
sender_id=getattr(agent, "_user_id", None) or "",
)
_ctx_parts: list[str] = []
for r in _pre_results:
if isinstance(r, dict) and r.get("context"):
_ctx_parts.append(str(r["context"]))
elif isinstance(r, str) and r.strip():
_ctx_parts.append(r)
if _ctx_parts:
plugin_user_context = "\n\n".join(_ctx_parts)
except Exception as exc:
logger.warning("pre_llm_call hook failed: %s", exc)
# Per-turn file-mutation verifier state.
agent._turn_failed_file_mutations = {}
# Record the execution thread so interrupt()/clear_interrupt() can scope
# the tool-level interrupt signal to THIS agent's thread only.
agent._execution_thread_id = threading.current_thread().ident
# Clear stale per-thread interrupt state, preserving a pending interrupt.
ra()._set_interrupt(False, agent._execution_thread_id)
if agent._interrupt_requested:
ra()._set_interrupt(True, agent._execution_thread_id)
agent._interrupt_thread_signal_pending = False
else:
agent._interrupt_message = None
agent._interrupt_thread_signal_pending = False
# Notify memory providers of the new turn (BEFORE prefetch_all).
if agent._memory_manager:
try:
_turn_msg = original_user_message if isinstance(original_user_message, str) else ""
agent._memory_manager.on_turn_start(agent._user_turn_count, _turn_msg)
except Exception:
pass
# External memory provider: prefetch once before the tool loop.
ext_prefetch_cache = ""
if agent._memory_manager:
try:
_query = original_user_message if isinstance(original_user_message, str) else ""
ext_prefetch_cache = agent._memory_manager.prefetch_all(_query) or ""
except Exception:
pass
return TurnContext(
user_message=user_message,
original_user_message=original_user_message,
messages=messages,
conversation_history=conversation_history,
active_system_prompt=active_system_prompt,
effective_task_id=effective_task_id,
turn_id=turn_id,
current_turn_user_idx=current_turn_user_idx,
should_review_memory=should_review_memory,
plugin_user_context=plugin_user_context,
ext_prefetch_cache=ext_prefetch_cache,
)

View File

@@ -1,428 +0,0 @@
"""Post-loop turn finalization for ``run_conversation``.
Extracted from ``agent/conversation_loop.py`` as part of the god-file
decomposition campaign (``~/.hermes/plans/god-file-decomposition.md``, Phase 1
step 4 — the post-loop ``TurnFinalizer`` seam). ``run_conversation``'s tail
(everything after the main tool-calling ``while`` loop) is lifted here verbatim:
budget-exhaustion summary, trajectory save, session persist, turn diagnostics,
response transforms, result-dict assembly, steer drain, and the memory/skill
review trigger.
Behavior-neutral: the body is moved unchanged. All ``agent.*`` side effects fire
exactly as before; only the post-loop *locals* are passed in as keyword args, and
the assembled ``result`` dict is returned to ``run_conversation`` which returns it
to the caller. The function is synchronous with a single return — mirroring the
region it replaces (no awaits, no early returns).
Module ``logger`` is imported lazily inside the body (``from
agent.conversation_loop import logger``) so this module never imports
``agent.conversation_loop`` at import time -> no import cycle, and the log records
keep the exact logger name (``"agent.conversation_loop"``).
"""
from __future__ import annotations
import os
from agent.codex_responses_adapter import _summarize_user_message_for_log
def finalize_turn(
agent,
*,
final_response,
api_call_count,
interrupted,
failed,
messages,
conversation_history,
effective_task_id,
turn_id,
user_message,
original_user_message,
_should_review_memory,
_turn_exit_reason,
):
"""Run the post-loop finalization and return the turn ``result`` dict.
Lifted verbatim from ``run_conversation`` (the region after the main agent
loop). See module docstring.
"""
from agent.conversation_loop import logger
if final_response is None and (
api_call_count >= agent.max_iterations
or agent.iteration_budget.remaining <= 0
):
# Budget exhausted — ask the model for a summary via one extra
# API call with tools stripped. _handle_max_iterations injects a
# user message and makes a single toolless request.
_turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})"
agent._emit_status(
f"⚠️ Iteration budget exhausted ({api_call_count}/{agent.max_iterations}) "
"— asking model to summarise"
)
if not agent.quiet_mode:
agent._safe_print(
f"\n⚠️ Iteration budget exhausted ({api_call_count}/{agent.max_iterations}) "
"— requesting summary..."
)
final_response = agent._handle_max_iterations(messages, api_call_count)
# If running as a kanban worker, signal the dispatcher that the
# worker could not complete (rather than treating it as a
# protocol violation). The agent loop strips tools before calling
# _handle_max_iterations, so the model cannot call kanban_block
# itself — we must do it on its behalf.
#
# We route through ``_record_task_failure(outcome="timed_out")``
# rather than ``kanban_block`` so this counts toward the
# ``consecutive_failures`` counter and the dispatcher's
# ``failure_limit`` circuit breaker (#29747 gap 2). Without this,
# a task whose worker keeps exhausting its budget would block
# silently each run, get auto-promoted by the operator (or never
# surface), and re-block in an endless loop with no signal.
_kanban_task = os.environ.get("HERMES_KANBAN_TASK")
if _kanban_task:
try:
from hermes_cli import kanban_db as _kb
_conn = _kb.connect()
try:
_kb._record_task_failure(
_conn,
_kanban_task,
error=(
f"Iteration budget exhausted "
f"({api_call_count}/{agent.max_iterations}) — "
"task could not complete within the allowed "
"iterations"
),
outcome="timed_out",
release_claim=True,
end_run=True,
event_payload_extra={
"budget_used": api_call_count,
"budget_max": agent.max_iterations,
},
)
logger.info(
"recorded budget-exhausted failure for task %s (%d/%d)",
_kanban_task, api_call_count, agent.max_iterations,
)
finally:
try:
_conn.close()
except Exception:
pass
except Exception:
logger.warning(
"Failed to record budget-exhausted failure for task %s",
_kanban_task,
exc_info=True,
)
# Determine if conversation completed successfully
completed = (
final_response is not None
and api_call_count < agent.max_iterations
and not failed
)
# Save trajectory if enabled. ``user_message`` may be a multimodal
# list of parts; the trajectory format wants a plain string.
agent._save_trajectory(messages, _summarize_user_message_for_log(user_message), completed)
# Clean up VM and browser for this task after conversation completes
agent._cleanup_task_resources(effective_task_id)
# Persist session to both JSON log and SQLite only after private retry
# scaffolding has been removed. Otherwise a later user "continue" turn
# can replay assistant("(empty)") / recovery nudges and fall into the
# same empty-response loop again.
agent._drop_trailing_empty_response_scaffolding(messages)
agent._persist_session(messages, conversation_history)
# ── Turn-exit diagnostic log ─────────────────────────────────────
# Always logged at INFO so agent.log captures WHY every turn ended.
# When the last message is a tool result (agent was mid-work), log
# at WARNING — this is the "just stops" scenario users report.
_last_msg_role = messages[-1].get("role") if messages else None
_last_tool_name = None
if _last_msg_role == "tool":
# Walk back to find the assistant message with the tool call
for _m in reversed(messages):
if _m.get("role") == "assistant" and _m.get("tool_calls"):
_tcs = _m["tool_calls"]
if _tcs and isinstance(_tcs[0], dict):
_last_tool_name = _tcs[-1].get("function", {}).get("name")
break
_turn_tool_count = sum(
1 for m in messages
if isinstance(m, dict) and m.get("role") == "assistant" and m.get("tool_calls")
)
_resp_len = len(final_response) if final_response else 0
_budget_used = agent.iteration_budget.used if agent.iteration_budget else 0
_budget_max = agent.iteration_budget.max_total if agent.iteration_budget else 0
_diag_msg = (
"Turn ended: reason=%s model=%s api_calls=%d/%d budget=%d/%d "
"tool_turns=%d last_msg_role=%s response_len=%d session=%s"
)
_diag_args = (
_turn_exit_reason, agent.model, api_call_count, agent.max_iterations,
_budget_used, _budget_max,
_turn_tool_count, _last_msg_role, _resp_len,
agent.session_id or "none",
)
if _last_msg_role == "tool" and not interrupted:
# Agent was mid-work — this is the "just stops" case.
logger.warning(
"Turn ended with pending tool result (agent may appear stuck). "
+ _diag_msg + " last_tool=%s",
*_diag_args, _last_tool_name,
)
else:
logger.info(_diag_msg, *_diag_args)
# File-mutation verifier footer.
# If one or more ``write_file`` / ``patch`` calls failed during this
# turn and were never superseded by a successful write to the same
# path, append an advisory footer to the assistant response. This
# catches the specific case — reported by Ben Eng (#15524-adjacent)
# — where a model issues a batch of parallel patches, half of them
# fail with "Could not find old_string", and the model summarises
# the turn claiming every file was edited. The user then has to
# manually run ``git status`` to catch the lie. With this footer
# the truth is surfaced on every turn, so over-claiming is
# structurally impossible past the model.
#
# Gate: only applied when a real text response exists for this
# turn and the user didn't interrupt. Empty/interrupted turns
# already have other surface text that shouldn't be augmented.
if final_response and not interrupted:
try:
_failed = getattr(agent, "_turn_failed_file_mutations", None) or {}
if _failed and agent._file_mutation_verifier_enabled():
footer = agent._format_file_mutation_failure_footer(_failed)
if footer:
final_response = final_response.rstrip() + "\n\n" + footer
except Exception as _ver_err:
logger.debug("file-mutation verifier footer failed: %s", _ver_err)
# Turn-completion explainer.
# When a turn ends abnormally after substantive work — empty content
# after retries, a partial/truncated stream, a still-pending tool
# result, or an iteration/budget limit — the user otherwise gets a
# blank or fragmentary response box with no consolidated reason why
# the agent stopped (#34452). Surface a single user-visible
# explanation derived from ``_turn_exit_reason``, mirroring the
# file-mutation verifier footer pattern above.
#
# Gate carefully so healthy turns stay quiet:
# - ``text_response(...)`` exits never produce an explanation
# (handled inside the formatter), so a terse ``Done.`` is silent.
# - We only ACT when there is no genuinely usable reply this turn:
# an empty response, the "(empty)" terminal sentinel, or a
# suspiciously short partial fragment with no terminating
# punctuation (e.g. "The"). A real short answer keeps its text.
if not interrupted:
try:
if agent._turn_completion_explainer_enabled():
_stripped = (final_response or "").strip()
_is_empty_terminal = _stripped == "" or _stripped == "(empty)"
# A short fragment that is not a normal text_response exit
# and lacks sentence-ending punctuation is treated as a
# truncated partial (the "The" case from #34452).
_is_partial_fragment = (
not _is_empty_terminal
and not str(_turn_exit_reason).startswith("text_response")
and len(_stripped) <= 24
and _stripped[-1:] not in {".", "!", "?", "", "", "", "`", ")"}
)
if _is_empty_terminal or _is_partial_fragment:
_explanation = agent._format_turn_completion_explanation(
_turn_exit_reason
)
if _explanation:
if _is_empty_terminal:
# Replace the bare "(empty)"/blank sentinel with
# the actionable explanation.
final_response = _explanation
else:
# Keep the partial fragment, append the reason so
# the user sees both what arrived and why it
# stopped.
final_response = (
_stripped + "\n\n" + _explanation
)
except Exception as _exp_err:
logger.debug("turn-completion explainer failed: %s", _exp_err)
_response_transformed = False
# Plugin hook: transform_llm_output
# Fired once per turn after the tool-calling loop completes.
# Plugins can transform the LLM's output text before it's returned.
# First hook to return a string wins; None/empty return leaves text unchanged.
if final_response and not interrupted:
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_transform_results = _invoke_hook(
"transform_llm_output",
response_text=final_response,
session_id=agent.session_id or "",
model=agent.model,
platform=getattr(agent, "platform", None) or "",
)
for _hook_result in _transform_results:
if isinstance(_hook_result, str) and _hook_result:
final_response = _hook_result
_response_transformed = True
break # First non-empty string wins
except Exception as exc:
logger.warning("transform_llm_output hook failed: %s", exc)
# Plugin hook: post_llm_call
# Fired once per turn after the tool-calling loop completes.
# Plugins can use this to persist conversation data (e.g. sync
# to an external memory system).
if final_response and not interrupted:
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_invoke_hook(
"post_llm_call",
session_id=agent.session_id,
task_id=effective_task_id,
turn_id=turn_id,
user_message=original_user_message,
assistant_response=final_response,
conversation_history=list(messages),
model=agent.model,
platform=getattr(agent, "platform", None) or "",
)
except Exception as exc:
logger.warning("post_llm_call hook failed: %s", exc)
# Extract reasoning from the CURRENT turn only. Walk backwards
# but stop at the user message that started this turn — anything
# earlier is from a prior turn and must not leak into the reasoning
# box (confusing stale display; #17055). Within the current turn
# we still want the *most recent* non-empty reasoning: many
# providers (Claude thinking, DeepSeek v4, Codex Responses) emit
# reasoning on the tool-call step and leave the final-answer step
# with reasoning=None, so picking only the last assistant would
# silently drop legitimate same-turn reasoning.
last_reasoning = None
for msg in reversed(messages):
if msg.get("role") == "user":
break # turn boundary — don't cross into prior turns
if msg.get("role") == "assistant" and msg.get("reasoning"):
last_reasoning = msg["reasoning"]
break
# Build result with interrupt info if applicable
result = {
"final_response": final_response,
"last_reasoning": last_reasoning,
"messages": messages,
"api_calls": api_call_count,
"completed": completed,
"turn_exit_reason": _turn_exit_reason,
"failed": failed,
"partial": False, # True only when stopped due to invalid tool calls
"interrupted": interrupted,
"response_transformed": _response_transformed,
"response_previewed": getattr(agent, "_response_was_previewed", False),
"model": agent.model,
"provider": agent.provider,
"base_url": agent.base_url,
"input_tokens": agent.session_input_tokens,
"output_tokens": agent.session_output_tokens,
"cache_read_tokens": agent.session_cache_read_tokens,
"cache_write_tokens": agent.session_cache_write_tokens,
"reasoning_tokens": agent.session_reasoning_tokens,
"prompt_tokens": agent.session_prompt_tokens,
"completion_tokens": agent.session_completion_tokens,
"total_tokens": agent.session_total_tokens,
"last_prompt_tokens": getattr(agent.context_compressor, "last_prompt_tokens", 0) or 0,
"estimated_cost_usd": agent.session_estimated_cost_usd,
"cost_status": agent.session_cost_status,
"cost_source": agent.session_cost_source,
"session_id": agent.session_id,
}
if agent._tool_guardrail_halt_decision is not None:
result["guardrail"] = agent._tool_guardrail_halt_decision.to_metadata()
# If a /steer landed after the final assistant turn (no more tool
# batches to drain into), hand it back to the caller so it can be
# delivered as the next user turn instead of being silently lost.
_leftover_steer = agent._drain_pending_steer()
if _leftover_steer:
result["pending_steer"] = _leftover_steer
agent._response_was_previewed = False
# Include interrupt message if one triggered the interrupt
if interrupted and agent._interrupt_message:
result["interrupt_message"] = agent._interrupt_message
# Clear interrupt state after handling
agent.clear_interrupt()
# Clear stream callback so it doesn't leak into future calls
agent._stream_callback = None
# Check skill trigger NOW — based on how many tool iterations THIS turn used.
_should_review_skills = False
if (agent._skill_nudge_interval > 0
and agent._iters_since_skill >= agent._skill_nudge_interval
and "skill_manage" in agent.valid_tool_names):
_should_review_skills = True
agent._iters_since_skill = 0
# External memory provider: sync the completed turn + queue next prefetch.
agent._sync_external_memory_for_turn(
original_user_message=original_user_message,
final_response=final_response,
interrupted=interrupted,
messages=messages,
)
# Background memory/skill review — runs AFTER the response is delivered
# so it never competes with the user's task for model attention.
if final_response and not interrupted and (_should_review_memory or _should_review_skills):
try:
agent._spawn_background_review(
messages_snapshot=list(messages),
review_memory=_should_review_memory,
review_skills=_should_review_skills,
)
except Exception:
pass # Background review is best-effort
# Note: Memory provider on_session_end() + shutdown_all() are NOT
# called here — run_conversation() is called once per user message in
# multi-turn sessions. Shutting down after every turn would kill the
# provider before the second message. Actual session-end cleanup is
# handled by the CLI (atexit / /reset) and gateway (session expiry /
# _reset_session).
# Plugin hook: on_session_end
# Fired at the very end of every run_conversation call.
# Plugins can use this for cleanup, flushing buffers, etc.
try:
from hermes_cli.plugins import invoke_hook as _invoke_hook
_invoke_hook(
"on_session_end",
session_id=agent.session_id,
task_id=effective_task_id,
turn_id=turn_id,
completed=completed,
interrupted=interrupted,
model=agent.model,
platform=getattr(agent, "platform", None) or "",
)
except Exception as exc:
logger.warning("on_session_end hook failed: %s", exc)
return result

View File

@@ -1,68 +0,0 @@
"""Per-attempt recovery bookkeeping for the conversation turn loop.
The inner retry loop in ``run_conversation`` (``while retry_count <
max_retries``) makes several distinct recovery attempts on a single model API
call: a credential-pool 429 retry, a per-provider OAuth refresh (codex,
anthropic, nous, copilot), a long-context compression restart, a length-
continuation restart, and a handful of format-recovery branches (thinking-
signature stripping, multimodal-tool-content stripping, llama.cpp grammar
fallback, image shrink, invalid-encrypted-content, 1M-beta header).
Each of those branches is guarded by a one-shot boolean so it fires at most
once per attempt. They used to be ~16 bare ``*_attempted`` / ``has_retried_*``
/ ``restart_with_*`` locals declared inline before the loop and threaded
through its 2,400-line body. ``TurnRetryState`` collapses them into one object
the loop mutates in place (``state.codex_auth_retry_attempted = True``), giving
the recovery bookkeeping a single named, testable home.
Loop-control variables (``retry_count``, ``max_retries``,
``max_compression_attempts``) intentionally stay as plain locals — they are the
``while`` mechanics, not recovery bookkeeping, and putting them on the object
would add indirection without clarifying anything.
This module is dependency-free so it can be unit-tested in isolation and
imported by the turn loop without an import cycle.
"""
from __future__ import annotations
from dataclasses import dataclass, fields
@dataclass
class TurnRetryState:
"""One-shot recovery guards + restart signals for a single API-call attempt.
A fresh instance is created for each iteration of the outer turn loop
(once per ``api_call_count``). Each guard fires its recovery branch at most
once; the ``restart_with_*`` signals are read by the loop after the attempt
to decide whether to rebuild the request and retry.
"""
# ── Per-provider OAuth / credential refresh guards ───────────────────
codex_auth_retry_attempted: bool = False
anthropic_auth_retry_attempted: bool = False
nous_auth_retry_attempted: bool = False
nous_paid_entitlement_refresh_attempted: bool = False
copilot_auth_retry_attempted: bool = False
# ── Format / payload recovery guards ─────────────────────────────────
thinking_sig_retry_attempted: bool = False
invalid_encrypted_content_retry_attempted: bool = False
image_shrink_retry_attempted: bool = False
multimodal_tool_content_retry_attempted: bool = False
oauth_1m_beta_retry_attempted: bool = False
llama_cpp_grammar_retry_attempted: bool = False
# ── Transport / rate-limit recovery ──────────────────────────────────
primary_recovery_attempted: bool = False
has_retried_429: bool = False
# ── Restart signals (read by the outer loop after the attempt) ───────
restart_with_compressed_messages: bool = False
restart_with_length_continuation: bool = False
def __iter__(self):
# Convenience for debugging / tests: iterate (name, value) pairs.
for f in fields(self):
yield f.name, getattr(self, f.name)

View File

@@ -72,7 +72,7 @@ pub async fn run_script(
let mut child: Child = cmd
.spawn()
.with_context(|| format!("spawning {} via {}", script_path.display(), interpreter_label()))?;
.with_context(|| format!("spawning {}", script_path.display()))?;
let stdout = child.stdout.take().expect("stdout was piped");
let stderr = child.stderr.take().expect("stderr was piped");
@@ -177,9 +177,8 @@ async fn recv_cancel(rx: &mut Option<CancelRx>) {
fn build_command(script_path: &Path, args: &[String]) -> Command {
// We want PowerShell 5.1 / 7. install.ps1 uses 5.1-safe syntax everywhere.
// Prefer `powershell.exe` (5.1 baseline, present on every Windows since 7)
// over `pwsh.exe` (7+, may not be present). Resolve it by absolute path —
// see `windows_powershell_exe`.
let mut cmd = Command::new(windows_powershell_exe());
// over `pwsh.exe` (7+, may not be present).
let mut cmd = Command::new("powershell.exe");
cmd.arg("-NoProfile");
cmd.arg("-ExecutionPolicy").arg("Bypass");
cmd.arg("-File").arg(script_path);
@@ -201,60 +200,6 @@ fn build_command(script_path: &Path, args: &[String]) -> Command {
cmd
}
/// Canonical PowerShell 5.1 location under a Windows root (`%SystemRoot%`).
/// Kept separate (and test-visible) so the path layout is unit-tested on any
/// host, not just Windows.
#[cfg(any(target_os = "windows", test))]
fn powershell_under_root(root: &Path) -> std::path::PathBuf {
root.join("System32")
.join("WindowsPowerShell")
.join("v1.0")
.join("powershell.exe")
}
/// Resolves the PowerShell interpreter to spawn.
///
/// `Command::new("powershell.exe")` trusts PATH to contain
/// `%SystemRoot%\System32\WindowsPowerShell\v1.0`. On machines whose PATH was
/// trimmed or truncated (Windows silently drops entries once the variable grows
/// past its length limit), that lookup fails and the spawn dies with
/// "program not found" before install.ps1 ever runs — the installer then stalls
/// at "0 of 0 steps". Resolve by absolute path first, then fall back to PATH
/// (powershell 5.1, then pwsh 7), then a bare name as a last resort.
#[cfg(target_os = "windows")]
fn windows_powershell_exe() -> std::path::PathBuf {
for var in ["SystemRoot", "windir"] {
if let Ok(root) = std::env::var(var) {
let candidate = powershell_under_root(Path::new(&root));
if candidate.is_file() {
return candidate;
}
}
}
for exe in ["powershell.exe", "pwsh.exe"] {
if let Ok(found) = which::which(exe) {
return found;
}
}
std::path::PathBuf::from("powershell.exe")
}
/// Human-readable interpreter name for spawn-failure context. On Windows this
/// is the resolved PowerShell path so a missing/odd interpreter is obvious in
/// the log (the old message only printed the script path, which read as if the
/// .ps1 itself was missing).
#[cfg(target_os = "windows")]
fn interpreter_label() -> String {
windows_powershell_exe().display().to_string()
}
#[cfg(not(target_os = "windows"))]
fn interpreter_label() -> String {
"bash".to_string()
}
/// Parses the LAST line of stdout that looks like a JSON object matching
/// the install.ps1 stage-result contract: `{ok: bool, stage: string, ...}`.
///
@@ -344,14 +289,4 @@ info line
let cwd = stable_script_cwd(script, Some("/"));
assert_eq!(cwd, Some(Path::new("/")));
}
#[test]
fn powershell_under_root_uses_system32_v1_layout() {
let resolved = powershell_under_root(Path::new("C:\\Windows"));
let normalized = resolved.to_string_lossy().replace('\\', "/");
assert!(
normalized.ends_with("System32/WindowsPowerShell/v1.0/powershell.exe"),
"unexpected powershell path: {normalized}"
);
}
}

View File

@@ -1,167 +0,0 @@
# Desktop Design System
Conventions for the Electron desktop app (`apps/desktop`). Read this before
adding a component, overlay, or style. The rule of thumb: **one source per
concern, tokens over literals, flat over boxed.** If you reach for a raw color,
a one-off shadow, a bespoke button, or a hardcoded `px-*` on a control — stop,
there's already a primitive for it.
## Principles
1. **Flat, not boxed.** No card-in-card, no divider borders inside a panel.
Group with whitespace and a single hairline, never nested rounded boxes.
2. **Borderless + shadow for elevation.** Overlays float on `shadow-nous` + a
`--stroke-nous` hairline, not hard borders.
3. **One primitive per concern.** One `Button`, one set of control variants,
one `SearchField`, one `Loader`, one `ErrorState`. Migrate onto them; don't
fork.
4. **Tokens, not literals.** Reference CSS vars (`--ui-*`, `--shadow-nous`,
`--theme-*`), never raw hex / ad-hoc rgba in components.
5. **Style lives in the primitive.** Variants and sizes own padding, radius,
color, chrome. Call sites pass a `variant`/`size`, not `className` overrides
that re-specify those.
## Surfaces & elevation
Every overlay / dialog / toast (boot-failure, install, notifications,
model-picker, onboarding, prompt-overlays, updates, base `Dialog`) uses:
```
shadow-nous /* downward-weighted, layered contact→ambient falloff */
border-(--stroke-nous) /* currentColor hairline, theme-adaptive */
```
Both are CSS vars in `src/styles.css` — tune in one place, everything inherits.
Don't add per-overlay `shadow-[…]` or `border-(--ui-stroke-secondary)`
one-offs; if elevation needs to change, change the token.
## Stroke & color tokens
| Token | Use |
| --- | --- |
| `--ui-stroke-primary…quaternary` | hairlines, in descending strength |
| `--ui-stroke-tertiary` | the default in-panel divider / list hairline |
| `--stroke-nous` | the overlay hairline (pairs with `shadow-nous`) |
| `--ui-text-primary / -secondary / -tertiary` | text hierarchy |
| `--ui-bg-quaternary` | soft control fill (secondary button) |
| `--chrome-action-hover` | hover fill for quiet controls |
| `--theme-primary`, `--ui-accent` | brand/accent |
Never hardcode `border-gray-*`, `bg-white`, `text-black`, etc. The white tile in
`BrandMark` is the one sanctioned literal (the mark needs a fixed backdrop).
## Buttons — one component
`src/components/ui/button.tsx` is the single source. Pick a `variant` + `size`;
do **not** pass `h-*`, `px-*`, `py-*`, or icon-size overrides.
**Variants:** `default` (primary), `destructive`, `secondary` (soft fill —
the default non-primary look), `outline` (transparent + 1px inset ring, no
fill/shadow), `ghost`, `link`, `text` (boxless quiet inline — "Cancel",
"Clear"), `textStrong` (bold underlined inline affordance — "Change",
"Open logs").
**Sizes:** `default`, `xs`, `sm`, `lg`, `inline` (flush, zero box — for buttons
that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), and the icon
family `icon` / `icon-xs` / `icon-sm` / `icon-lg` / `icon-titlebar`.
Notes:
- Text buttons are square (no radius) and sized by padding + line-height (no
fixed heights). Only icon buttons carry the shared 4px radius.
- SVGs inherit `size-3.5` (`size-3` at `xs`). Don't re-set icon size.
- Polymorph with `asChild` when the button must render as a link/Slot.
## Form controls
- **`controlVariants`** (`src/components/ui/control.ts`) is the shared shape for
`Input` / `Textarea` / `SelectTrigger`. New text-entry controls compose it.
- **`SearchField`** — borderless, underline-on-focus, auto-width. The only
search input. Don't build boxed search bars; don't wrap it in a bordered tile.
Empty lists hide their search field.
- **`SegmentedControl`** — the choice control for small mutually-exclusive sets
(color mode, tool-call display, usage period). Replaces radio piles and
pill rows.
- **`Switch`** (`size="xs"`) — bare, with `aria-label`. No bordered text wrapper.
## Layout
- **Gutters:** `PAGE_INSET_X` (`src/app/layout-constants.ts`) for page side
padding; `PAGE_INSET_NEG_X` to bleed a child to the edge. Don't hardcode
`px-6`/`px-8` on pages.
- **Master/detail overlays:** `OverlaySplitLayout` + `OverlaySidebar` /
`OverlayMain`. Cron, profiles, etc. ride this — don't rebuild a titlebar
shell.
- **Rows:** `ListRow` (settings `primitives.tsx`) for label/description/action
rows. Flat, flush-left; no per-row indentation that fights flush headers.
- **No dividers between rows** unless the list genuinely needs them; prefer
spacing. When you do need one, it's a single `--ui-stroke-tertiary` hairline.
## Feedback & empty/error/loading states
- **Loading:** `Loader` (`src/components/ui/loader.tsx`) — animated math/ascii
curves (`lemniscate-bloom` for long ops). Never ship the literal text
"Loading…".
- **Errors:** `ErrorState` + the canonical `ErrorIcon` (no bg chip). One look
for the React boundary, in-dialog errors, and the boot-failure banner. Pass
nodes for title/description so Radix `DialogTitle`/`Description` can flow
through for a11y.
- **Logs:** `LogView` — no bg, hairline border, tight padding, small mono.
Every place we surface raw logs uses it.
- **Empty:** `EmptyState` / `EmptyPanel` — don't hand-roll centered empties.
## Iconography & brand
- **`Codicon`** is the icon set. No mixing icon libraries inline.
- **`BrandMark`** (`src/components/brand-mark.tsx`) is the brand glyph — the
`nous-girl` mark on a white tile, softly rounded, identical in light/dark.
It replaced scattered Sparkles glyphs in updates / onboarding / about. Use it
for hero/brand moments; don't reintroduce decorative star/sparkle icons.
## Motion
- Quick, functional transitions (~100ms on controls). Respect
`prefers-reduced-motion` for anything beyond a fade.
- Choreographed exits (e.g. onboarding's "matrix" fade-down) stagger per-element
then settle the surface — the outer container's fade is *delayed* so it
doesn't swallow the inner animation. Don't let a global fade race the detail.
## i18n
- Every user-facing string goes through `useI18n()` (`src/i18n/context.tsx`).
No literals in JSX.
- **Update all locales together** — `en`, `ja`, `zh`, `zh-hant`. A string change
in `en.ts` that skips the others is a regression (drifted punctuation,
stale labels). Keep trailing-punctuation and tone consistent across all four.
## State (TypeScript)
Mirrors the repo TS style (see root `AGENTS.md`):
- Shared/cross-component state → small **nanostores**, not prop-drilling.
Each feature owns its atoms; shared atoms live in `src/store`.
- Rendering components subscribe with `useStore`; non-render actions read with
`$atom.get()`.
- Colocated action modules over god hooks. A hook owns one narrow job.
- Keep persistence beside the atom that owns it. Route roots stay thin.
- Prefer `interface` for public props; extend React primitives
(`React.ComponentProps<'button'>`, `Omit<…>`).
## Affordances
- `cursor-pointer` at the primitive level (Button, dropdown/select) — don't
hardcode it per call site.
- Global focus-ring reset; titlebar actions have no active-background state.
- `Esc` closes every dismissable overlay/dialog (install/onboarding excluded);
close is an x-icon, not the word "Close".
## Before you add something — checklist
- [ ] Reuse a primitive (`Button`, `SearchField`, `SegmentedControl`,
`ListRow`, `Loader`, `ErrorState`, `LogView`) instead of forking one?
- [ ] Tokens (`--ui-*`, `shadow-nous`, `--stroke-nous`) — zero raw colors /
one-off shadows?
- [ ] No `className` overriding a primitive's padding / size / radius / chrome?
- [ ] Overlay uses `shadow-nous` + `border-(--stroke-nous)`, no hard border?
- [ ] Flat — no card-in-card, no gratuitous row dividers?
- [ ] All four locales updated for any new/changed string?
- [ ] `cursor-pointer`, focus ring, and `Esc`-to-close behave?

View File

@@ -76,21 +76,6 @@ function bootstrapCacheDir(hermesHome) {
return path.join(hermesHome, 'bootstrap-cache')
}
// The install.sh / install.ps1 that ships inside the already-installed agent
// checkout under ~/.hermes/hermes-agent. Used as a last-resort fallback when
// the pinned commit can't be fetched from GitHub (e.g. a locally-built desktop
// app stamped to an unpushed HEAD).
function installedAgentInstallScript(hermesHome) {
if (!hermesHome) return null
const candidate = path.join(hermesHome, 'hermes-agent', 'scripts', installScriptName())
try {
fs.accessSync(candidate, fs.constants.R_OK)
return candidate
} catch {
return null
}
}
function cachedScriptPath(hermesHome, commit) {
return path.join(bootstrapCacheDir(hermesHome), `install-${commit}.${process.platform === 'win32' ? 'ps1' : 'sh'}`)
}
@@ -170,7 +155,7 @@ function downloadInstallScript(commit, destPath) {
})
}
async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit, _download = downloadInstallScript }) {
async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome, emit }) {
// 1. Dev shortcut: prefer a local checkout's installer so we can iterate
// without pushing. SOURCE_REPO_ROOT comes from main.cjs (path.resolve
// of APP_ROOT/../..).
@@ -204,84 +189,18 @@ async function resolveInstallScript({ installStamp, sourceRepoRoot, hermesHome,
type: 'log',
line: `[bootstrap] fetching ${installScriptName()} for ${installStamp.commit.slice(0, 12)} from GitHub`
})
try {
await _download(installStamp.commit, cached)
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
} catch (err) {
// The pinned commit may not be fetchable from GitHub -- most commonly a
// locally-built desktop app stamped to an unpushed HEAD (see
// write-build-stamp.cjs fromLocalGit). Fall back to the installer that
// ships inside the already-installed agent checkout so dev/self-builds can
// still bootstrap instead of dying with a fatal 404.
const installed = installedAgentInstallScript(hermesHome)
if (installed) {
emit({
type: 'log',
line:
`[bootstrap] GitHub fetch failed (${err.message}); ` +
`falling back to installed agent ${installScriptName()} at ${installed}`
})
try {
fs.mkdirSync(path.dirname(cached), { recursive: true })
fs.copyFileSync(installed, cached)
return { path: cached, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
} catch {
// Cache copy failed (read-only FS, etc.) -- use the source path directly.
return { path: installed, source: 'installed-agent', commit: installStamp.commit, kind: installScriptKind() }
}
}
throw err
}
await downloadInstallScript(installStamp.commit, cached)
emit({ type: 'log', line: `[bootstrap] saved to ${cached}` })
return { path: cached, source: 'download', commit: installStamp.commit, kind: installScriptKind() }
}
// ---------------------------------------------------------------------------
// powershell wrapper
// ---------------------------------------------------------------------------
// Canonical PowerShell 5.1 location under a Windows root (%SystemRoot%).
function powershellUnderRoot(root) {
return path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
}
// Resolve the PowerShell interpreter to spawn.
//
// Spawning bare 'powershell.exe' trusts PATH to contain
// %SystemRoot%\System32\WindowsPowerShell\v1.0. On machines whose PATH was
// trimmed, truncated, or stored as a non-expanding REG_SZ (so %SystemRoot%
// never expands), that lookup fails and the spawn dies with ENOENT before
// install.ps1 ever runs — the installer stalls at "0 of 0 steps". Resolve by
// absolute path first, then fall back to PATH (powershell 5.1, then pwsh 7),
// then a bare name as a last resort.
function resolveWindowsPowerShell() {
for (const v of ['SystemRoot', 'windir']) {
const root = process.env[v]
if (root) {
const candidate = powershellUnderRoot(root)
try {
if (fs.statSync(candidate).isFile()) return candidate
} catch {
void 0
}
}
}
const pathDirs = (process.env.PATH || process.env.Path || '').split(path.delimiter).filter(Boolean)
for (const exe of ['powershell.exe', 'pwsh.exe']) {
for (const dir of pathDirs) {
const candidate = path.join(dir, exe)
try {
if (fs.statSync(candidate).isFile()) return candidate
} catch {
void 0
}
}
}
return 'powershell.exe'
}
function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, hermesHome } = {}) {
return new Promise((resolve, reject) => {
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
const ps = process.platform === 'win32' ? 'powershell.exe' : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
const child = spawn(ps, fullArgs, {
@@ -714,7 +633,5 @@ module.exports = {
// Exposed for testability
parseStageResult,
resolveLocalInstallScript,
resolveInstallScript,
installedAgentInstallScript,
cachedScriptPath
}

View File

@@ -1,21 +1,7 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const {
runBootstrap,
resolveInstallScript,
installedAgentInstallScript,
cachedScriptPath
} = require('./bootstrap-runner.cjs')
const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh'
function mkTmpHome() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-bootstrap-test-'))
}
const { runBootstrap } = require('./bootstrap-runner.cjs')
test('runBootstrap bails immediately when the signal is already aborted', async () => {
const controller = new AbortController()
@@ -39,100 +25,3 @@ test('runBootstrap bails immediately when the signal is already aborted', async
'should emit a cancelled failure event'
)
})
test('installedAgentInstallScript resolves the installer in the agent checkout', () => {
const home = mkTmpHome()
try {
assert.equal(installedAgentInstallScript(home), null, 'absent before the checkout exists')
const scriptsDir = path.join(home, 'hermes-agent', 'scripts')
fs.mkdirSync(scriptsDir, { recursive: true })
const scriptPath = path.join(scriptsDir, SCRIPT_NAME)
fs.writeFileSync(scriptPath, '#!/bin/sh\necho hi\n')
assert.equal(installedAgentInstallScript(home), scriptPath)
assert.equal(installedAgentInstallScript(null), null, 'null home -> null')
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})
test('resolveInstallScript prefers a cached script without touching the network', async () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
const cached = cachedScriptPath(home, commit)
fs.mkdirSync(path.dirname(cached), { recursive: true })
fs.writeFileSync(cached, '#!/bin/sh\necho cached\n')
const logs = []
const result = await resolveInstallScript({
installStamp: { commit },
sourceRepoRoot: null,
hermesHome: home,
emit: ev => logs.push(ev)
})
assert.equal(result.source, 'cache')
assert.equal(result.path, cached)
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})
test('resolveInstallScript falls back to the installed agent checkout on a 404', async () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
// Seed the installed agent checkout so the fallback has something to resolve.
const scriptsDir = path.join(home, 'hermes-agent', 'scripts')
fs.mkdirSync(scriptsDir, { recursive: true })
const installed = path.join(scriptsDir, SCRIPT_NAME)
fs.writeFileSync(installed, '#!/bin/sh\necho fallback\n')
const logs = []
const result = await resolveInstallScript({
installStamp: { commit },
sourceRepoRoot: null,
hermesHome: home,
emit: ev => logs.push(ev),
// Simulate GitHub returning a 404 for the pinned commit.
_download: async () => {
throw new Error('Failed to download install.sh: HTTP 404')
}
})
assert.equal(result.source, 'installed-agent')
// It should have copied the installer into the bootstrap cache.
assert.equal(result.path, cachedScriptPath(home, commit))
assert.ok(fs.existsSync(result.path), 'fallback script copied into cache')
assert.ok(
logs.some(ev => /falling back to installed agent/.test(ev.line || '')),
'emits a fallback log line'
)
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})
test('resolveInstallScript rethrows when the 404 fallback is unavailable', async () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
// No installed agent checkout seeded -> nothing to fall back to.
await assert.rejects(
resolveInstallScript({
installStamp: { commit },
sourceRepoRoot: null,
hermesHome: home,
emit: () => {},
_download: async () => {
throw new Error('Failed to download install.sh: HTTP 404')
}
}),
/HTTP 404|Failed to download/
)
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})

View File

@@ -1,232 +0,0 @@
/**
* desktop-uninstall.cjs
*
* Pure, electron-free helpers for the desktop Chat GUI uninstaller. These map
* the three user-facing uninstall modes to the `hermes uninstall` CLI flags,
* resolve the running app bundle/exe so a detached cleanup script can remove
* it after the app quits, and build that cleanup script for each OS.
*
* Kept standalone (no `require('electron')`) so it can be unit-tested with
* `node --test` — same pattern as connection-config.cjs / backend-probes.cjs.
* main.cjs requires these and wires them into the electron-coupled IPC layer.
*
* The three modes mirror the CLI's options exactly:
* - 'gui' → remove ONLY the Chat GUI, keep the agent + all user data.
* `hermes uninstall --gui --yes`
* - 'lite' → remove the GUI + agent code, KEEP user data (config / sessions
* / .env) for a future reinstall. `hermes uninstall --yes`
* - 'full' → remove everything: GUI + agent + all user data.
* `hermes uninstall --full --yes`
*
* Why a detached cleanup script: 'lite'/'full' delete the very venv the
* `hermes` command runs from, and every mode may need to delete the running
* app bundle (locked on macOS/Windows while the process is alive). So we hand
* the work to a detached child that waits for this app's PID to exit, runs the
* Python uninstall, then removes the app bundle — then the app quits. Same
* shape as the self-update swap-and-relaunch flow already in main.cjs.
*/
const path = require('node:path')
const UNINSTALL_MODES = ['gui', 'lite', 'full']
/**
* Map an uninstall mode to the `python -m hermes_cli.uninstall` argv (after the
* python executable). Uses the dedicated lightweight module entrypoint (not
* `hermes_cli.main`) so it can run under a system Python OUTSIDE the venv that
* lite/full delete — see the Finding-3 note in buildWindowsCleanupScript.
* Throws on an unknown mode so a typo can't silently become a full wipe.
*/
function uninstallArgsForMode(mode) {
if (!UNINSTALL_MODES.includes(mode)) {
throw new Error(`Unknown uninstall mode: ${mode}`)
}
return ['-m', 'hermes_cli.uninstall', '--mode', mode]
}
/** True when `mode` removes the agent (lite/full), false for gui-only. */
function modeRemovesAgent(mode) {
return mode === 'lite' || mode === 'full'
}
/** True when `mode` removes user data (full only). */
function modeRemovesUserData(mode) {
return mode === 'full'
}
/**
* Resolve the on-disk app bundle/dir to remove for the running desktop app,
* given the path to the running executable (`process.execPath`) and platform.
*
* macOS: …/Hermes.app/Contents/MacOS/Hermes → …/Hermes.app
* Windows: …\Hermes\Hermes.exe → …\Hermes (install dir)
* Linux: AppImage → the APPIMAGE env path; unpacked → the *-unpacked dir
*
* Returns null when we can't confidently identify a removable bundle (e.g.
* running from a dev checkout, or a system-package install we must not rmtree).
*/
function resolveRemovableAppPath(execPath, platform, env = {}) {
const exe = String(execPath || '')
if (!exe) return null
// Use the path flavor that matches the TARGET platform, not the host running
// this code — so the Windows branch parses backslash paths correctly even
// when these pure helpers are unit-tested on Linux/macOS CI.
const p = platform === 'win32' ? path.win32 : path.posix
if (platform === 'darwin') {
// …/Hermes.app/Contents/MacOS/Hermes → strip 3 segments to the .app
const macOsDir = p.dirname(exe) // …/Contents/MacOS
const contents = p.dirname(macOsDir) // …/Contents
const appBundle = p.dirname(contents) // …/Hermes.app
if (appBundle.endsWith('.app')) return appBundle
return null
}
if (platform === 'win32') {
// NSIS per-user installs Hermes.exe directly in the install dir.
const dir = p.dirname(exe)
if (/[\\/]Hermes$/i.test(dir) || /[\\/]hermes-desktop$/i.test(dir)) return dir
return null
}
// Linux: an AppImage exposes its own path via the APPIMAGE env var.
if (env.APPIMAGE) return env.APPIMAGE
// Unpacked electron-builder tree: …/linux-unpacked/hermes
const dir = p.dirname(exe)
if (/-unpacked$/.test(dir)) return dir
return null
}
/**
* Should we even try to remove the running app bundle from a cleanup script?
* Only when packaged AND we resolved a concrete removable path. Dev runs
* (electron from node_modules) and system-package installs return null above
* and are left to the OS package manager.
*/
function shouldRemoveAppBundle(isPackaged, appPath) {
return Boolean(isPackaged) && Boolean(appPath)
}
/**
* Build a POSIX cleanup shell script (macOS / Linux). It:
* 1. waits (bounded ~30s) for the desktop PID to exit (venv/bundle unlock),
* 2. runs the Python uninstall module with the mode,
* 3. removes the app bundle if one was resolved.
*
* `pythonExe` should be a Python OUTSIDE the venv for lite/full (the venv is
* being deleted); `pythonPath` is prepended to PYTHONPATH so `import hermes_cli`
* resolves from the agent source. `q()` single-quote-escapes for the shell
* (closes-escapes-reopens any embedded apostrophe), defending against spaces.
*/
function buildPosixCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
const q = s => `'${String(s).replace(/'/g, `'\\''`)}'`
const lines = [
'#!/bin/bash',
'set -u',
'# Wait (up to ~30s) for the desktop process to exit so the venv python',
'# and the app bundle are no longer in use.',
`pid=${Number(desktopPid) || 0}`,
'if [ "$pid" -gt 0 ]; then',
' for _ in $(seq 1 60); do',
' kill -0 "$pid" 2>/dev/null || break',
' sleep 0.5',
' done',
'fi',
`export HERMES_HOME=${q(hermesHome)}`
]
if (pythonPath) {
lines.push(`export PYTHONPATH=${q(pythonPath)}\${PYTHONPATH:+:$PYTHONPATH}`)
}
lines.push(
`cd ${q(agentRoot)} 2>/dev/null || true`,
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')} || true`
)
if (appPath) {
lines.push(`rm -rf ${q(appPath)} || true`)
}
// Self-delete the script.
lines.push('rm -f "$0" 2>/dev/null || true')
lines.push('')
return lines.join('\n')
}
/**
* Build a Windows cleanup batch script. Same three steps, cmd.exe flavored.
*
* Finding 3 (venv self-deletion): for lite/full the agent uninstall rmtree's
* the venv that contains `python.exe`. A running .exe is mandatory-locked on
* Windows, so running the uninstall from the venv's OWN python half-fails. The
* desktop passes a system Python (findSystemPython) as `pythonExe` for those
* modes + `pythonPath`=agentRoot so `import hermes_cli` resolves from source
* while the venv is torn down. gui-only doesn't touch the venv, so it can use
* either interpreter.
*
* Wait-loop: bounded (matches POSIX's ~30s cap) so a never-exiting / mismatched
* PID can't wedge the cleanup forever. The `/FI "PID eq"` filter is an EXACT
* match, so no redundant `| find` (which would substring-match 99→990).
*
* Removal: even after the desktop PID is gone, Windows releases directory
* handles lazily, so a single `rmdir /s /q` can half-fail — retry up to 10x.
*/
function buildWindowsCleanupScript({ desktopPid, pythonExe, pythonPath, agentRoot, uninstallArgs, appPath, hermesHome }) {
const pid = Number(desktopPid) || 0
// cmd.exe has no string escaping inside quotes; strip embedded quotes (paths
// under %LOCALAPPDATA% never contain them). `&`/`^` in a path would still be
// a problem, but Hermes install paths don't use them.
const q = s => `"${String(s).replace(/"/g, '')}"`
const lines = [
'@echo off',
'setlocal enableextensions',
`set "HERMES_HOME=${String(hermesHome).replace(/"/g, '')}"`,
`set "PID=${pid}"`
]
if (pythonPath) {
lines.push(`set "PYTHONPATH=${String(pythonPath).replace(/"/g, '')};%PYTHONPATH%"`)
}
lines.push(
'set /a waited=0',
':waitloop',
'rem /FI "PID eq %PID%" is an EXACT filter — tasklist outputs the one task',
'rem row for that PID, or "INFO: No tasks..." otherwise. /NH drops the',
'rem header; findstr matches the PID as a whole space-delimited token so',
'rem PID 99 cannot match 990 (the substring trap of a bare `find`).',
'tasklist /NH /FI "PID eq %PID%" 2>nul | findstr /r /c:" %PID% " >nul',
'if %ERRORLEVEL% neq 0 goto waited_done',
'set /a waited+=1',
'if %waited% geq 60 goto waited_done',
'timeout /t 1 /nobreak >nul',
'goto waitloop',
':waited_done',
`cd /d ${q(agentRoot)}`,
`${q(pythonExe)} ${uninstallArgs.map(q).join(' ')}`
)
if (appPath) {
lines.push(
'set /a tries=0',
':rmloop',
`if not exist ${q(appPath)} goto rmdone`,
`rmdir /s /q ${q(appPath)} >nul 2>&1`,
`if not exist ${q(appPath)} goto rmdone`,
'set /a tries+=1',
'if %tries% geq 10 goto rmdone',
'timeout /t 1 /nobreak >nul',
'goto rmloop',
':rmdone'
)
}
lines.push('del "%~f0"')
lines.push('')
return lines.join('\r\n')
}
module.exports = {
UNINSTALL_MODES,
buildPosixCleanupScript,
buildWindowsCleanupScript,
modeRemovesAgent,
modeRemovesUserData,
resolveRemovableAppPath,
shouldRemoveAppBundle,
uninstallArgsForMode
}

View File

@@ -1,246 +0,0 @@
/**
* Tests for electron/desktop-uninstall.cjs.
*
* Run with: node --test electron/desktop-uninstall.test.cjs
* (Wired into npm test:desktop:platforms in package.json.)
*
* These are the pure helpers behind the desktop Chat GUI uninstaller: the
* mode → CLI-flag mapping, the running-app-bundle resolution per OS, and the
* cleanup-script builders (POSIX + Windows).
*/
const test = require('node:test')
const assert = require('node:assert/strict')
const {
UNINSTALL_MODES,
buildPosixCleanupScript,
buildWindowsCleanupScript,
modeRemovesAgent,
modeRemovesUserData,
resolveRemovableAppPath,
shouldRemoveAppBundle,
uninstallArgsForMode
} = require('./desktop-uninstall.cjs')
// --- uninstallArgsForMode ---
test('uninstallArgsForMode maps each mode to the module-runner argv', () => {
assert.deepEqual(uninstallArgsForMode('gui'), ['-m', 'hermes_cli.uninstall', '--mode', 'gui'])
assert.deepEqual(uninstallArgsForMode('lite'), ['-m', 'hermes_cli.uninstall', '--mode', 'lite'])
assert.deepEqual(uninstallArgsForMode('full'), ['-m', 'hermes_cli.uninstall', '--mode', 'full'])
})
test('uninstallArgsForMode throws on an unknown mode (no silent full wipe)', () => {
assert.throws(() => uninstallArgsForMode('nuke'), /Unknown uninstall mode/)
assert.throws(() => uninstallArgsForMode(''), /Unknown uninstall mode/)
})
test('UNINSTALL_MODES lists exactly the three supported modes', () => {
assert.deepEqual([...UNINSTALL_MODES].sort(), ['full', 'gui', 'lite'])
})
// --- modeRemovesAgent / modeRemovesUserData ---
test('mode predicates classify what each mode removes', () => {
assert.equal(modeRemovesAgent('gui'), false)
assert.equal(modeRemovesAgent('lite'), true)
assert.equal(modeRemovesAgent('full'), true)
assert.equal(modeRemovesUserData('gui'), false)
assert.equal(modeRemovesUserData('lite'), false)
assert.equal(modeRemovesUserData('full'), true)
})
// --- resolveRemovableAppPath ---
test('resolveRemovableAppPath finds the .app bundle on macOS', () => {
assert.equal(
resolveRemovableAppPath('/Applications/Hermes.app/Contents/MacOS/Hermes', 'darwin'),
'/Applications/Hermes.app'
)
assert.equal(
resolveRemovableAppPath('/Users/x/Applications/Hermes.app/Contents/MacOS/Hermes', 'darwin'),
'/Users/x/Applications/Hermes.app'
)
})
test('resolveRemovableAppPath: dev-run .app resolves (safety is shouldRemoveAppBundle, not null)', () => {
// A dev run from node_modules' Electron DOES resolve to a .app — the real
// dev-run safety gate is shouldRemoveAppBundle(isPackaged=false,...), not a
// null return here. This test documents that contract.
assert.equal(
resolveRemovableAppPath('/repo/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron', 'darwin'),
'/repo/node_modules/electron/dist/Electron.app'
)
assert.equal(shouldRemoveAppBundle(false, '/repo/node_modules/electron/dist/Electron.app'), false)
// A bare path with no .app ancestor → null.
assert.equal(resolveRemovableAppPath('/usr/bin/electron', 'darwin'), null)
})
test('resolveRemovableAppPath finds the install dir on Windows', () => {
assert.equal(
resolveRemovableAppPath('C:\\Users\\x\\AppData\\Local\\Programs\\Hermes\\Hermes.exe', 'win32'),
'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes'
)
assert.equal(
resolveRemovableAppPath('C:\\Users\\x\\AppData\\Local\\hermes-desktop\\Hermes.exe', 'win32'),
'C:\\Users\\x\\AppData\\Local\\hermes-desktop'
)
})
test('resolveRemovableAppPath returns null for an unrecognized Windows dir', () => {
assert.equal(resolveRemovableAppPath('C:\\Temp\\foo\\Hermes.exe', 'win32'), null)
})
test('resolveRemovableAppPath uses APPIMAGE on Linux when set', () => {
assert.equal(
resolveRemovableAppPath('/tmp/.mount_HermesXXXX/hermes', 'linux', { APPIMAGE: '/home/x/Apps/Hermes.AppImage' }),
'/home/x/Apps/Hermes.AppImage'
)
})
test('resolveRemovableAppPath finds the unpacked dir on Linux', () => {
assert.equal(
resolveRemovableAppPath('/opt/hermes/linux-unpacked/hermes', 'linux', {}),
'/opt/hermes/linux-unpacked'
)
// A system-package install (/usr/bin) → null, left to apt/dnf.
assert.equal(resolveRemovableAppPath('/usr/bin/hermes', 'linux', {}), null)
})
test('resolveRemovableAppPath returns null for an empty exe path', () => {
assert.equal(resolveRemovableAppPath('', 'darwin'), null)
assert.equal(resolveRemovableAppPath(null, 'win32'), null)
})
// --- shouldRemoveAppBundle ---
test('shouldRemoveAppBundle requires packaged AND a resolved path', () => {
assert.equal(shouldRemoveAppBundle(true, '/Applications/Hermes.app'), true)
assert.equal(shouldRemoveAppBundle(false, '/Applications/Hermes.app'), false)
assert.equal(shouldRemoveAppBundle(true, null), false)
assert.equal(shouldRemoveAppBundle(false, null), false)
})
// --- buildPosixCleanupScript ---
test('buildPosixCleanupScript waits for the PID, runs the uninstall module, removes bundle', () => {
const script = buildPosixCleanupScript({
desktopPid: 4321,
pythonExe: '/home/x/.hermes/hermes-agent/venv/bin/python',
pythonPath: null,
agentRoot: '/home/x/.hermes/hermes-agent',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
appPath: '/opt/hermes/linux-unpacked',
hermesHome: '/home/x/.hermes'
})
assert.match(script, /^#!\/bin\/bash/)
assert.match(script, /pid=4321/)
assert.match(script, /kill -0 "\$pid"/)
// bounded wait (~30s), not unbounded
assert.match(script, /seq 1 60/)
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'gui'/)
assert.match(script, /rm -rf '\/opt\/hermes\/linux-unpacked'/)
assert.match(script, /export HERMES_HOME='\/home\/x\/\.hermes'/)
})
test('buildPosixCleanupScript exports PYTHONPATH when pythonPath is set (lite/full)', () => {
const script = buildPosixCleanupScript({
desktopPid: 1,
pythonExe: '/usr/bin/python3',
pythonPath: '/home/x/.hermes/hermes-agent',
agentRoot: '/home/x/.hermes/hermes-agent',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'full'],
appPath: null,
hermesHome: '/home/x/.hermes'
})
// System python + source on PYTHONPATH so import hermes_cli works while the
// venv is torn down.
assert.match(script, /export PYTHONPATH='\/home\/x\/\.hermes\/hermes-agent'/)
assert.match(script, /'\/usr\/bin\/python3' '-m' 'hermes_cli\.uninstall' '--mode' 'full'/)
})
test('buildPosixCleanupScript omits PYTHONPATH when pythonPath is null (gui)', () => {
const script = buildPosixCleanupScript({
desktopPid: 1,
pythonExe: '/p/python',
pythonPath: null,
agentRoot: '/a',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
appPath: null,
hermesHome: '/h'
})
assert.doesNotMatch(script, /export PYTHONPATH/)
})
test('buildPosixCleanupScript omits the bundle rm when appPath is null', () => {
const script = buildPosixCleanupScript({
desktopPid: 1,
pythonExe: '/p/python',
pythonPath: null,
agentRoot: '/a',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'lite'],
appPath: null,
hermesHome: '/h'
})
assert.doesNotMatch(script, /rm -rf '\//)
// Still runs the uninstall.
assert.match(script, /'-m' 'hermes_cli\.uninstall' '--mode' 'lite'/)
})
test('buildPosixCleanupScript single-quote-escapes paths with apostrophes', () => {
const script = buildPosixCleanupScript({
desktopPid: 1,
pythonExe: "/home/o'brien/python",
pythonPath: null,
agentRoot: '/a',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
appPath: null,
hermesHome: '/h'
})
// The apostrophe is closed-escaped-reopened so the shell sees the literal.
assert.match(script, /'\/home\/o'\\''brien\/python'/)
})
// --- buildWindowsCleanupScript ---
test('buildWindowsCleanupScript waits (bounded) for PID, runs uninstall, rmdir bundle', () => {
const script = buildWindowsCleanupScript({
desktopPid: 9988,
pythonExe: 'C:\\Python313\\python.exe',
pythonPath: 'C:\\hermes',
agentRoot: 'C:\\hermes',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'full'],
appPath: 'C:\\Users\\x\\AppData\\Local\\Programs\\Hermes',
hermesHome: 'C:\\Users\\x\\AppData\\Local\\hermes'
})
assert.match(script, /@echo off/)
assert.match(script, /set "PID=9988"/)
// PYTHONPATH set so a system python can import hermes_cli from source.
assert.match(script, /set "PYTHONPATH=C:\\hermes;%PYTHONPATH%"/)
assert.match(script, /"C:\\Python313\\python.exe" "-m" "hermes_cli\.uninstall" "--mode" "full"/)
// Bounded wait-loop (no infinite loop), whole-token PID match (no substring).
assert.match(script, /if %waited% geq 60 goto waited_done/)
assert.match(script, /findstr \/r \/c:" %PID% "/)
assert.doesNotMatch(script, /find "%PID%"/) // the old substring-prone form is gone
// Removal is a retry loop (Windows releases dir handles lazily).
assert.match(script, /:rmloop/)
assert.match(script, /rmdir \/s \/q "C:\\Users\\x\\AppData\\Local\\Programs\\Hermes" >nul 2>&1/)
assert.match(script, /if %tries% geq 10 goto rmdone/)
assert.match(script, /del "%~f0"/)
})
test('buildWindowsCleanupScript omits PYTHONPATH + rmdir when not needed (gui, no bundle)', () => {
const script = buildWindowsCleanupScript({
desktopPid: 2,
pythonExe: 'C:\\h\\venv\\Scripts\\python.exe',
pythonPath: null,
agentRoot: 'C:\\h',
uninstallArgs: ['-m', 'hermes_cli.uninstall', '--mode', 'gui'],
appPath: null,
hermesHome: 'C:\\h'
})
assert.doesNotMatch(script, /rmdir/)
assert.doesNotMatch(script, /set "PYTHONPATH=/)
})

View File

@@ -29,15 +29,6 @@ const { runBootstrap } = require('./bootstrap-runner.cjs')
const { canImportHermesCli, verifyHermesCli } = require('./backend-probes.cjs')
const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs')
const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs')
const {
buildPosixCleanupScript,
buildWindowsCleanupScript,
modeRemovesAgent,
modeRemovesUserData,
resolveRemovableAppPath,
shouldRemoveAppBundle,
uninstallArgsForMode
} = require('./desktop-uninstall.cjs')
const {
authModeFromStatus,
buildGatewayWsUrl,
@@ -119,20 +110,6 @@ if (REMOTE_DISPLAY_REASON) {
`[hermes] remote display detected (${REMOTE_DISPLAY_REASON}); disabling GPU hardware acceleration to prevent flicker`
)
}
// Keep the renderer running at full speed while the window is in the background
// or occluded. The chat transcript streams to screen through a
// requestAnimationFrame-gated flush; Chromium pauses rAF (and clamps timers)
// for backgrounded/occluded renderers, so without these the live answer stalls
// whenever the window loses focus (switching to your editor mid-turn, detached
// devtools, another window covering it) and only paints on refocus or refresh.
// `backgroundThrottling: false` on the BrowserWindow covers the blurred case;
// these process-level switches additionally stop Chromium from backgrounding or
// occlusion-throttling the renderer. Must run before app `ready`.
app.commandLine.appendSwitch('disable-renderer-backgrounding')
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows')
app.commandLine.appendSwitch('disable-background-timer-throttling')
const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
// Build-time install stamp -- the git ref this .exe was built against.
@@ -1511,20 +1488,6 @@ function forceKillProcessTree(pid) {
// aggressively SIGKILL-ing the backend here would be an untested behavior change
// for no benefit. So we no-op off Windows and leave that path exactly as it was.
async function releaseBackendLockForUpdate(updateRoot) {
return releaseBackendLock(updateRoot, 'updates')
}
// Shared backend teardown + venv-shim unlock wait. Used by BOTH the self-update
// hand-off and the desktop uninstaller — they have the identical Windows
// problem: the desktop's backend (and the grandchildren IT spawned — a hermes
// REPL, a pty terminal, the gateway) keep `hermes.exe` and other files in the
// venv mandatory-locked, so any in-place replace/delete of the install tree
// races a live handle and half-fails (#37532). We tree-kill every backend PID
// the desktop owns, then poll the shim until it's genuinely writable.
//
// `tag` only flavors the log lines. No-op off Windows (POSIX has no mandatory
// locks — the before-quit SIGTERM + the cleanup script's own PID-wait suffice).
async function releaseBackendLock(updateRoot, tag) {
if (!IS_WINDOWS) return { unlocked: true }
// Collect every backend PID the desktop owns: primary window backend + pool.
@@ -1549,12 +1512,14 @@ async function releaseBackendLock(updateRoot, tag) {
const deadlineMs = Date.now() + 15000
while (Date.now() < deadlineMs) {
if (!isShimLocked(shim)) {
rememberLog(`[${tag}] venv shim unlocked; safe to proceed`)
rememberLog('[updates] venv shim unlocked; safe to hand off the update')
return { unlocked: true }
}
await new Promise(r => setTimeout(r, 300))
}
rememberLog(`[${tag}] venv shim still locked after 15s; proceeding anyway (force)`)
// Timed out: the updater's own wait_for_venv_free + force-kill is the second
// line of defense, and we pass --force so the guard won't dead-end. Log it.
rememberLog('[updates] venv shim still locked after 15s; handing off anyway (updater will force)')
return { unlocked: false }
}
@@ -1916,36 +1881,12 @@ function resolveWebDist() {
const unpackedDist = path.join(unpackedPathFor(APP_ROOT), 'dist')
if (directoryExists(unpackedDist)) return unpackedDist
// Final fallback: APP_ROOT/dist. When packaged with asar:true this lives
// INSIDE app.asar — not a servable filesystem directory — so the embedded
// dashboard backend 404s on static routes (see #41327, #39472). The durable
// fix is unpacking dist/ (PR #41411 adds dist/** to asarUnpack so the tier-2
// unpackedDist above resolves). If we still land here while packaged, log it
// so the cause isn't silent.
const fallback = path.join(APP_ROOT, 'dist')
if (IS_PACKAGED && /app\.asar(?=$|[\\/])/.test(fallback) && !directoryExists(fallback)) {
rememberLog(
`[web-dist] dashboard frontend dir resolved to an asar-internal path that ` +
`is not a real directory: ${fallback}. Static routes will 404. ` +
`Ensure dist/** is unpacked (asarUnpack) or set HERMES_DESKTOP_WEB_DIST.`
)
}
return fallback
return path.join(APP_ROOT, 'dist')
}
function resolveRendererIndex() {
const candidates = [path.join(APP_ROOT, 'dist', 'index.html'), path.join(resolveWebDist(), 'index.html')]
const found = candidates.find(fileExists)
if (found) return found
// Nothing on disk. A packaged build with no renderer bundle blank-pages with
// a bare ERR_FILE_NOT_FOUND and no clue why (see #39484). Surface the cause
// and the fix before Electron loads the missing file.
rememberLog(
`[renderer] index.html not found — the desktop app was packaged without a ` +
`renderer bundle. Tried: ${candidates.join(', ')}. ` +
`Rebuild with: hermes desktop --force-build`
)
return candidates[0]
return candidates.find(fileExists) || candidates[0]
}
function resolveHermesCwd() {
@@ -3175,7 +3116,7 @@ function buildApplicationMenu() {
label: 'Actual Size',
accelerator: 'CommandOrControl+0',
click: () => {
setAndPersistZoomLevel(mainWindow, 0)
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.setZoomLevel(0)
}
},
{
@@ -3183,7 +3124,8 @@ function buildApplicationMenu() {
accelerator: 'CommandOrControl+Plus',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() + 0.1)
const next = Math.min(mainWindow.webContents.getZoomLevel() + 0.1, 9)
mainWindow.webContents.setZoomLevel(next)
}
}
},
@@ -3192,7 +3134,8 @@ function buildApplicationMenu() {
accelerator: 'CommandOrControl+-',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
setAndPersistZoomLevel(mainWindow, mainWindow.webContents.getZoomLevel() - 0.1)
const next = Math.max(mainWindow.webContents.getZoomLevel() - 0.1, -9)
mainWindow.webContents.setZoomLevel(next)
}
}
},
@@ -3254,38 +3197,6 @@ function installPreviewShortcut(window) {
})
}
// Zoom level is persisted in the renderer's own localStorage (per-origin,
// survives reloads/restarts) rather than a main-process JSON file. The main
// process owns setZoomLevel, so we mirror each change into localStorage and
// read it back on did-finish-load to re-apply after reloads or crash recovery.
const ZOOM_STORAGE_KEY = 'hermes:desktop:zoomLevel'
function clampZoomLevel(value) {
if (!Number.isFinite(value)) return 0
return Math.min(Math.max(value, -9), 9)
}
function setAndPersistZoomLevel(window, zoomLevel) {
if (!window || window.isDestroyed()) return
const next = clampZoomLevel(zoomLevel)
window.webContents.setZoomLevel(next)
window.webContents
.executeJavaScript(`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`)
.catch(error => rememberLog(`[zoom] persist failed: ${error?.message || error}`))
}
function restorePersistedZoomLevel(window) {
if (!window || window.isDestroyed()) return
window.webContents
.executeJavaScript(`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`)
.then(stored => {
if (stored == null || !window || window.isDestroyed()) return
const level = clampZoomLevel(Number(stored))
window.webContents.setZoomLevel(level)
})
.catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`))
}
function installZoomShortcuts(window) {
// Override Ctrl/Cmd + +/-/0 with half the default zoom step (0.1 vs 0.2).
// The menu items handle this on macOS (where the menu is always present),
@@ -3299,13 +3210,15 @@ function installZoomShortcuts(window) {
const key = input.key
if (key === '0') {
event.preventDefault()
setAndPersistZoomLevel(window, 0)
window.webContents.setZoomLevel(0)
} else if (key === '=' || key === '+') {
event.preventDefault()
setAndPersistZoomLevel(window, window.webContents.getZoomLevel() + ZOOM_STEP)
const next = Math.min(window.webContents.getZoomLevel() + ZOOM_STEP, 9)
window.webContents.setZoomLevel(next)
} else if (key === '-') {
event.preventDefault()
setAndPersistZoomLevel(window, window.webContents.getZoomLevel() - ZOOM_STEP)
const next = Math.max(window.webContents.getZoomLevel() - ZOOM_STEP, -9)
window.webContents.setZoomLevel(next)
}
})
}
@@ -3913,12 +3826,10 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
const scoped = key ? config.profiles?.[key] || null : null
const block = key ? scoped || {} : config.remote || {}
const envOverride = key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
const remoteToken = decryptDesktopSecret(block.token)
const authMode = normAuthMode(block.authMode)
const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '')
const mode = envOverride || (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
const remoteUrl = String(block.url || '')
const mode = (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
let remoteOauthConnected = false
if (authMode === 'oauth' && remoteUrl) {
@@ -3944,7 +3855,7 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
remoteTokenSet: Boolean(remoteToken),
// The env override only forces the global/primary connection; a per-profile
// scope is never overridden by HERMES_DESKTOP_REMOTE_URL.
envOverride
envOverride: key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
}
}
@@ -4437,9 +4348,6 @@ async function spawnPoolBackend(profile, entry) {
HERMES_HOME,
...backend.env,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
HERMES_DESKTOP: '1',
HERMES_WEB_DIST: webDist
},
shell: backend.shell,
@@ -4581,9 +4489,6 @@ async function startHermes() {
HERMES_HOME,
...backend.env,
HERMES_DASHBOARD_SESSION_TOKEN: token,
// Marks this dashboard backend as desktop-spawned so it runs the cron
// scheduler tick loop (the gateway isn't running under the app).
HERMES_DESKTOP: '1',
HERMES_WEB_DIST: webDist
},
shell: backend.shell,
@@ -4682,7 +4587,7 @@ function createWindow() {
mainWindow = new BrowserWindow({
width: 1220,
height: 800,
minWidth: 400,
minWidth: 900,
minHeight: 620,
title: 'Hermes',
// Frameless title bar on every platform so the renderer can paint the
@@ -4703,16 +4608,7 @@ function createWindow() {
webviewTag: true,
sandbox: true,
nodeIntegration: false,
devTools: true,
// Keep timers + requestAnimationFrame running at full speed when the
// window is blurred/occluded. The chat transcript streams to the screen
// through a requestAnimationFrame-gated flush (useSessionStateCache),
// so with Chromium's default background throttling the live answer
// stalls whenever this window isn't focused (e.g. you switch to your
// editor mid-turn, or open detached devtools) and only appears once you
// refocus or refresh. A streaming chat app must render in the
// background, so opt out — matching the secondary windows above.
backgroundThrottling: false
devTools: true
}
})
@@ -4807,7 +4703,6 @@ function createWindow() {
}
mainWindow.webContents.once('did-finish-load', () => {
restorePersistedZoomLevel(mainWindow)
broadcastBootProgress()
sendWindowStateChanged()
startHermes().catch(error => rememberLog(error.stack || error.message))
@@ -4815,45 +4710,6 @@ function createWindow() {
}
ipcMain.handle('hermes:connection', async (_event, profile) => ensureBackend(profile))
// Reconnect-after-wake recovery. A REMOTE primary backend has no child process,
// so the 'exit'/'error' handlers that would clear a dead connectionPromise never
// fire — once the remote becomes unreachable across a sleep/wake the renderer
// re-dials the same dead descriptor forever and the composer stays stuck on
// "Starting Hermes…". Before the renderer's backoff loop reconnects, it asks us
// to confirm the cached PRIMARY backend is still reachable; if a remote one is
// not, we drop the cache so the next getConnection() rebuilds it. Local backends
// self-heal via their child 'exit' handler, so we never touch them here.
ipcMain.handle('hermes:connection:revalidate', async () => {
if (!connectionPromise) {
return { ok: true, rebuilt: false }
}
let conn = null
try {
conn = await connectionPromise
} catch {
// The cached boot already rejected (its own catch nulls connectionPromise);
// nothing to revalidate — the next getConnection() builds fresh.
return { ok: true, rebuilt: false }
}
if (!conn || conn.mode !== 'remote' || !conn.baseUrl) {
return { ok: true, rebuilt: false }
}
const base = conn.baseUrl.replace(/\/+$/, '')
try {
await fetchPublicJson(`${base}/api/status`, { timeoutMs: 2_500 })
return { ok: true, rebuilt: false }
} catch {
// Unreachable remote: drop the stale cache so the renderer's next reconnect
// tick rebuilds a fresh, reachable descriptor. resetHermesConnection only
// nulls connectionPromise for a remote (no child to SIGTERM).
rememberLog('Cached remote Hermes backend failed liveness probe; dropping stale connection.')
resetHermesConnection()
return { ok: true, rebuilt: true }
}
})
ipcMain.handle('hermes:backend:touch', async (_event, profile) => {
touchPoolBackend(profile)
return { ok: true }
@@ -5617,199 +5473,6 @@ ipcMain.handle('hermes:version', async () => ({
hermesRoot: resolveUpdateRoot()
}))
// ===========================================================================
// Uninstall — remove the Chat GUI (and optionally the agent / user data).
// ===========================================================================
//
// The renderer's About → Danger Zone surfaces three options that mirror the
// CLI exactly: GUI only, Lite (keep user data), Full. We ask the agent to do
// the actual removal via `hermes uninstall …` so the cross-platform PATH /
// registry / service / node-symlink cleanup all lives in one place
// (hermes_cli/uninstall.py + hermes_cli/gui_uninstall.py).
//
// getUninstallSummary() shells out to `--gui-summary` (a fast, no-side-effect
// JSON probe) so the UI can gate options on what's actually installed — and
// detect a missing agent (a future "lite client" that ships without the
// bundled agent), hiding the agent/full options when there's nothing to remove.
function uninstallVenvPython() {
return getVenvPython(VENV_ROOT)
}
async function getUninstallSummary() {
const py = uninstallVenvPython()
const agentRoot = ACTIVE_HERMES_ROOT
// Fast JS-side fallback used when the agent venv is gone (lite client) or the
// probe fails — the renderer still needs *something* to render options from.
const fallback = () => ({
hermes_home: HERMES_HOME,
agent_installed: isHermesSourceRoot(agentRoot) && fileExists(py),
gui_installed: true,
source_built_artifacts: [],
packaged_app_paths: [],
userdata_dir: app.getPath('userData'),
userdata_exists: true,
platform: process.platform,
probe: 'fallback'
})
if (!fileExists(py)) {
return fallback()
}
return new Promise(resolve => {
let stdout = ''
let settled = false
const done = value => {
if (settled) return
settled = true
resolve(value)
}
try {
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], {
cwd: agentRoot,
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
stdio: ['ignore', 'pipe', 'ignore']
})
child.stdout.on('data', chunk => {
stdout += chunk.toString()
})
child.on('error', () => done(fallback()))
child.on('exit', code => {
if (code !== 0) return done(fallback())
try {
const line = stdout.trim().split('\n').filter(Boolean).pop() || '{}'
const parsed = JSON.parse(line)
// The app bundle the renderer would be removing on *this* machine,
// resolved from the running exe (the Python probe only knows the
// standard locations, not where THIS build actually runs from).
parsed.running_app_path = resolveRemovableAppPath(process.execPath, process.platform, process.env)
done(parsed)
} catch {
done(fallback())
}
})
setTimeout(() => done(fallback()), 8000)
} catch {
done(fallback())
}
})
}
async function runDesktopUninstall(mode) {
let uninstallArgs
try {
uninstallArgs = uninstallArgsForMode(mode)
} catch (error) {
return { ok: false, error: 'invalid-mode', message: error.message }
}
const venvPy = uninstallVenvPython()
if (!fileExists(venvPy)) {
return {
ok: false,
error: 'agent-missing',
message: `Can't run the uninstaller: no Hermes agent venv at ${VENV_ROOT}.`
}
}
// Interpreter choice (Finding 3): lite/full rmtree the venv that holds the
// running python.exe. On Windows a running .exe is mandatory-locked, so the
// rmtree must NOT be driven by the venv's own interpreter — use a system
// Python with PYTHONPATH=<agentRoot> so `import hermes_cli` resolves from
// source while the venv is torn down. gui-only doesn't touch the venv, so the
// venv python is fine there. If no system Python exists (the Windows edge
// case), fall back to the venv python — gui-only is unaffected; lite/full may
// leave venv remnants the user can delete, which we log.
let py = venvPy
let pythonPath = null
if (modeRemovesAgent(mode)) {
const sysPy = findSystemPython()
if (sysPy) {
py = sysPy
pythonPath = ACTIVE_HERMES_ROOT
} else if (IS_WINDOWS) {
rememberLog(
'[uninstall] no system Python found for lite/full on Windows; falling back ' +
'to the venv python — venv files locked by the running interpreter may ' +
'remain and need manual deletion.'
)
}
}
const appPath = resolveRemovableAppPath(process.execPath, process.platform, process.env)
const removeBundle = shouldRemoveAppBundle(IS_PACKAGED, appPath) ? appPath : null
// CRITICAL (Windows): tear down every backend the desktop owns and wait for
// the venv shim to unlock BEFORE the cleanup script runs. lite/full delete
// the venv, and even gui-only removes the install tree's GUI artifacts — a
// live backend grandchild (gateway / pty / REPL) holding a mandatory file
// lock would make the script's rmdir half-fail (#37532 for the update path).
// Reuses the incident-hardened update teardown; no-op on macOS/Linux.
try {
await releaseBackendLock(ACTIVE_HERMES_ROOT, 'uninstall')
} catch (error) {
rememberLog(`[uninstall] backend teardown errored (continuing): ${error.message}`)
}
const scriptArgs = {
desktopPid: process.pid,
pythonExe: py,
pythonPath,
agentRoot: ACTIVE_HERMES_ROOT,
uninstallArgs,
appPath: removeBundle,
hermesHome: HERMES_HOME
}
let scriptPath
let runner
let runnerArgs
try {
if (IS_WINDOWS) {
scriptPath = path.join(app.getPath('temp'), `hermes-uninstall-${Date.now()}.cmd`)
fs.writeFileSync(scriptPath, buildWindowsCleanupScript(scriptArgs))
runner = process.env.ComSpec || 'cmd.exe'
runnerArgs = ['/c', scriptPath]
} else {
scriptPath = path.join(app.getPath('temp'), `hermes-uninstall-${Date.now()}.sh`)
fs.writeFileSync(scriptPath, buildPosixCleanupScript(scriptArgs), { mode: 0o755 })
runner = '/bin/bash'
runnerArgs = [scriptPath]
}
} catch (error) {
return { ok: false, error: 'script-write-failed', message: error.message }
}
try {
const child = spawn(runner, runnerArgs, {
detached: true,
stdio: 'ignore',
windowsHide: true
})
child.unref()
} catch (error) {
return { ok: false, error: 'spawn-failed', message: error.message }
}
rememberLog(
`[uninstall] launched detached cleanup (${mode}): ${scriptPath} ` +
`(removesAgent=${modeRemovesAgent(mode)} removesUserData=${modeRemovesUserData(mode)} bundle=${removeBundle || 'none'})`
)
// Give the renderer a beat to show its "uninstalling…" state, then quit so
// the venv python shim + app bundle unlock and the cleanup script can run.
setTimeout(() => app.quit(), 800)
return { ok: true, mode, willRemoveAppBundle: Boolean(removeBundle), scriptPath }
}
ipcMain.handle('hermes:uninstall:summary', async () => getUninstallSummary())
ipcMain.handle('hermes:uninstall:run', async (_event, payload) => {
const mode = payload && typeof payload === 'object' ? payload.mode : payload
return runDesktopUninstall(String(mode || ''))
})
app.whenReady().then(() => {
if (IS_MAC) {
Menu.setApplicationMenu(buildApplicationMenu())

View File

@@ -2,7 +2,6 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron')
contextBridge.exposeInMainWorld('hermesDesktop', {
getConnection: profile => ipcRenderer.invoke('hermes:connection', profile),
revalidateConnection: () => ipcRenderer.invoke('hermes:connection:revalidate'),
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
getBootProgress: () => ipcRenderer.invoke('hermes:boot-progress:get'),
@@ -118,10 +117,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
return () => ipcRenderer.removeListener('hermes:bootstrap:event', listener)
},
getVersion: () => ipcRenderer.invoke('hermes:version'),
uninstall: {
summary: () => ipcRenderer.invoke('hermes:uninstall:summary'),
run: mode => ipcRenderer.invoke('hermes:uninstall:run', { mode })
},
updates: {
check: () => ipcRenderer.invoke('hermes:updates:check'),
apply: opts => ipcRenderer.invoke('hermes:updates:apply', opts),

View File

@@ -18,7 +18,7 @@
"profile:main": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build && node scripts/assert-dist-built.cjs",
"build": "node scripts/assert-root-install.cjs && node scripts/write-build-stamp.cjs && node scripts/stage-native-deps.cjs && tsc -b && vite build",
"builder": "cross-env NODE_OPTIONS=--max-old-space-size=16384 electron-builder",
"pack": "npm run build && npm run builder -- --dir",
"dist": "npm run build && npm run builder",
@@ -35,7 +35,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-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -166,8 +166,7 @@
"afterSign": "scripts/notarize.cjs",
"asarUnpack": [
"**/*.node",
"**/prebuilds/**",
"dist/**"
"**/prebuilds/**"
],
"mac": {
"category": "public.app-category.developer-tools",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 770 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1,70 +0,0 @@
"use strict"
// Build-time guard: refuse to hand a half-built renderer to electron-builder.
//
// `npm run pack` / `npm run dist*` are `npm run build && npm run builder`.
// If the `build` step (tsc -b && vite build) fails but packaging proceeds
// anyway — a stale checkout that fails typecheck, an interrupted vite build,
// or npm not short-circuiting `&&` in some shells — electron-builder happily
// packages an app with an empty or missing `dist/`. The result launches but
// blank-pages with `ERR_FILE_NOT_FOUND` for dist/index.html, with no clue why.
//
// This runs at the tail of `build`, after vite build, so any packaging path
// inherits it. It fails loud and early instead of shipping a broken bundle.
// See issues #39484 (renderer blank page) and #41327 / #39472 (dashboard 404).
const fs = require("fs")
const path = require("path")
// Pure check — returns { ok: true } or { ok: false, error: "..." }.
// Kept side-effect-free so it can be unit tested without spawning a process.
function checkDistBuilt(distDir) {
if (!fs.existsSync(distDir) || !fs.statSync(distDir).isDirectory()) {
return { ok: false, error: `no dist directory at ${distDir}` }
}
const indexHtml = path.join(distDir, "index.html")
if (!fs.existsSync(indexHtml) || !fs.statSync(indexHtml).isFile()) {
return { ok: false, error: `dist/index.html is missing at ${indexHtml}` }
}
if (fs.statSync(indexHtml).size === 0) {
return { ok: false, error: `dist/index.html is empty at ${indexHtml}` }
}
// index.html alone isn't enough — vite emits hashed JS into dist/assets.
// An index.html with no script bundle still blank-pages.
const assetsDir = path.join(distDir, "assets")
const hasAssets =
fs.existsSync(assetsDir) &&
fs.statSync(assetsDir).isDirectory() &&
fs.readdirSync(assetsDir).some(name => name.endsWith(".js"))
if (!hasAssets) {
return { ok: false, error: `dist/assets has no built JS bundle (expected vite output under ${assetsDir})` }
}
return { ok: true }
}
function main() {
const desktopRoot = path.resolve(__dirname, "..")
const distDir = path.join(desktopRoot, "dist")
const result = checkDistBuilt(distDir)
if (!result.ok) {
console.error(`\n✗ assert-dist-built: ${result.error}`)
console.error(" The renderer bundle is missing or incomplete, so packaging")
console.error(" would produce an app that launches to a blank page.")
console.error(" Re-run the build and check the tsc/vite output above for the")
console.error(" real failure, then package again:")
console.error(` cd ${desktopRoot} && npm run build\n`)
process.exit(1)
}
console.log("✓ assert-dist-built: dist/index.html + assets present")
}
if (require.main === module) {
main()
}
module.exports = { checkDistBuilt }

View File

@@ -1,84 +0,0 @@
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
const { checkDistBuilt } = require('../scripts/assert-dist-built.cjs')
function makeDist(extra) {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-'))
const distDir = path.join(tempRoot, 'dist')
fs.mkdirSync(distDir, { recursive: true })
if (extra) extra(distDir)
return { tempRoot, distDir }
}
test('checkDistBuilt passes when index.html + an assets JS bundle exist', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html><div id=root></div>', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
})
try {
assert.deepEqual(checkDistBuilt(distDir), { ok: true })
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when the dist directory is absent', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-assert-dist-'))
try {
const result = checkDistBuilt(path.join(tempRoot, 'dist'))
assert.equal(result.ok, false)
assert.match(result.error, /no dist directory/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when index.html is missing', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.mkdirSync(path.join(d, 'assets'))
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
})
try {
const result = checkDistBuilt(distDir)
assert.equal(result.ok, false)
assert.match(result.error, /index\.html is missing/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when index.html is empty', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.js'), 'console.log(1)', 'utf8')
})
try {
const result = checkDistBuilt(distDir)
assert.equal(result.ok, false)
assert.match(result.error, /index\.html is empty/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})
test('checkDistBuilt fails when assets/ has no JS bundle', () => {
const { tempRoot, distDir } = makeDist(d => {
fs.writeFileSync(path.join(d, 'index.html'), '<!doctype html>', 'utf8')
fs.mkdirSync(path.join(d, 'assets'))
// CSS only, no JS — still a blank page at runtime.
fs.writeFileSync(path.join(d, 'assets', 'index-abc123.css'), 'body{}', 'utf8')
})
try {
const result = checkDistBuilt(distDir)
assert.equal(result.ok, false)
assert.match(result.error, /no built JS bundle/)
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true })
}
})

View File

@@ -38,9 +38,17 @@ export function UrlDialog({
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md gap-5">
<DialogHeader>
<DialogTitle icon={Globe}>{c.attachUrlTitle}</DialogTitle>
<DialogDescription>{c.attachUrlDesc}</DialogDescription>
<DialogHeader className="flex-row items-center gap-3 sm:items-center">
<span
aria-hidden
className="grid size-9 shrink-0 place-items-center rounded-xl bg-[color-mix(in_srgb,var(--dt-primary)_14%,transparent)] text-primary ring-1 ring-inset ring-primary/15"
>
<Globe className="size-4" />
</span>
<div className="grid gap-0.5 text-left">
<DialogTitle>{c.attachUrlTitle}</DialogTitle>
<DialogDescription>{c.attachUrlDesc}</DialogDescription>
</div>
</DialogHeader>
<form
className="grid gap-4"

View File

@@ -124,10 +124,7 @@ function ChatHeader({
return (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div
className="min-w-0 flex-1"
style={{ maxWidth: 'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)' }}
>
<div className="min-w-0 flex-1">
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
@@ -138,11 +135,11 @@ function ChatHeader({
title={title}
>
<Button
className="pointer-events-auto flex h-6 min-w-0 max-w-full gap-1 border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
className="pointer-events-auto h-6 min-w-0 gap-1 border border-transparent bg-transparent px-2 py-0 text-(--ui-text-secondary) hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground data-[state=open]:border-(--ui-stroke-tertiary) data-[state=open]:bg-(--ui-control-active-background) [-webkit-app-region:no-drag]"
type="button"
variant="ghost"
>
<h2 className="min-w-0 flex-1 truncate text-[0.75rem] font-medium leading-none">{title}</h2>
<h2 className="max-w-[52vw] truncate text-[0.75rem] font-medium leading-none">{title}</h2>
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="chevron-down" size="0.8125rem" />
</Button>
</SessionActionsMenu>

View File

@@ -1,325 +0,0 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar'
import { Tip } from '@/components/ui/tooltip'
import { getCronJobRuns, type SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $selectedStoredSessionId } from '@/store/session'
import type { CronJob } from '@/types/hermes'
import { jobState, jobTitle, STATE_DOT } from '../../cron/job-state'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
const INACTIVE_STATES = new Set(['completed', 'disabled', 'error', 'paused'])
// Recent runs shown in the inline quick-peek — enough to glance at history
// without turning the sidebar into the full Cron page.
const PEEK_RUN_LIMIT = 5
// Runs are written by the background scheduler tick (no UI signal), so poll the
// open peek so a freshly-fired run shows up within a few seconds.
const PEEK_POLL_INTERVAL_MS = 8000
const relativeFmt = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' })
// Localized "in 5 min" / "2 hr ago" without hand-rolled strings — picks the
// coarsest sensible unit so a daily job reads "in 14 hr", not "in 840 min".
function relativeTime(targetMs: number, nowMs: number): string {
const diff = targetMs - nowMs
const abs = Math.abs(diff)
const sign = diff < 0 ? -1 : 1
if (abs < 60_000) {return relativeFmt.format(sign * Math.round(abs / 1000), 'second')}
if (abs < 3_600_000) {return relativeFmt.format(sign * Math.round(abs / 60_000), 'minute')}
if (abs < 86_400_000) {return relativeFmt.format(sign * Math.round(abs / 3_600_000), 'hour')}
return relativeFmt.format(sign * Math.round(abs / 86_400_000), 'day')
}
function nextRunMs(job: CronJob): null | number {
if (!job.next_run_at) {return null}
const ms = Date.parse(job.next_run_at)
return Number.isNaN(ms) ? null : ms
}
// Runs all belong to the same job, so the run name just repeats the job name —
// the timestamp is what tells them apart. Compact (no year, no seconds) for the
// narrow sidebar.
function formatRunTime(seconds?: null | number): string {
if (!seconds) {return '—'}
const date = new Date(seconds * 1000)
return Number.isNaN(date.valueOf())
? '—'
: date.toLocaleString(undefined, { day: 'numeric', hour: 'numeric', minute: '2-digit', month: 'short' })
}
interface SidebarCronJobsSectionProps {
jobs: CronJob[]
label: string
max?: number
// Open a run session's chat (1 click to output).
onOpenRun: (sessionId: string) => void
// Open the full Cron page focused on this job (manage / full history).
onManageJob: (jobId: string) => void
// Fire the job now.
onTriggerJob: (jobId: string) => void
onToggle: () => void
open: boolean
}
export function SidebarCronJobsSection({
jobs,
label,
max = 50,
onManageJob,
onOpenRun,
onTriggerJob,
onToggle,
open
}: SidebarCronJobsSectionProps) {
const [nowMs, setNowMs] = useState(() => Date.now())
// Single-open inline peek so the section stays scannable.
const [peekJobId, setPeekJobId] = useState<null | string>(null)
// One clock for the whole section (rows are pure) so the countdowns tick
// without re-rendering the rest of the sidebar. Only runs while expanded.
useEffect(() => {
if (!open) {return}
const id = window.setInterval(() => setNowMs(Date.now()), 1000)
return () => window.clearInterval(id)
}, [open])
// Upcoming first (soonest next run), jobs with no next run sink to the bottom,
// then alphabetical for stability.
const sorted = useMemo(() => {
return [...jobs].sort((a, b) => {
const an = nextRunMs(a)
const bn = nextRunMs(b)
if (an !== null && bn !== null && an !== bn) {return an - bn}
if (an === null && bn !== null) {return 1}
if (an !== null && bn === null) {return -1}
return jobTitle(a).localeCompare(jobTitle(b))
})
}, [jobs])
const shown = sorted.slice(0, max)
// When capped, signal "50+" rather than implying the list is complete.
const countLabel = jobs.length > max ? `${max}+` : String(jobs.length)
return (
<SidebarGroup className="shrink-0 p-0 pb-1">
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
<button
className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left leading-none"
onClick={onToggle}
type="button"
>
<SidebarPanelLabel>{label}</SidebarPanelLabel>
<span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{countLabel}</span>
<DisclosureCaret
className="text-(--ui-text-tertiary) opacity-0 transition group-hover/section-label:opacity-100"
open={open}
/>
</button>
</div>
{open && (
<SidebarGroupContent className="flex max-h-72 shrink-0 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75">
{shown.map(job => (
<CronJobSidebarRow
expanded={peekJobId === job.id}
job={job}
key={job.id}
nowMs={nowMs}
onManage={() => onManageJob(job.id)}
onOpenRun={onOpenRun}
onTogglePeek={() => setPeekJobId(prev => (prev === job.id ? null : job.id))}
onTrigger={() => onTriggerJob(job.id)}
/>
))}
</SidebarGroupContent>
)}
</SidebarGroup>
)
}
function CronJobSidebarRow({
expanded,
job,
nowMs,
onManage,
onOpenRun,
onTogglePeek,
onTrigger
}: {
expanded: boolean
job: CronJob
nowMs: number
onManage: () => void
onOpenRun: (sessionId: string) => void
onTogglePeek: () => void
onTrigger: () => void
}) {
const { t } = useI18n()
const c = t.cron
const state = jobState(job)
const next = nextRunMs(job)
const label = jobTitle(job)
const meta = INACTIVE_STATES.has(state)
? (c.states[state] ?? state)
: next !== null
? relativeTime(next, nowMs)
: '—'
return (
<div>
<div className="group/cron relative grid min-h-[1.625rem] grid-cols-[minmax(0,1fr)_auto] items-center rounded-md hover:bg-(--chrome-action-hover)">
{/* Lead with the dot in the same w-3.5 cell + pl-2 the session rows use
so the cron dots line up with the sessions above; the caret sits next
to the label (matching the other sidebar disclosures) and the whole
label area toggles the run peek. */}
<button
aria-expanded={expanded}
aria-label={expanded ? c.hideRuns : c.showRuns}
className="flex min-w-0 items-center gap-1.5 bg-transparent py-0.5 pl-2 pr-1 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
onClick={onTogglePeek}
title={label}
type="button"
>
<span className="grid w-3.5 shrink-0 place-items-center">
<span
aria-hidden="true"
className={cn(
'size-1 rounded-full',
STATE_DOT[state] ?? 'bg-(--ui-text-quaternary)',
state === 'running' && 'size-1.5 animate-pulse'
)}
/>
</span>
<span className="min-w-0 truncate text-[0.8125rem] text-(--ui-text-secondary) group-hover/cron:text-foreground">
{label}
</span>
<DisclosureCaret
className={cn(
'shrink-0 text-(--ui-text-tertiary) transition',
expanded ? 'opacity-100' : 'opacity-0 group-hover/cron:opacity-100'
)}
open={expanded}
/>
</button>
{/* Trailing cluster: countdown by default, quick actions on hover. */}
<div className="flex items-center gap-0.5 justify-self-end pr-1">
<span className="text-[0.6875rem] text-(--ui-text-tertiary) tabular-nums group-hover/cron:hidden">
{meta}
</span>
<div className="hidden items-center gap-0.5 group-hover/cron:flex">
<Tip label={c.triggerNow}>
<button
aria-label={c.triggerNow}
className="grid size-5 place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={onTrigger}
type="button"
>
<Codicon name="zap" size="0.75rem" />
</button>
</Tip>
<Tip label={c.manage}>
<button
aria-label={c.manage}
className="grid size-5 place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={onManage}
type="button"
>
<Codicon name="watch" size="0.75rem" />
</button>
</Tip>
</div>
</div>
</div>
{expanded && <CronJobSidebarRuns jobId={job.id} onOpenRun={onOpenRun} />}
</div>
)
}
function CronJobSidebarRuns({
jobId,
onOpenRun
}: {
jobId: string
onOpenRun: (sessionId: string) => void
}) {
const { t } = useI18n()
const c = t.cron
const selectedSessionId = useStore($selectedStoredSessionId)
const [runs, setRuns] = useState<null | SessionInfo[]>(null)
useEffect(() => {
let cancelled = false
const load = () =>
getCronJobRuns(jobId, PEEK_RUN_LIMIT)
.then(result => {
if (!cancelled) {setRuns(result)}
})
.catch(() => {
if (!cancelled) {setRuns(prev => prev ?? [])}
})
void load()
const intervalId = window.setInterval(() => {
if (document.visibilityState === 'visible') {void load()}
}, PEEK_POLL_INTERVAL_MS)
return () => {
cancelled = true
window.clearInterval(intervalId)
}
}, [jobId])
return (
<div className="mb-1 ml-[1.375rem] flex flex-col gap-px">
{runs === null ? (
<div className="flex items-center gap-1.5 py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">
<Codicon name="loading" size="0.75rem" spinning />
</div>
) : runs.length === 0 ? (
<div className="py-1 pl-1 text-[0.6875rem] text-(--ui-text-tertiary)">{c.noRuns}</div>
) : (
<>
{runs.map(run => (
<button
className={cn(
'truncate rounded-md px-1.5 py-0.5 text-left text-[0.6875rem] tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',
run.id === selectedSessionId
? 'bg-(--ui-row-active-background) text-foreground'
: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-foreground'
)}
key={run.id}
onClick={() => onOpenRun(run.id)}
type="button"
>
{formatRunTime(run.last_active || run.started_at)}
</button>
))}
</>
)}
</div>
)
}

View File

@@ -19,7 +19,6 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
@@ -40,29 +39,20 @@ import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/he
import { useI18n } from '@/i18n'
import { profileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search'
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
import { $cronJobs } from '@/store/cron'
import {
$panesFlipped,
$pinnedSessionIds,
$sidebarAgentsGrouped,
$sidebarCronOpen,
$sidebarOpen,
$sidebarOverlayMounted,
$sidebarPinsOpen,
$sidebarRecentsOpen,
$sidebarSessionOrderIds,
$sidebarWorkspaceOrderIds,
pinSession,
reorderPinnedSession,
SESSION_SEARCH_FOCUS_EVENT,
setSidebarAgentsGrouped,
setSidebarCronOpen,
setSidebarPinsOpen,
setSidebarRecentsOpen,
setSidebarSessionOrderIds,
setSidebarWorkspaceOrderIds,
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '@/store/layout'
@@ -75,7 +65,6 @@ import {
normalizeProfileKey
} from '@/store/profile'
import {
$cronSessions,
$selectedStoredSessionId,
$sessionProfileTotals,
$sessions,
@@ -89,7 +78,6 @@ import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '..
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import type { SidebarNavItem } from '../../types'
import { SidebarCronJobsSection } from './cron-jobs-section'
import { ProfileRail } from './profile-switcher'
import { SidebarSessionRow } from './session-row'
import { VirtualSessionList } from './virtual-session-list'
@@ -123,14 +111,10 @@ const WORKSPACE_PAGE = 5
// ALL-profiles view: show only the latest N per profile up front to keep the
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
const PROFILE_INITIAL_PAGE = 5
const GROUP_DND_ID_PREFIX = 'group:'
const LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui'])
const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
const parseGroupDndId = (id: string) =>
id.startsWith(GROUP_DND_ID_PREFIX) ? id.slice(GROUP_DND_ID_PREFIX.length) : null
const WS_ID_PREFIX = 'workspace:'
const wsId = (id: string) => `${WS_ID_PREFIX}${id}`
const parseWsId = (id: string) => (id.startsWith(WS_ID_PREFIX) ? id.slice(WS_ID_PREFIX.length) : null)
const countLabel = (loaded: number, total: number) => (total > loaded ? `${loaded}/${total}` : String(loaded))
const sessionTime = (s: SessionInfo) => s.last_active || s.started_at || 0
@@ -161,33 +145,6 @@ function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[
return out
}
function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
if (!currentIds.length) {
return []
}
if (!orderIds.length) {
return currentIds
}
const current = new Set(currentIds)
const next = orderIds.filter(id => current.has(id))
const known = new Set(next)
for (const id of currentIds) {
if (!known.has(id)) {
next.push(id)
known.add(id)
}
}
return next
}
function sameIds(left: string[], right: string[]) {
return left.length === right.length && left.every((item, index) => item === right[index])
}
const baseName = (path: string) =>
path
.replace(/[/\\]+$/, '')
@@ -221,11 +178,7 @@ function searchResultToSession(result: SessionSearchResult): SessionInfo {
}
}
function workspaceGroupsFor(
sessions: SessionInfo[],
noWorkspaceLabel: string,
options: { preserveSessionOrder?: boolean } = {}
): SidebarSessionGroup[] {
function workspaceGroupsFor(sessions: SessionInfo[], noWorkspaceLabel: string): SidebarSessionGroup[] {
const groups = new Map<string, SidebarSessionGroup>()
for (const session of sessions) {
@@ -238,56 +191,17 @@ function workspaceGroupsFor(
groups.set(id, group)
}
if (!options.preserveSessionOrder) {
// Groups keep recency order (Map insertion = first-seen in the recency-sorted
// input, so an active project floats up), but rows *within* a group sort by
// creation time so they don't reshuffle every time a message lands — keeps
// muscle memory intact.
for (const group of groups.values()) {
group.sessions.sort((a, b) => b.started_at - a.started_at)
}
// Groups keep recency order (Map insertion = first-seen in the recency-sorted
// input, so an active project floats up), but rows *within* a group sort by
// creation time so they don't reshuffle every time a message lands — keeps
// muscle memory intact.
for (const group of groups.values()) {
group.sessions.sort((a, b) => b.started_at - a.started_at)
}
return [...groups.values()]
}
function sourceSessionGroupsFor(sessions: SessionInfo[]): {
localSessions: SessionInfo[]
sourceGroups: SidebarSessionGroup[]
} {
const groups = new Map<string, SidebarSessionGroup>()
const localSessions: SessionInfo[] = []
for (const session of sessions) {
const sourceId = normalizeSessionSource(session.source)
if (!sourceId || LOCAL_SESSION_SOURCES.has(sourceId)) {
localSessions.push(session)
continue
}
const label = sessionSourceLabel(sourceId) ?? sourceId
const group = groups.get(sourceId) ?? {
id: `source:${sourceId}`,
label,
mode: 'source',
path: null,
sessions: [],
sourceId
}
group.sessions.push(session)
groups.set(sourceId, group)
}
return {
localSessions,
sourceGroups: [...groups.values()].sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}
}
function useSortableBindings(id: string) {
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
@@ -296,11 +210,7 @@ function useSortableBindings(id: string) {
dragHandleProps: { ...attributes, ...listeners },
ref: setNodeRef,
reorderable: true as const,
style: {
transform: CSS.Transform.toString(transform),
transition: isDragging ? undefined : transition,
willChange: isDragging ? 'transform' : undefined
}
style: { transform: CSS.Transform.toString(transform), transition }
}
}
@@ -313,8 +223,6 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
onNewSessionInWorkspace: (path: null | string) => void
onManageCronJob: (jobId: string) => void
onTriggerCronJob: (jobId: string) => void
}
export function ChatSidebar({
@@ -325,26 +233,18 @@ export function ChatSidebar({
onResumeSession,
onDeleteSession,
onArchiveSession,
onNewSessionInWorkspace,
onManageCronJob,
onTriggerCronJob
onNewSessionInWorkspace
}: ChatSidebarProps) {
const { t } = useI18n()
const s = t.sidebar
const sidebarOpen = useStore($sidebarOpen)
// Collapsed-but-overlay-mounted → render the full sidebar, not just the nav rail.
const overlayMounted = useStore($sidebarOverlayMounted)
const contentVisible = sidebarOpen || overlayMounted
const panesFlipped = useStore($panesFlipped)
const agentsGrouped = useStore($sidebarAgentsGrouped)
const pinnedSessionIds = useStore($pinnedSessionIds)
const pinsOpen = useStore($sidebarPinsOpen)
const agentsOpen = useStore($sidebarRecentsOpen)
const cronOpen = useStore($sidebarCronOpen)
const selectedSessionId = useStore($selectedStoredSessionId)
const sessions = useStore($sessions)
const cronSessions = useStore($cronSessions)
const cronJobs = useStore($cronJobs)
const sessionsLoading = useStore($sessionsLoading)
const sessionsTotal = useStore($sessionsTotal)
const sessionProfileTotals = useStore($sessionProfileTotals)
@@ -358,8 +258,8 @@ export function ChatSidebar({
// profile while scope is still ALL (persisted), the rail is hidden and they'd
// otherwise be stuck in the grouped view with no way out.
const showAllProfiles = multiProfile && profileScope === ALL_PROFILES
const agentOrderIds = useStore($sidebarSessionOrderIds)
const workspaceOrderIds = useStore($sidebarWorkspaceOrderIds)
const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
@@ -423,10 +323,7 @@ export function ChatSidebar({
const sessionByAnyId = useMemo(() => {
const map = new Map<string, SessionInfo>()
// Cron sessions are listed separately but can still be pinned, so index
// them too — otherwise a pinned cron job can't resolve into the Pinned
// section. Recents take precedence on id collisions (set last).
for (const s of [...cronSessions, ...visibleSessions]) {
for (const s of visibleSessions) {
map.set(s.id, s)
if (s._lineage_root_id && !map.has(s._lineage_root_id)) {
@@ -435,7 +332,7 @@ export function ChatSidebar({
}
return map
}, [visibleSessions, cronSessions])
}, [visibleSessions])
const pinnedSessions = useMemo(() => {
const seen = new Set<string>()
@@ -513,40 +410,14 @@ export function ChatSidebar({
[sortedSessions, pinnedRealIdSet]
)
useEffect(() => {
const next = reconcileOrderIds(
unpinnedAgentSessions.map(s => s.id),
agentOrderIds
)
if (!sameIds(next, agentOrderIds)) {
setSidebarSessionOrderIds(next)
}
}, [agentOrderIds, unpinnedAgentSessions])
const agentSessions = useMemo(
() => orderByIds(unpinnedAgentSessions, s => s.id, agentOrderIds),
[unpinnedAgentSessions, agentOrderIds]
)
const { localSessions: localAgentSessions, sourceGroups } = useMemo(
() => sourceSessionGroupsFor(agentSessions),
[agentSessions]
)
const orderedSourceGroups = useMemo(
() => orderByIds(sourceGroups, g => g.id, workspaceOrderIds),
[sourceGroups, workspaceOrderIds]
)
const agentGroups = useMemo(
() =>
orderByIds(
workspaceGroupsFor(localAgentSessions, s.noWorkspace, { preserveSessionOrder: sourceGroups.length > 0 }),
g => g.id,
workspaceOrderIds
),
[localAgentSessions, s.noWorkspace, sourceGroups.length, workspaceOrderIds]
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
[agentSessions, s.noWorkspace, workspaceOrderIds]
)
const loadMoreForProfileGroup = useCallback(
@@ -559,7 +430,9 @@ export function ChatSidebar({
void Promise.resolve(onLoadMoreProfileSessions(profile))
.catch(() => undefined)
.finally(() => setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest))
.finally(() =>
setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest)
)
},
[onLoadMoreProfileSessions]
)
@@ -590,17 +463,15 @@ export function ChatSidebar({
groups.set(key, group)
}
return (
[...groups.values()]
.map(group => ({
...group,
loadingMore: Boolean(profileLoadMorePending[group.id]),
onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined,
totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0)
}))
// default (root) first, then the rest alphabetically.
.sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)))
)
return [...groups.values()]
.map(group => ({
...group,
loadingMore: Boolean(profileLoadMorePending[group.id]),
onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined,
totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0)
}))
// default (root) first, then the rest alphabetically.
.sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)))
}, [
showAllProfiles,
agentSessions,
@@ -610,57 +481,8 @@ export function ChatSidebar({
sessionProfileTotals
])
const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions
const displayAgentGroups = useMemo(() => {
if (orderedSourceGroups.length) {
const localGroups = agentsGrouped
? agentGroups
: localAgentSessions.length
? [
{
id: 'local-sessions',
label: 'Local',
mode: 'workspace' as const,
path: null,
sessions: localAgentSessions
}
]
: []
return orderByIds([...orderedSourceGroups, ...localGroups], g => g.id, workspaceOrderIds)
}
return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
}, [
agentGroups,
agentsGrouped,
localAgentSessions,
orderedSourceGroups,
profileGroups,
showAllProfiles,
workspaceOrderIds
])
useEffect(() => {
if (!displayAgentGroups?.length || showAllProfiles) {
return
}
const next = reconcileOrderIds(
displayAgentGroups.map(g => g.id),
workspaceOrderIds
)
if (!sameIds(next, workspaceOrderIds)) {
setSidebarWorkspaceOrderIds(next)
}
}, [displayAgentGroups, showAllProfiles, workspaceOrderIds])
const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
// Pagination is scope-aware. In "All profiles" mode it tracks the global
// unified set. When scoped to one profile it must compare that profile's own
// loaded rows against that profile's total — otherwise a huge default profile
@@ -704,24 +526,23 @@ export function ChatSidebar({
const activeId = String(active.id)
const overId = String(over.id)
const activeGroup = parseGroupDndId(activeId)
const overGroup = parseGroupDndId(overId)
const activeWs = parseWsId(activeId)
const overWs = parseWsId(overId)
if (activeGroup && overGroup) {
const groups = displayAgentGroups ?? []
const oldIdx = groups.findIndex(g => g.id === activeGroup)
const newIdx = groups.findIndex(g => g.id === overGroup)
if (activeWs && overWs) {
const oldIdx = agentGroups.findIndex(g => g.id === activeWs)
const newIdx = agentGroups.findIndex(g => g.id === overWs)
if (oldIdx < 0 || newIdx < 0) {
return
}
setSidebarWorkspaceOrderIds(arrayMove(groups, oldIdx, newIdx).map(g => g.id))
setWorkspaceOrderIds(arrayMove(agentGroups, oldIdx, newIdx).map(g => g.id))
return
}
if (activeGroup || overGroup) {
if (activeWs || overWs) {
return
}
@@ -732,7 +553,7 @@ export function ChatSidebar({
return
}
setSidebarSessionOrderIds(arrayMove(agentSessions, oldIdx, newIdx).map(s => s.id))
setAgentOrderIds(arrayMove(agentSessions, oldIdx, newIdx).map(s => s.id))
}
return (
@@ -742,11 +563,7 @@ export function ChatSidebar({
panesFlipped ? 'border-l border-r-0' : 'border-r border-l-0',
sidebarOpen
? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
: 'pointer-events-none border-transparent bg-transparent opacity-0',
// While floated by PaneShell's hover-reveal, force visible + interactive
// — on hover (group-hover/reveal) or when keyboard-pinned (data-forced).
'in-data-[pane-hover-reveal=open]:pointer-events-auto in-data-[pane-hover-reveal=open]:border-(--sidebar-edge-border) in-data-[pane-hover-reveal=open]:bg-(--ui-sidebar-surface-background) in-data-[pane-hover-reveal=open]:opacity-100',
'group-hover/reveal:pointer-events-auto group-hover/reveal:border-(--sidebar-edge-border) group-hover/reveal:bg-(--ui-sidebar-surface-background) group-hover/reveal:opacity-100'
: 'pointer-events-none border-transparent bg-transparent opacity-0'
)}
collapsible="none"
>
@@ -790,14 +607,14 @@ export function ChatSidebar({
type="button"
>
<item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
{contentVisible && (
{sidebarOpen && (
<>
<span className="min-w-0 flex-1 truncate">
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">
{s.nav[item.id] ?? item.label}
</span>
{isNewSession && (
<KbdGroup
className={cn('ml-auto', newSessionKbdFlash && 'opacity-100!')}
className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
keys={[...NEW_SESSION_KBD]}
/>
)}
@@ -811,7 +628,7 @@ export function ChatSidebar({
</SidebarGroupContent>
</SidebarGroup>
{contentVisible && showSessionSections && (
{sidebarOpen && showSessionSections && (
<div className="shrink-0 px-2 pb-1 pt-1">
<SearchField
aria-label={s.searchAria}
@@ -823,7 +640,7 @@ export function ChatSidebar({
</div>
)}
{contentVisible && showSessionSections && trimmedQuery && (
{sidebarOpen && showSessionSections && trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
@@ -847,7 +664,7 @@ export function ChatSidebar({
/>
)}
{contentVisible && showSessionSections && !trimmedQuery && (
{sidebarOpen && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
@@ -869,7 +686,7 @@ export function ChatSidebar({
/>
)}
{contentVisible && showSessionSections && !trimmedQuery && (
{sidebarOpen && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName={cn(
@@ -893,7 +710,7 @@ export function ChatSidebar({
) : null
}
forceEmptyState={showSessionSkeletons}
groups={displayAgentGroups}
groups={showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined}
headerAction={
// Always reserve the icon-xs (size-6) slot so the header keeps the
// same height whether or not the toggle renders — otherwise the
@@ -902,7 +719,7 @@ export function ChatSidebar({
// the toggle does nothing, and it's irrelevant in the ALL-profiles
// view (always grouped by profile), so hide the button (not the slot).
<div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && localAgentSessions.length > 0 ? (
{!showAllProfiles && agentSessions.length > 0 ? (
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
<Button
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
@@ -936,27 +753,15 @@ export function ChatSidebar({
open={agentsOpen}
pinned={false}
rootClassName="min-h-0 flex-1 p-0"
sessions={displayAgentSessions}
sessions={agentSessions}
sortable={!showAllProfiles && agentSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{contentVisible && !trimmedQuery && cronJobs.length > 0 && (
<SidebarCronJobsSection
jobs={cronJobs}
label={s.cronJobs}
onManageJob={onManageCronJob}
onOpenRun={onResumeSession}
onToggle={() => setSidebarCronOpen(!cronOpen)}
onTriggerJob={onTriggerCronJob}
open={cronOpen}
/>
)}
{sidebarOpen && !showSessionSections && <div className="min-h-0 flex-1" />}
{contentVisible && !showSessionSections && <div className="min-h-0 flex-1" />}
{contentVisible && (
{sidebarOpen && (
<div className="shrink-0 px-0.5 pb-1 pt-0.5">
<ProfileRail />
</div>
@@ -1038,9 +843,8 @@ interface SidebarSessionGroup {
// Profile color for the ALL-profiles view; absent for workspace groups.
color?: null | string
loadingMore?: boolean
mode?: 'profile' | 'source' | 'workspace'
mode?: 'profile' | 'workspace'
onLoadMore?: () => void
sourceId?: string
totalCount?: number
}
@@ -1095,8 +899,7 @@ function SidebarSessionsSection({
onReorder,
dndSensors
}: SidebarSessionsSectionProps) {
const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0))
const showEmptyState = forceEmptyState || (!hasGroupedSessions && sessions.length === 0)
const showEmptyState = forceEmptyState || sessions.length === 0
const dndActive = sortable && !!onReorder
const renderRow = (session: SessionInfo) => {
@@ -1129,25 +932,12 @@ function SidebarSessionsSection({
renderRows(items)
)
const renderNestedSessionList = (items: SessionInfo[]) =>
dndActive ? (
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
<SortableContext items={items.map(s => s.id)} strategy={verticalListSortingStrategy}>
{renderRows(items)}
</SortableContext>
</DndContext>
) : (
renderRows(items)
)
const flatVirtualized = !showEmptyState && !groups?.length && sessions.length >= VIRTUALIZE_THRESHOLD
let inner: React.ReactNode
let bodyOwnsDndContext = dndActive && !showEmptyState
if (showEmptyState) {
inner = emptyState
bodyOwnsDndContext = false
} else if (groups?.length) {
const groupNodes = groups.map(group =>
dndActive ? (
@@ -1155,7 +945,7 @@ function SidebarSessionsSection({
group={group}
key={group.id}
onNewSession={onNewSessionInWorkspace}
renderRows={renderNestedSessionList}
renderRows={renderSessionList}
/>
) : (
<SidebarWorkspaceGroup
@@ -1168,15 +958,12 @@ function SidebarSessionsSection({
)
inner = dndActive ? (
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
<SortableContext items={groups.map(g => groupDndId(g.id))} strategy={verticalListSortingStrategy}>
{groupNodes}
</SortableContext>
</DndContext>
<SortableContext items={groups.map(g => wsId(g.id))} strategy={verticalListSortingStrategy}>
{groupNodes}
</SortableContext>
) : (
groupNodes
)
bodyOwnsDndContext = false
} else if (flatVirtualized) {
inner = (
<VirtualSessionList
@@ -1195,13 +982,14 @@ function SidebarSessionsSection({
inner = renderSessionList(sessions)
}
const body = bodyOwnsDndContext ? (
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
{inner}
</DndContext>
) : (
inner
)
const body =
dndActive && !showEmptyState ? (
<DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
{inner}
</DndContext>
) : (
inner
)
// The virtualizer owns its own scroller, so suppress the wrapper's overflow
// to avoid a double scroll container.
@@ -1244,7 +1032,6 @@ function SidebarWorkspaceGroup({
const { t } = useI18n()
const s = t.sidebar
const isProfileGroup = group.mode === 'profile'
const isSourceGroup = group.mode === 'source'
const pageStep = isProfileGroup ? PROFILE_INITIAL_PAGE : WORKSPACE_PAGE
const [open, setOpen] = useState(true)
const [visibleCount, setVisibleCount] = useState(pageStep)
@@ -1270,16 +1057,7 @@ function SidebarWorkspaceGroup({
}
return (
<div
className={cn(
'grid gap-px data-[dragging=true]:z-10 data-[dragging=true]:opacity-70 data-[dragging=true]:will-change-transform',
className
)}
data-dragging={dragging ? 'true' : undefined}
ref={ref}
style={style}
{...rest}
>
<div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
<div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
<button
className="flex min-w-0 items-center gap-1.5 bg-transparent text-left hover:text-(--ui-text-secondary)"
@@ -1287,18 +1065,7 @@ function SidebarWorkspaceGroup({
type="button"
>
{group.color ? (
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: group.color }}
/>
) : null}
{isSourceGroup && group.sourceId ? (
<PlatformAvatar
className="size-4 rounded-[4px] text-[0.5625rem] [&_svg]:size-3"
platformId={group.sourceId}
platformName={group.label}
/>
<span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: group.color }} />
) : null}
<span className="truncate">{group.label}</span>
<SidebarCount>
@@ -1347,11 +1114,7 @@ function SidebarWorkspaceGroup({
{renderRows(visibleSessions)}
{hiddenCount > 0 &&
(isProfileGroup ? (
<SidebarLoadMoreRow
loading={Boolean(group.loadingMore)}
onClick={handleProfileLoadMore}
step={nextCount}
/>
<SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
) : (
<Tip label={s.showMoreIn(nextCount, group.label)}>
<button
@@ -1377,7 +1140,7 @@ interface SortableWorkspaceProps {
}
function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
return <SidebarWorkspaceGroup {...props} {...useSortableBindings(groupDndId(props.group.id))} />
return <SidebarWorkspaceGroup {...props} {...useSortableBindings(wsId(props.group.id))} />
}
function SidebarCount({ children }: { children: React.ReactNode }) {

View File

@@ -176,8 +176,8 @@ export function SidebarSessionRow({
needsInput ? 'overflow-visible' : 'overflow-hidden'
)}
>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
)}
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
import type * as React from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
interface CronJobActions {
busy?: boolean
isPaused: boolean
title: string
onDelete: () => void
onEdit: () => void
onPauseResume: () => void
onTrigger: () => void
}
interface CronJobActionsMenuProps
extends CronJobActions, Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
children: React.ReactNode
}
export function CronJobActionsMenu({
align = 'end',
busy = false,
children,
isPaused,
onDelete,
onEdit,
onPauseResume,
onTrigger,
sideOffset = 6,
title
}: CronJobActionsMenuProps) {
const { t } = useI18n()
const c = t.cron
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
aria-label={c.actionsFor(title)}
className="w-44"
sideOffset={sideOffset}
>
<DropdownMenuItem
disabled={busy}
onSelect={() => {
triggerHaptic('selection')
onPauseResume()
}}
>
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
<span>{isPaused ? c.resumeTitle : c.pauseTitle}</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={busy}
onSelect={() => {
triggerHaptic('selection')
onTrigger()
}}
>
<Codicon name="zap" size="0.875rem" />
<span>{c.triggerNow}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onEdit()
}}
>
<Codicon name="edit" size="0.875rem" />
<span>{c.edit}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
triggerHaptic('warning')
onDelete()
}}
variant="destructive"
>
<Codicon name="trash" size="0.875rem" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
interface CronJobActionsTriggerProps extends Omit<React.ComponentProps<typeof Button>, 'size' | 'variant'> {
title: string
}
export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) {
const { t } = useI18n()
return (
<Button
aria-label={t.cron.actionsFor(title)}
className={className}
size="icon-sm"
title={t.cron.actionsTitle}
variant="ghost"
{...props}
>
<Codicon className="text-muted-foreground" name="ellipsis" size="0.875rem" />
</Button>
)
}

View File

@@ -1,6 +1,5 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
@@ -14,33 +13,29 @@ import {
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { SearchField } from '@/components/ui/search-field'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import {
createCronJob,
type CronJob,
deleteCronJob,
getCronJobRuns,
getCronJobs,
pauseCronJob,
resumeCronJob,
type SessionInfo,
triggerCronJob,
updateCronJob
} from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { AlertTriangle, Clock } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $cronFocusJobId, $cronJobs, setCronFocusJobId, setCronJobs, updateCronJobs } from '@/store/cron'
import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
import { PageSearchShell } from '../page-search-shell'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { jobState, jobTitle, STATE_DOT } from './job-state'
import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu'
const DEFAULT_DELIVER = 'local'
@@ -85,6 +80,28 @@ function jobPrompt(job: CronJob): string {
return asText(job.prompt)
}
function jobTitle(job: CronJob): string {
const name = jobName(job)
if (name) {
return name
}
const prompt = jobPrompt(job)
if (prompt) {
return truncate(prompt, 60)
}
const script = asText(job.script)
if (script) {
return truncate(script, 60)
}
return job.id || 'Cron job'
}
function jobScheduleDisplay(job: CronJob): string {
return asText(job.schedule_display) || asText(job.schedule?.display) || asText(job.schedule?.expr) || '—'
}
@@ -93,6 +110,10 @@ function jobScheduleExpr(job: CronJob): string {
return asText(job.schedule?.expr) || asText(job.schedule_display) || ''
}
function jobState(job: CronJob): string {
return asText(job.state) || (job.enabled === false ? 'disabled' : 'scheduled')
}
function jobDeliver(job: CronJob): string {
return asText(job.deliver) || DEFAULT_DELIVER
}
@@ -240,38 +261,31 @@ function matchesQuery(job: CronJob, q: string): boolean {
interface CronViewProps extends React.ComponentProps<'section'> {
onClose: () => void
onOpenSession?: (sessionId: string) => void
setStatusbarItemGroup?: SetStatusbarItemGroup
}
export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setStatusbarItemGroup }: CronViewProps) {
export function CronView({ onClose, setStatusbarItemGroup: _setStatusbarItemGroup, ...props }: CronViewProps) {
const { t } = useI18n()
const c = t.cron
// Source of truth is the shared atom (also fed by the controller poll), so the
// sidebar and this overlay never drift — a delete here clears the sidebar row
// immediately. `loading` only gates the first paint before the atom is filled.
const jobs = useStore($cronJobs)
const [loading, setLoading] = useState(jobs.length === 0)
const [jobs, setJobs] = useState<CronJob[] | null>(null)
const [query, setQuery] = useState('')
const [refreshing, setRefreshing] = useState(false)
const [busyJobId, setBusyJobId] = useState<null | string>(null)
// Master/detail: the job whose schedule + run history fill the right pane.
const [selectedJobId, setSelectedJobId] = useState<null | string>(null)
// Set when a job is opened from the sidebar so we scroll it into view once the
// row exists. Cleared after the scroll fires.
const pendingScrollRef = useRef<null | string>(null)
const focusJobId = useStore($cronFocusJobId)
const [editor, setEditor] = useState<EditorState>({ mode: 'closed' })
const [pendingDelete, setPendingDelete] = useState<CronJob | null>(null)
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
setRefreshing(true)
try {
setCronJobs(await getCronJobs())
const result = await getCronJobs()
setJobs(result)
} catch (err) {
notifyError(err, c.failedLoad)
} finally {
setLoading(false)
setRefreshing(false)
}
}, [c])
@@ -281,47 +295,16 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
void refresh()
}, [refresh])
// Sidebar → "open this job": resolve the focus id (or name) to a job, select
// it, queue a scroll, then clear the one-shot focus so re-opening cron
// normally doesn't re-trigger it.
useEffect(() => {
if (!focusJobId) {return}
const match = jobs.find(job => job.id === focusJobId || jobName(job) === focusJobId)
if (match) {
setSelectedJobId(match.id)
pendingScrollRef.current = match.id
const visibleJobs = useMemo(() => {
if (!jobs) {
return []
}
setCronFocusJobId(null)
}, [focusJobId, jobs])
return jobs.filter(job => matchesQuery(job, query.trim())).sort((a, b) => jobTitle(a).localeCompare(jobTitle(b)))
}, [jobs, query])
const visibleJobs = useMemo(
() => jobs.filter(job => matchesQuery(job, query.trim())).sort((a, b) => jobTitle(a).localeCompare(jobTitle(b))),
[jobs, query]
)
// Detail always reflects a concrete job: the explicitly selected one, else the
// first visible row, so the right pane is never empty while jobs exist.
const selectedJob = useMemo(
() => visibleJobs.find(job => job.id === selectedJobId) ?? visibleJobs[0] ?? null,
[visibleJobs, selectedJobId]
)
// Scroll a sidebar-opened job into view once its list row is mounted.
useEffect(() => {
const target = pendingScrollRef.current
if (!target || selectedJob?.id !== target) {return}
pendingScrollRef.current = null
requestAnimationFrame(() => {
document.querySelector(`[data-cron-row="${CSS.escape(target)}"]`)?.scrollIntoView({ block: 'nearest' })
})
}, [selectedJob])
const totalCount = jobs.length
const enabledCount = jobs?.filter(job => job.enabled).length ?? 0
const totalCount = jobs?.length ?? 0
async function handlePauseResume(job: CronJob) {
setBusyJobId(job.id)
@@ -329,7 +312,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
try {
const isPaused = jobState(job) === 'paused'
const updated = isPaused ? await resumeCronJob(job.id) : await pauseCronJob(job.id)
updateCronJobs(rows => rows.map(row => (row.id === job.id ? updated : row)))
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
notify({
kind: 'success',
title: isPaused ? c.resumed : c.paused,
@@ -347,7 +330,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
try {
const updated = await triggerCronJob(job.id)
updateCronJobs(rows => rows.map(row => (row.id === job.id ? updated : row)))
setJobs(current => (current ? current.map(row => (row.id === job.id ? updated : row)) : current))
notify({ kind: 'success', title: c.triggered, message: truncate(jobTitle(job), 60) })
} catch (err) {
notifyError(err, c.failedTrigger)
@@ -365,7 +348,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
try {
await deleteCronJob(pendingDelete.id)
updateCronJobs(rows => rows.filter(row => row.id !== pendingDelete.id))
setJobs(current => (current ? current.filter(row => row.id !== pendingDelete.id) : current))
notify({ kind: 'success', title: c.deleted, message: truncate(jobTitle(pendingDelete), 60) })
setPendingDelete(null)
} catch (err) {
@@ -384,7 +367,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
deliver: values.deliver || DEFAULT_DELIVER
})
updateCronJobs(rows => [...rows, created])
setJobs(current => (current ? [...current, created] : [created]))
notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) })
} else if (editor.mode === 'edit') {
const updated = await updateCronJob(editor.job.id, {
@@ -394,7 +377,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
deliver: values.deliver
})
updateCronJobs(rows => rows.map(row => (row.id === updated.id ? updated : row)))
setJobs(current => (current ? current.map(row => (row.id === updated.id ? updated : row)) : current))
notify({ kind: 'success', title: c.updated, message: truncate(jobTitle(updated), 60) })
}
@@ -403,62 +386,71 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
return (
<OverlayView closeLabel={c.close} onClose={onClose}>
{loading && jobs.length === 0 ? (
<PageLoader label={c.loading} />
) : (
<OverlaySplitLayout>
<OverlaySidebar>
<OverlayNewButton label={c.newCron} onClick={() => setEditor({ mode: 'create' })} />
{totalCount > 0 && (
<SearchField
aria-label={c.search}
containerClassName="mb-1 w-full px-2"
onChange={setQuery}
placeholder={c.search}
value={query}
/>
)}
{visibleJobs.map(job => (
<CronJobListRow
active={selectedJob?.id === job.id}
c={c}
job={job}
key={job.id}
onSelect={() => setSelectedJobId(job.id)}
/>
))}
{visibleJobs.length === 0 && (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">
{totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch}
</p>
)}
</OverlaySidebar>
<OverlayMain className="px-0">
{selectedJob ? (
<CronJobDetail
busy={busyJobId === selectedJob.id}
c={c}
job={selectedJob}
onDelete={() => setPendingDelete(selectedJob)}
onEdit={() => setEditor({ mode: 'edit', job: selectedJob })}
onOpenSession={onOpenSession}
onPauseResume={() => void handlePauseResume(selectedJob)}
onTrigger={() => void handleTrigger(selectedJob)}
/>
) : (
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
<div>
<Clock className="mx-auto size-6 text-muted-foreground/60" />
<p className="mt-3">{totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}</p>
</div>
</div>
)}
</OverlayMain>
</OverlaySplitLayout>
)}
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<PageSearchShell
{...props}
onSearchChange={setQuery}
searchPlaceholder={c.search}
searchTrailingAction={
<Button
aria-label={refreshing ? c.refreshing : c.refresh}
className="text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground"
disabled={refreshing}
onClick={() => void refresh()}
size="icon-xs"
title={refreshing ? c.refreshing : c.refresh}
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.875rem" spinning={refreshing} />
</Button>
}
searchValue={query}
>
{!jobs ? (
<PageLoader label={c.loading} />
) : visibleJobs.length === 0 ? (
// Empty state owns the primary "create" CTA — we used to also have
// one in the filters bar but it was redundant. Only show the button
// when there are zero jobs total; the search-empty case ("No
// matches") just asks the user to broaden their query.
<EmptyState
actionLabel={totalCount === 0 ? c.createFirst : undefined}
description={totalCount === 0 ? c.emptyDescNew : c.emptyDescSearch}
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
title={totalCount === 0 ? c.emptyTitleNew : c.emptyTitleSearch}
/>
) : (
<div className="h-full overflow-y-auto px-4 py-3">
{/* Inline header replaces the old top-bar "New cron" button. We
still need a single, always-visible affordance to add a job
when the list is non-empty (rows themselves only expose
edit/pause/trigger/delete). */}
<div className="mb-2 flex items-center justify-between">
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
{c.active(enabledCount, totalCount)}
</span>
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
<Codicon name="add" />
{c.newCron}
</Button>
</div>
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
{visibleJobs.map(job => (
<CronJobRow
busy={busyJobId === job.id}
c={c}
job={job}
key={job.id}
onDelete={() => setPendingDelete(job)}
onEdit={() => setEditor({ mode: 'edit', job })}
onPauseResume={() => void handlePauseResume(job)}
onTrigger={() => void handleTrigger(job)}
/>
))}
</div>
</div>
)}
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
<DialogContent className="max-w-md">
@@ -484,52 +476,17 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
</DialogFooter>
</DialogContent>
</Dialog>
</PageSearchShell>
</OverlayView>
)
}
function CronJobListRow({
active,
c,
job,
onSelect
}: {
active: boolean
c: Translations['cron']
job: CronJob
onSelect: () => void
}) {
const state = jobState(job)
return (
<button
className={cn(
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
)}
data-cron-row={job.id}
onClick={onSelect}
type="button"
>
<span className="flex w-full items-center gap-2">
<span
aria-hidden="true"
className={cn('size-1.5 shrink-0 rounded-full', STATE_DOT[state] ?? 'bg-muted-foreground')}
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{jobTitle(job)}</span>
</span>
<span className="truncate pl-3.5 text-[0.66rem] text-muted-foreground">{jobScheduleDisplay(job)}</span>
</button>
)
}
function CronJobDetail({
function CronJobRow({
busy,
c,
job,
onDelete,
onEdit,
onOpenSession,
onPauseResume,
onTrigger
}: {
@@ -538,172 +495,71 @@ function CronJobDetail({
job: CronJob
onDelete: () => void
onEdit: () => void
onOpenSession?: (sessionId: string) => void
onPauseResume: () => void
onTrigger: () => void
}) {
const state = jobState(job)
const isPaused = state === 'paused'
const deliver = jobDeliver(job)
const hasName = Boolean(jobName(job))
const prompt = jobPrompt(job)
const deliver = jobDeliver(job)
return (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl space-y-6 px-6 py-6">
<header className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-xl font-semibold tracking-tight">{jobTitle(job)}</h3>
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</StatePill>
{deliver && deliver !== DEFAULT_DELIVER && (
<StatePill tone="muted">{c.deliveryLabels[deliver] ?? deliver}</StatePill>
)}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.7rem] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<Clock className="size-3" />
{jobScheduleDisplay(job)}
</span>
<span>
{c.last} {formatTime(job.last_run_at)}
</span>
<span>
{c.next} {formatTime(job.next_run_at)}
</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button disabled={busy} onClick={onPauseResume} size="sm" variant="outline">
<Codicon name={isPaused ? 'play' : 'debug-pause'} size="0.875rem" />
{isPaused ? c.resumeTitle : c.pauseTitle}
</Button>
<Button disabled={busy} onClick={onTrigger} size="sm" variant="outline">
<Codicon name="zap" size="0.875rem" />
{c.triggerNow}
</Button>
<Button onClick={onEdit} size="sm" variant="outline">
<Codicon name="edit" size="0.875rem" />
{c.edit}
</Button>
<Button
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={onDelete}
size="sm"
variant="ghost"
>
<Codicon name="trash" size="0.875rem" />
</Button>
</div>
</div>
{prompt && <p className="line-clamp-3 text-xs text-muted-foreground">{prompt}</p>}
{job.last_error && (
<p className="inline-flex items-start gap-1 text-[0.7rem] text-destructive">
<AlertTriangle className="mt-px size-3 shrink-0" />
<span className="line-clamp-2">{job.last_error}</span>
</p>
)}
</header>
<CronJobRuns c={c} jobId={job.id} onOpenSession={onOpenSession} />
<div className="grid gap-3 px-3 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start">
<button
className="min-w-0 cursor-pointer rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
onClick={onEdit}
type="button"
>
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{jobTitle(job)}</span>
<StatePill tone={STATE_TONE[state] ?? 'muted'}>{c.states[state] ?? state}</StatePill>
{deliver && deliver !== DEFAULT_DELIVER && (
<StatePill tone="muted">{c.deliveryLabels[deliver] ?? deliver}</StatePill>
)}
</div>
{hasName && prompt && <p className="mt-1 truncate text-xs text-muted-foreground">{truncate(prompt, 120)}</p>}
<div className="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1 text-[0.68rem] text-muted-foreground">
<span className="inline-flex items-center gap-1 font-mono">
<Clock className="size-3" />
{jobScheduleDisplay(job)}
</span>
<span>
{c.last} {formatTime(job.last_run_at)}
</span>
<span>
{c.next} {formatTime(job.next_run_at)}
</span>
</div>
{job.last_error && (
<p className="mt-1 inline-flex items-start gap-1 text-[0.68rem] text-destructive">
<AlertTriangle className="mt-px size-3 shrink-0" />
<span className="line-clamp-2">{job.last_error}</span>
</p>
)}
</button>
<div className="flex shrink-0 items-center">
<CronJobActionsMenu
busy={busy}
isPaused={isPaused}
onDelete={onDelete}
onEdit={onEdit}
onPauseResume={onPauseResume}
onTrigger={onTrigger}
title={jobTitle(job)}
>
<CronJobActionsTrigger
className="text-muted-foreground hover:text-foreground"
onClick={event => event.stopPropagation()}
title={jobTitle(job)}
/>
</CronJobActionsMenu>
</div>
</div>
)
}
function formatRunTime(seconds?: null | number): string {
if (!seconds) {
return '—'
}
const date = new Date(seconds * 1000)
return Number.isNaN(date.valueOf()) ? '—' : date.toLocaleString()
}
// Runs are produced by the background scheduler tick (no UI signal), so poll
// while the panel is open + on tab re-focus so a fired run shows up within a few
// seconds instead of waiting for a reload.
const RUNS_POLL_INTERVAL_MS = 8000
function CronJobRuns({
c,
jobId,
onOpenSession
}: {
c: Translations['cron']
jobId: string
onOpenSession?: (sessionId: string) => void
}) {
const [runs, setRuns] = useState<null | SessionInfo[]>(null)
useEffect(() => {
let cancelled = false
const load = () =>
getCronJobRuns(jobId)
.then(result => {
if (!cancelled) {setRuns(result)}
})
.catch(() => {
if (!cancelled) {setRuns(prev => prev ?? [])}
})
void load()
const intervalId = window.setInterval(() => {
if (document.visibilityState === 'visible') {void load()}
}, RUNS_POLL_INTERVAL_MS)
const onVisible = () => {
if (document.visibilityState === 'visible') {void load()}
}
document.addEventListener('visibilitychange', onVisible)
return () => {
cancelled = true
window.clearInterval(intervalId)
document.removeEventListener('visibilitychange', onVisible)
}
}, [jobId])
return (
<div>
<div className="mb-1.5 text-[0.62rem] font-medium uppercase tracking-wide text-muted-foreground">
{c.runHistory}
{runs && runs.length > 0 ? ` · ${runs.length}` : ''}
</div>
{runs === null ? (
<div className="flex items-center gap-1.5 py-1 text-xs text-muted-foreground">
<Codicon name="loading" size="0.75rem" spinning />
</div>
) : runs.length === 0 ? (
<div className="py-1 text-xs text-muted-foreground">{c.noRuns}</div>
) : (
<div className="flex flex-col gap-px">
{runs.map(run => (
<button
className="flex items-center justify-between gap-3 rounded-md px-2 py-1 text-left text-xs hover:bg-(--chrome-action-hover) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
key={run.id}
onClick={() => onOpenSession?.(run.id)}
type="button"
>
<span className="truncate text-foreground">{run.title?.trim() || run.preview?.trim() || run.id}</span>
<span className="shrink-0 text-[0.62rem] text-muted-foreground tabular-nums">
{formatRunTime(run.last_active || run.started_at)}
</span>
</button>
))}
</div>
)}
</div>
)
}
function StatePill({ children, tone }: { children: string; tone: keyof typeof PILL_TONE }) {
return (
<span
@@ -714,6 +570,33 @@ function StatePill({ children, tone }: { children: string; tone: keyof typeof PI
)
}
function EmptyState({
actionLabel,
description,
onAction,
title
}: {
actionLabel?: string
description: string
onAction?: () => void
title: string
}) {
return (
<div className="grid h-full place-items-center px-6 py-12 text-center">
<div className="max-w-sm space-y-2">
<div className="text-sm font-medium">{title}</div>
<p className="text-xs text-muted-foreground">{description}</p>
{actionLabel && onAction && (
<Button className="mt-2" onClick={onAction} size="sm">
<Codicon name="add" />
{actionLabel}
</Button>
)}
</div>
</div>
)
}
function CronEditorDialog({
editor,
onClose,
@@ -870,7 +753,7 @@ function CronEditorDialog({
<FieldHint>{c.customHint}</FieldHint>
</Field>
) : (
<div className="rounded-md bg-(--ui-bg-quinary) px-3 py-2">
<div className="rounded-md border border-border/60 bg-muted/30 px-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
<span className="font-medium text-foreground">{scheduleHint}</span>
<span className="font-mono text-muted-foreground">{schedule}</span>
@@ -879,7 +762,7 @@ function CronEditorDialog({
)}
{error && (
<div className="flex items-start gap-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>

View File

@@ -1,29 +0,0 @@
import type { CronJob } from '@/types/hermes'
// Status-pip color per cron job state. Single source for the sidebar section and
// the Cron page so the two never drift. (Animation/size live at the call site.)
export const STATE_DOT: Record<string, string> = {
completed: 'bg-(--ui-text-quaternary)',
disabled: 'bg-(--ui-text-quaternary)',
enabled: 'bg-primary',
error: 'bg-destructive',
paused: 'bg-amber-500',
running: 'bg-primary',
scheduled: 'bg-primary'
}
// Effective state: explicit state wins; otherwise infer from the enabled flag.
export function jobState(job: CronJob): string {
const state = typeof job.state === 'string' ? job.state.trim() : ''
return state || (job.enabled === false ? 'disabled' : 'scheduled')
}
// Human label for a job: name → first 60 of prompt → first 60 of script → id.
// One source for the sidebar row and the Cron page so the two never drift.
export function jobTitle(job: CronJob): string {
const pick = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
const clip = (v: string) => (v.length > 60 ? `${v.slice(0, 60)}` : v)
return pick(job.name) || clip(pick(job.prompt)) || clip(pick(job.script)) || job.id || 'Cron job'
}

View File

@@ -8,13 +8,11 @@ import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
import { DesktopOnboardingOverlay } from '@/components/desktop-onboarding-overlay'
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
import { Pane, PaneMain } from '@/components/pane-shell'
import { useMediaQuery } from '@/hooks/use-media-query'
import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
import { getSessionMessages, listAllProfileSessions, type SessionInfo } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import { setCronFocusJobId, setCronJobs } from '../store/cron'
import {
$panesFlipped,
$pinnedSessionIds,
@@ -24,21 +22,13 @@ import {
FILE_BROWSER_MAX_WIDTH,
FILE_BROWSER_MIN_WIDTH,
pinSession,
setSidebarOverlayMounted,
SIDEBAR_DEFAULT_WIDTH,
SIDEBAR_MAX_WIDTH,
SIDEBAR_SESSIONS_PAGE_SIZE,
unpinSession
} from '../store/layout'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '../store/preview'
import {
$activeGatewayProfile,
$freshSessionRequest,
$profileScope,
ALL_PROFILES,
normalizeProfileKey,
refreshActiveProfile
} from '../store/profile'
import { $activeGatewayProfile, $freshSessionRequest, normalizeProfileKey, refreshActiveProfile } from '../store/profile'
import {
$activeSessionId,
$currentCwd,
@@ -47,13 +37,10 @@ import {
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
CRON_SECTION_LIMIT,
getRecentlySettledSessionIds,
mergeSessionPage,
sessionPinId,
setAwaitingResponse,
setBusy,
setCronSessions,
setCurrentBranch,
setCurrentCwd,
setCurrentModel,
@@ -79,13 +66,12 @@ import { CommandPalette } from './command-palette'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { useKeybinds } from './hooks/use-keybinds'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
import { RightSidebarPane } from './right-sidebar'
import { $terminalTakeover } from './right-sidebar/store'
import { PersistentTerminal, TerminalSlot } from './right-sidebar/terminal/persistent'
import { CRON_ROUTE, NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
import { NEW_CHAT_ROUTE, routeSessionId, sessionRoute, SETTINGS_ROUTE } from './routes'
import { useContextSuggestions } from './session/hooks/use-context-suggestions'
import { useCwdActions } from './session/hooks/use-cwd-actions'
import { useHermesConfig } from './session/hooks/use-hermes-config'
@@ -115,34 +101,13 @@ const ProfilesView = lazy(async () => ({ default: (await import('./profiles')).P
const SettingsView = lazy(async () => ({ default: (await import('./settings')).SettingsView }))
const SkillsView = lazy(async () => ({ default: (await import('./skills')).SkillsView }))
// Latest cron-job sessions surfaced in the collapsed "Cron jobs" section. The
// Cron sessions are written by a background scheduler tick (the desktop
// backend), so no user action signals the UI. Poll the bounded cron list on
// this cadence while the app is open + visible so new runs surface promptly
// instead of waiting for the next user-triggered refreshSessions().
const CRON_POLL_INTERVAL_MS = 30_000
// Cheap signature compare so the poll only swaps the atom (and re-renders the
// sidebar) when the visible cron rows actually changed.
function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
if (a.length !== b.length) {return false}
return a.every((session, i) => session.id === b[i]?.id && session.title === b[i]?.title)
}
// Rows a session refresh must preserve even if the aggregator omits them:
// in-flight first turns (message_count 0), pinned rows aged off the page, the
// actively-viewed chat (its "working" flag clears a beat before the aggregator
// sees the persisted row), and sessions whose turn just settled (same race, but
// for a chat the user has already navigated away from). Pass `scope` to only
// keep the active row when it belongs to the profile being paged.
// in-flight first turns (message_count 0), pinned rows aged off the page, and
// the actively-viewed chat (its "working" flag clears a beat before the
// aggregator sees the persisted row). Pass `scope` to only keep the active row
// when it belongs to the profile being paged.
function sessionsToKeep(scope?: string): Set<string> {
const keep = new Set<string>([
...$workingSessionIds.get(),
...$pinnedSessionIds.get(),
...getRecentlySettledSessionIds()
])
const keep = new Set<string>([...$workingSessionIds.get(), ...$pinnedSessionIds.get()])
const active = $selectedStoredSessionId.get()
if (active) {
@@ -174,11 +139,6 @@ export function DesktopController() {
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const terminalTakeover = useStore($terminalTakeover)
const panesFlipped = useStore($panesFlipped)
const profileScope = useStore($profileScope)
// Below SIDEBAR_COLLAPSE_BREAKPOINT_PX there's no room for a docked rail —
// collapse both sidebars (without touching their stored open state) so the
// hover-reveal overlay becomes the way in. Restores once it's wide again.
const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)
const routedSessionId = routeSessionId(location.pathname)
const routeToken = `${location.pathname}:${location.search}:${location.hash}`
@@ -264,36 +224,6 @@ export function DesktopController() {
}
}, [])
// Cron-job sessions as their own list (latest N). Independent of the recents
// page so the two never compete for slots. Cheap + bounded. Kept (even though
// the sidebar now lists cron *jobs*, not run sessions) so a pinned cron run
// still resolves into the Pinned section via sessionByAnyId.
const refreshCronSessions = useCallback(async () => {
try {
const { sessions } = await listAllProfileSessions(CRON_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
source: 'cron'
})
setCronSessions(prev => (sameCronSignature(prev, sessions) ? prev : sessions))
} catch {
// Non-fatal: the cron section just stays empty/stale.
}
}, [])
// Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created
// synchronously (agent tool call or the cron UI), so refreshing here right
// after an agent turn surfaces a new job immediately; the interval poll keeps
// next-run/state fresh as the scheduler advances them.
const refreshCronJobs = useCallback(async () => {
try {
const jobs = await getCronJobs()
setCronJobs(jobs)
} catch {
// Non-fatal: the cron section just keeps its last-known jobs.
}
}, [])
const refreshSessions = useCallback(async () => {
const requestId = refreshSessionsRequestRef.current + 1
refreshSessionsRequestRef.current = requestId
@@ -301,23 +231,13 @@ export function DesktopController() {
try {
const limit = $sessionsLimit.get()
// Require at least one message so abandoned/empty "Untitled" drafts (one
// was created per TUI/desktop launch before the lazy-create fix) don't
// clutter the sidebar.
// Unified cross-profile list (served read-only off each profile's
// state.db; no per-profile backend is spawned). Single-profile users get
// the same rows tagged profile="default". Cron sessions are excluded here
// and fetched separately (refreshCronSessions) so the scheduler's
// always-newest rows can't consume the recents page budget.
// Scope the fetch to the active profile (not always 'all') so a profile
// with few recent sessions isn't windowed out of the cross-profile
// recency page — the empty-history-on-profile-switch bug.
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
excludeSources: ['cron']
})
// the same rows tagged profile="default".
const result = await listAllProfileSessions(limit, 1)
if (refreshSessionsRequestRef.current === requestId) {
setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep()))
@@ -329,10 +249,7 @@ export function DesktopController() {
setSessionsLoading(false)
}
}
void refreshCronSessions()
void refreshCronJobs()
}, [profileScope, refreshCronSessions, refreshCronJobs])
}, [])
const loadMoreSessions = useCallback(() => {
bumpSessionsLimit()
@@ -345,11 +262,7 @@ export function DesktopController() {
const key = normalizeProfileKey(profile)
const inKey = (s: SessionInfo) => normalizeProfileKey(s.profile) === key
const loaded = $sessions.get().filter(inKey).length
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {
excludeSources: ['cron']
})
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key)
const keep = sessionsToKeep(key)
setSessions(prev => [...prev.filter(s => !inKey(s)), ...mergeSessionPage(prev.filter(inKey), result.sessions, keep)])
@@ -647,25 +560,6 @@ export function DesktopController() {
}
}, [gatewayState, refreshCurrentModel, refreshSessions])
// Keep the cron jobs section live without a user action: the scheduler ticks
// in the background (advancing next-run/state and creating runs), so poll the
// job list on an interval (and on tab re-focus) while connected.
useEffect(() => {
if (gatewayState !== 'open') {return}
const tick = () => {
if (document.visibilityState === 'visible') {void refreshCronJobs()}
}
const intervalId = window.setInterval(tick, CRON_POLL_INTERVAL_MS)
document.addEventListener('visibilitychange', tick)
return () => {
window.clearInterval(intervalId)
document.removeEventListener('visibilitychange', tick)
}
}, [gatewayState, refreshCronJobs])
useRouteResume({
activeSessionId,
activeSessionIdRef,
@@ -706,18 +600,9 @@ export function DesktopController() {
onDeleteSession={sessionId => void removeSession(sessionId)}
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
onLoadMoreSessions={loadMoreSessions}
onManageCronJob={jobId => {
setCronFocusJobId(jobId)
navigate(CRON_ROUTE)
}}
onNavigate={selectSidebarItem}
onNewSessionInWorkspace={startSessionInWorkspace}
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
onTriggerCronJob={jobId => {
void triggerCronJob(jobId)
.then(() => refreshCronJobs())
.catch(() => undefined)
}}
/>
)
@@ -784,10 +669,7 @@ export function DesktopController() {
{cronOpen && (
<Suspense fallback={null}>
<CronView
onClose={closeOverlayToPreviousRoute}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
<CronView onClose={closeOverlayToPreviousRoute} />
</Suspense>
)}
@@ -861,8 +743,6 @@ export function DesktopController() {
<Pane
defaultOpen={false}
disabled={!chatOpen}
forceCollapsed={narrowViewport}
hoverReveal
id="file-browser"
key="file-browser"
maxWidth={FILE_BROWSER_MAX_WIDTH}
@@ -890,12 +770,9 @@ export function DesktopController() {
>
<Pane
disabled={terminalTakeoverActive}
forceCollapsed={narrowViewport}
hoverReveal
id="chat-sidebar"
maxWidth={SIDEBAR_MAX_WIDTH}
minWidth={SIDEBAR_DEFAULT_WIDTH}
onOverlayActiveChange={setSidebarOverlayMounted}
resizable
side={sidebarSide}
width={`${SIDEBAR_DEFAULT_WIDTH}px`}

View File

@@ -120,13 +120,6 @@ export function useGatewayBoot({
reconnecting = true
try {
// Drop a stale REMOTE backend cache before re-dialing. After sleep/wake a
// remote backend can become unreachable, but it has no child process
// whose 'exit' would clear the main process's cached descriptor — without
// this the renderer re-dials the same dead endpoint forever and stays on
// "Starting Hermes…". The probe is a no-op for a healthy or local backend.
await desktop.revalidateConnection?.().catch(() => undefined)
const conn = await desktop.getConnection($activeGatewayProfile.get())
if (cancelled) {
@@ -225,15 +218,6 @@ export function useGatewayBoot({
reconnectAttempt = 0
reauthNotified = false
clearReconnectTimer()
// A revalidate-driven reconnect can rebuild the backend in place when the
// cached remote was found dead, which re-drives the boot-progress overlay.
// Unlike the initial boot, nothing calls completeDesktopBoot() afterwards,
// so dismiss it here once we're open again — otherwise the overlay sticks
// at ~94%. A no-op on a normal (non-rebuild) reconnect.
if (bootCompleted) {
completeDesktopBoot()
}
} else if (bootCompleted && (st === 'closed' || st === 'error')) {
// The socket dropped after a healthy boot (typically sleep/wake). Try
// to bring it back instead of leaving the composer stuck disabled.

View File

@@ -2,15 +2,11 @@ import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { setRightSidebarTab } from '@/app/right-sidebar/store'
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
import { matchesQuery } from '@/hooks/use-media-query'
import { PROFILE_SLOT_COUNT } from '@/lib/keybinds/actions'
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
import { toggleCommandPalette } from '@/store/command-palette'
import { $capture, $comboIndex, endCapture, setBinding, toggleKeybindPanel } from '@/store/keybinds'
import {
CHAT_SIDEBAR_PANE_ID,
FILE_BROWSER_PANE_ID,
requestSessionSearchFocus,
setFileBrowserOpen,
toggleFileBrowserOpen,
@@ -28,7 +24,6 @@ import { $activeSessionId, $sessions, setModelPickerOpen } from '@/store/session
import { useTheme } from '@/themes/context'
import { requestComposerFocus } from '../chat/composer/focus'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
import {
AGENTS_ROUTE,
ARTIFACTS_ROUTE,
@@ -114,20 +109,8 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'session.focusSearch': requestSessionSearchFocus,
'session.togglePin': deps.toggleSelectedPin,
'view.toggleSidebar': () => {
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: CHAT_SIDEBAR_PANE_ID } }))
} else {
toggleSidebarOpen()
}
},
'view.toggleRightSidebar': () => {
if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: FILE_BROWSER_PANE_ID } }))
} else {
toggleFileBrowserOpen()
}
},
'view.toggleSidebar': toggleSidebarOpen,
'view.toggleRightSidebar': toggleFileBrowserOpen,
'view.showFiles': () => showRightSidebarTab('files'),
'view.showTerminal': () => showRightSidebarTab('terminal'),
'view.flipPanes': togglePanesFlipped,

View File

@@ -11,9 +11,3 @@ export const PAGE_INSET_X = 'px-[clamp(1.25rem,4vw,4rem)]'
// Matching negative inline-margin to bleed an element (e.g. a sticky header bar)
// out to the gutter edges before re-applying PAGE_INSET_X.
export const PAGE_INSET_NEG_X = '-mx-[clamp(1.25rem,4vw,4rem)]'
// Below this viewport width a docked sidebar leaves no room for content, so both
// rails auto-collapse into the hover-reveal overlay. Single source of truth for
// the responsive collapse point.
export const SIDEBAR_COLLAPSE_BREAKPOINT_PX = 768
export const SIDEBAR_COLLAPSE_MEDIA_QUERY = `(max-width: ${SIDEBAR_COLLAPSE_BREAKPOINT_PX}px)`

View File

@@ -449,7 +449,7 @@ function PlatformDetail({
{hiddenCount > 0 && (
<section>
<button
className="flex w-full items-center justify-between gap-2 py-0.5 text-left text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground transition-colors hover:text-foreground"
className="flex w-full items-center justify-between gap-2 rounded-lg px-1 py-1 text-left text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground hover:text-foreground"
onClick={() => setShowAdvanced(value => !value)}
type="button"
>
@@ -477,13 +477,17 @@ function PlatformDetail({
<footer className="bg-(--ui-chat-surface-background) px-5 py-2.5">
<div className="mx-auto flex max-w-2xl flex-wrap items-center gap-2">
<Switch
aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
size="xs"
/>
<label className="flex shrink-0 items-center gap-2 rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2.5 py-1.5 text-[length:var(--conversation-text-font-size)]">
<Switch
aria-label={platform.enabled ? m.disableAria(platform.name) : m.enableAria(platform.name)}
checked={platform.enabled}
disabled={saving === `enabled:${platform.id}`}
onCheckedChange={onToggle}
/>
<span className="text-xs font-medium text-muted-foreground">
{platform.enabled ? m.enabled : m.disabled}
</span>
</label>
<div className="ml-auto flex items-center gap-2">
{hasEdits && <span className="text-xs text-muted-foreground">{m.unsavedChanges}</span>}

View File

@@ -28,17 +28,15 @@ import { cn } from '@/lib/utils'
type IconKind = 'brand' | 'generic'
interface PlatformIconSpec {
Icon?: ComponentType<SVGProps<SVGSVGElement>>
Icon: ComponentType<SVGProps<SVGSVGElement>>
color: string
kind: IconKind
monogram?: string
}
const PLATFORM_ICONS: Record<string, PlatformIconSpec> = {
telegram: { Icon: SiTelegram, color: '#26A5E4', kind: 'brand' },
discord: { Icon: SiDiscord, color: '#5865F2', kind: 'brand' },
// Slack removed from Simple Icons by Salesforce request — letter monogram.
slack: { color: '#4A154B', kind: 'brand', monogram: 'S' },
mattermost: { Icon: SiMattermost, color: '#0058CC', kind: 'brand' },
matrix: { Icon: SiMatrix, color: '#000000', kind: 'brand' },
signal: { Icon: SiSignal, color: '#3A76F0', kind: 'brand' },
@@ -89,7 +87,7 @@ export function PlatformAvatar({ className, platformId, platformName }: Platform
color
}}
>
{Icon ? <Icon className="size-3.5" /> : spec.monogram || platformName.charAt(0).toUpperCase()}
<Icon className="size-3.5" />
</span>
)
}

View File

@@ -1,6 +1,7 @@
import type { RefObject } from 'react'
import { SearchField } from '@/components/ui/search-field'
import { cn } from '@/lib/utils'
interface OverlaySearchInputProps {
containerClassName?: string
@@ -11,7 +12,6 @@ interface OverlaySearchInputProps {
value: string
}
// Borderless underline search — matches the tools/skills page (PageSearchShell).
export function OverlaySearchInput({
containerClassName,
inputRef,
@@ -22,7 +22,11 @@ export function OverlaySearchInput({
}: OverlaySearchInputProps) {
return (
<SearchField
containerClassName={containerClassName}
containerClassName={cn(
'rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2 shadow-sm focus-within:border-(--ui-stroke-secondary)',
containerClassName
)}
inputClassName="h-8 text-[0.8125rem]"
inputRef={inputRef}
loading={loading}
onChange={onChange}

View File

@@ -1,7 +1,5 @@
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import type { IconComponent } from '@/lib/icons'
import { cn } from '@/lib/utils'
@@ -75,31 +73,6 @@ export function OverlayMain({ children, className }: OverlayMainProps) {
)
}
// Boxless "+ New …" action that tops an OverlaySidebar list (profiles, cron, …).
// The text variant underlines on hover, which also strokes the icon glyph — so
// we keep the button itself underline-free and underline only the label span.
export function OverlayNewButton({
icon = 'add',
label,
onClick
}: {
icon?: string
label: string
onClick: () => void
}) {
return (
<Button
className="group mb-1 w-full justify-start gap-2 text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={onClick}
size="sm"
variant="ghost"
>
<Codicon name={icon} />
<span className="underline-offset-4 group-hover:underline">{label}</span>
</Button>
)
}
export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) {
return (
<button

View File

@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import {
Dialog,
DialogContent,
@@ -29,8 +30,10 @@ import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { OverlayMain, OverlayNewButton, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
import { titlebarHeaderBaseClass } from '../shell/titlebar'
import type { SetTitlebarToolGroup } from '../shell/titlebar-controls'
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
@@ -38,20 +41,30 @@ function isValidProfileName(name: string): boolean {
return PROFILE_NAME_RE.test(name.trim())
}
interface ProfilesViewProps {
interface ProfilesViewProps extends React.ComponentProps<'section'> {
onClose: () => void
setStatusbarItemGroup?: SetStatusbarItemGroup
setTitlebarToolGroup?: SetTitlebarToolGroup
}
export function ProfilesView({ onClose }: ProfilesViewProps) {
export function ProfilesView({
onClose,
setStatusbarItemGroup: _setStatusbarItemGroup,
setTitlebarToolGroup,
...props
}: ProfilesViewProps) {
const { t } = useI18n()
const p = t.profiles
const [profiles, setProfiles] = useState<null | ProfileInfo[]>(null)
const [refreshing, setRefreshing] = useState(false)
const [selectedName, setSelectedName] = useState<null | string>(null)
const [createOpen, setCreateOpen] = useState(false)
const [pendingDelete, setPendingDelete] = useState<null | ProfileInfo>(null)
const [deleting, setDeleting] = useState(false)
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const { profiles: list } = await getProfiles()
setProfiles(list)
@@ -64,6 +77,8 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
})
} catch (err) {
notifyError(err, p.failedLoad)
} finally {
setRefreshing(false)
}
}, [p])
@@ -73,6 +88,24 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
void refresh()
}, [refresh])
useEffect(() => {
if (!setTitlebarToolGroup) {
return
}
setTitlebarToolGroup('profiles', [
{
disabled: refreshing,
icon: <Codicon name="refresh" spinning={refreshing} />,
id: 'refresh-profiles',
label: refreshing ? p.refreshing : p.refresh,
onSelect: () => void refresh()
}
])
return () => setTitlebarToolGroup('profiles', [])
}, [p, refresh, refreshing, setTitlebarToolGroup])
const selected = useMemo(() => {
if (!profiles) {
return null
@@ -139,46 +172,64 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
return (
<OverlayView closeLabel={p.close} onClose={onClose}>
{!profiles ? (
<PageLoader label={p.loading} />
) : (
<OverlaySplitLayout>
<OverlaySidebar>
<OverlayNewButton label={p.newProfile} onClick={() => setCreateOpen(true)} />
{profiles.map(profile => (
<ProfileRow
active={selected?.name === profile.name}
key={profile.name}
onSelect={() => setSelectedName(profile.name)}
profile={profile}
/>
))}
{profiles.length === 0 && (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">{p.noProfiles}</p>
)}
</OverlaySidebar>
<section {...props} className="flex h-full min-w-0 flex-col overflow-hidden rounded-b-[0.9375rem] bg-background">
<header className={titlebarHeaderBaseClass}>
<h2 className="pointer-events-auto text-base font-semibold leading-none tracking-tight">{p.title}</h2>
<span className="pointer-events-auto text-xs text-muted-foreground">
{profiles ? p.count(profiles.length) : ''}
</span>
</header>
<OverlayMain className="px-0">
{selected ? (
<ProfileDetail
key={selected.name}
onDelete={() => setPendingDelete(selected)}
onRename={newName => handleRename(selected.name, newName)}
profile={selected}
/>
) : (
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
<div>
<Users className="mx-auto size-6 text-muted-foreground/60" />
<p className="mt-3">{p.selectPrompt}</p>
<div className="min-h-0 flex-1 overflow-hidden rounded-b-[1.0625rem] border border-border/50 bg-background/85">
{!profiles ? (
<PageLoader label={p.loading} />
) : (
<div className="grid h-full min-h-0 grid-cols-1 lg:grid-cols-[16rem_minmax(0,1fr)]">
<aside className="flex min-h-0 flex-col overflow-hidden border-b border-border/50 lg:border-b-0 lg:border-r">
<div className="border-b border-border/40 p-2">
<Button className="w-full" onClick={() => setCreateOpen(true)} size="sm">
<Codicon name="add" />
{p.newProfile}
</Button>
</div>
</div>
)}
</OverlayMain>
</OverlaySplitLayout>
)}
<ul className="min-h-0 flex-1 space-y-1 overflow-y-auto p-2">
{profiles.map(profile => (
<li key={profile.name}>
<ProfileRow
active={selected?.name === profile.name}
onSelect={() => setSelectedName(profile.name)}
profile={profile}
/>
</li>
))}
{profiles.length === 0 && (
<li className="px-2 py-4 text-center text-xs text-muted-foreground">{p.noProfiles}</li>
)}
</ul>
</aside>
<CreateProfileDialog
<main className="min-h-0 overflow-hidden">
{selected ? (
<ProfileDetail
key={selected.name}
onDelete={() => setPendingDelete(selected)}
onRename={newName => handleRename(selected.name, newName)}
profile={selected}
/>
) : (
<div className="grid h-full place-items-center px-6 py-12 text-center text-sm text-muted-foreground">
<div>
<Users className="mx-auto size-6 text-muted-foreground/60" />
<p className="mt-3">{p.selectPrompt}</p>
</div>
</div>
)}
</main>
</div>
)}
</div>
<CreateProfileDialog
onClose={() => setCreateOpen(false)}
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
open={createOpen}
@@ -210,6 +261,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
</DialogFooter>
</DialogContent>
</Dialog>
</section>
</OverlayView>
)
}
@@ -221,7 +273,7 @@ function ProfileRow({ active, onSelect, profile }: { active: boolean; onSelect:
return (
<button
className={cn(
'flex w-full flex-col items-start gap-0.5 rounded-md px-2 py-1.5 text-left transition-colors',
'flex w-full flex-col items-start gap-1 rounded-lg px-2.5 py-2 text-left transition-colors',
active ? 'bg-accent text-foreground' : 'text-foreground/85 hover:bg-accent/60'
)}
onClick={onSelect}
@@ -316,7 +368,7 @@ function ProfileDetail({
</div>
</div>
<dl className="grid gap-2 text-xs sm:grid-cols-2">
<dl className="grid gap-2 rounded-lg border border-border/40 bg-background/70 px-3 py-3 text-xs sm:grid-cols-2">
<DetailRow label={p.modelLabel}>
{profile.model ? (
<>
@@ -423,7 +475,9 @@ function SoulEditor({ profileName }: { profileName: string }) {
</div>
{loading ? (
<PageLoader className="min-h-44" label={p.loadingSoul} />
<div className="grid h-44 place-items-center rounded-md border border-border/40 bg-background/60 text-xs text-muted-foreground">
{p.loadingSoul}
</div>
) : (
<Textarea
className="min-h-72 font-mono text-xs leading-5"

View File

@@ -14,7 +14,6 @@ import {
upsertToolPart
} from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
import { triggerHaptic } from '@/lib/haptics'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { setClarifyRequest } from '@/store/clarify'
@@ -452,8 +451,7 @@ export function useMessageStream({
busy: false,
needsInput: false,
pendingBranchGroup: null,
streamId: null,
turnStartedAt: null
streamId: null
}
}
@@ -543,8 +541,7 @@ export function useMessageStream({
pendingBranchGroup: null,
awaitingResponse: false,
busy: false,
needsInput: false,
turnStartedAt: null
needsInput: false
}
})
@@ -602,8 +599,7 @@ export function useMessageStream({
sawAssistantPayload: true,
awaitingResponse: false,
busy: false,
needsInput: false,
turnStartedAt: null
needsInput: false
}
})
},
@@ -614,9 +610,6 @@ export function useMessageStream({
(event: RpcEvent) => {
const payload = event.payload as GatewayEventPayload | undefined
const explicitSid = event.session_id || ''
if (!explicitSid && gatewayEventRequiresSessionId(event.type)) {
return
}
const sessionId = explicitSid || activeSessionIdRef.current
const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current
@@ -690,8 +683,7 @@ export function useMessageStream({
if (busy) {
return {
...state,
busy,
turnStartedAt: state.turnStartedAt ?? Date.now()
busy
}
}
@@ -704,8 +696,7 @@ export function useMessageStream({
awaitingResponse: false,
busy,
pendingBranchGroup: null,
streamId: null,
turnStartedAt: null
streamId: null
}
})
}
@@ -744,8 +735,7 @@ export function useMessageStream({
busy: true,
awaitingResponse: true,
sawAssistantPayload: false,
interrupted: false,
turnStartedAt: Date.now()
interrupted: false
}))
if (isActiveEvent) {

View File

@@ -34,7 +34,6 @@ import { requestDesktopOnboarding } from '@/store/onboarding'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import {
$busy,
$connection,
$messages,
$yoloActive,
setAwaitingResponse,
@@ -81,28 +80,6 @@ function inlineErrorMessage(error: unknown, fallback: string): string {
return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim()
}
function base64FromDataUrl(dataUrl: string): string {
const comma = dataUrl.indexOf(',')
return comma >= 0 ? dataUrl.slice(comma + 1) : ''
}
function imageFilenameFromPath(filePath: string): string {
return filePath.split(/[\\/]/).filter(Boolean).pop() || 'image.png'
}
// Remote gateway: the local composer-image file lives on THIS machine's disk,
// not the gateway's, so read the bytes here and upload them via
// image.attach_bytes. Returns null when the file can't be read.
async function readImageForRemoteAttach(
filePath: string
): Promise<{ contentBase64: string; filename: string } | null> {
const dataUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
const contentBase64 = dataUrl ? base64FromDataUrl(dataUrl) : ''
return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
}
interface PromptActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
@@ -220,36 +197,16 @@ export function usePromptActions({
) => {
const updateComposerAttachments = options.updateComposerAttachments ?? true
const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path)
const remote = $connection.get()?.mode === 'remote'
for (const attachment of images) {
if (attachment.attachedSessionId === sessionId) {
continue
}
let result: ImageAttachResponse
if (remote) {
// The gateway is on another machine — it can't read attachment.path
// (a path on THIS disk). Upload the bytes via image.attach_bytes.
const payload = attachment.path ? await readImageForRemoteAttach(attachment.path) : null
if (!payload) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
throw new Error(`Could not read ${label}`)
}
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
session_id: sessionId,
content_base64: payload.contentBase64,
filename: payload.filename
})
} else {
result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
}
const result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
if (!result.attached) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')

View File

@@ -1,118 +0,0 @@
import { act, cleanup, render } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $turnStartedAt, setTurnStartedAt } from '@/store/session'
import { useSessionStateCache } from './use-session-state-cache'
type Cache = ReturnType<typeof useSessionStateCache>
interface HarnessProps {
activeSessionId: string | null
onReady: (cache: Cache) => void
selectedStoredSessionId: string | null
}
function Harness({ activeSessionId, onReady, selectedStoredSessionId }: HarnessProps) {
const busyRef: MutableRefObject<boolean> = { current: false }
const cache = useSessionStateCache({
activeSessionId,
busyRef,
selectedStoredSessionId,
setAwaitingResponse: () => undefined,
setBusy: () => undefined,
setMessages: () => undefined
})
onReady(cache)
return null
}
describe('useSessionStateCache — per-session turn timer', () => {
beforeEach(() => {
// The view-sync flush runs on a real rAF in the browser path; in jsdom we
// want it synchronous so the global mirror is observable immediately. The
// hook closes over `window.requestAnimationFrame`, so stub that exact ref.
// Return null (not a handle) so the hook's `viewSyncRafRef.current = rAF(...)`
// assignment doesn't overwrite the null the synchronous callback just set —
// otherwise the ref reads truthy and the NEXT sync is suppressed (a real
// browser returns a handle but runs the callback async, so this race is a
// test-only artifact of firing synchronously).
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => {
cb(0)
return null as unknown as number
})
setTurnStartedAt(null)
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
setTurnStartedAt(null)
})
it("keeps a background session's running turn clock and never mirrors it to the view", () => {
let cache!: Cache
// Active session is "fg-runtime"; the turn starts on the BACKGROUND session.
render(
<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />
)
const startedAt = 1_700_000_000_000
act(() => {
cache.updateSessionState(
'bg-runtime',
state => ({ ...state, busy: true, turnStartedAt: startedAt }),
'bg-stored'
)
})
// The background session's own cache entry holds the clock...
expect(cache.sessionStateByRuntimeIdRef.current.get('bg-runtime')?.turnStartedAt).toBe(startedAt)
// ...but the global atom (statusbar timer) is untouched — a background turn
// must not drive the foreground timer.
expect($turnStartedAt.get()).toBeNull()
})
it("mirrors the focused session's turn clock into the global atom on view-sync", () => {
let cache!: Cache
render(<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />)
const startedAt = 1_700_000_111_000
// A turn on the ACTIVE session stages into the view; the flush mirrors its
// turnStartedAt into the global atom the statusbar reads.
act(() => {
cache.updateSessionState(
'fg-runtime',
state => ({ ...state, busy: true, turnStartedAt: startedAt }),
'fg-stored'
)
})
expect($turnStartedAt.get()).toBe(startedAt)
})
it('clears the global clock when the focused turn ends', () => {
let cache!: Cache
render(<Harness activeSessionId="fg-runtime" onReady={c => (cache = c)} selectedStoredSessionId="fg-stored" />)
act(() => {
cache.updateSessionState(
'fg-runtime',
state => ({ ...state, busy: true, turnStartedAt: 1_700_000_222_000 }),
'fg-stored'
)
})
expect($turnStartedAt.get()).toBe(1_700_000_222_000)
act(() => {
cache.updateSessionState('fg-runtime', state => ({ ...state, busy: false, turnStartedAt: null }))
})
expect($turnStartedAt.get()).toBeNull()
})
})

View File

@@ -5,32 +5,10 @@ import type { ChatMessage } from '@/lib/chat-messages'
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { setMutableRef } from '@/lib/mutable-ref'
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking, setTurnStartedAt } from '@/store/session'
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking } from '@/store/session'
import type { ClientSessionState } from '../../types'
// Shallow per-message identity check. When a flush carries no transcript
// changes, `preserveLocalAssistantErrors` returns the same message objects in
// the same order, so reference equality per slot is enough to detect "nothing
// to publish" and avoid a needless `$messages` churn.
function sameMessageList(a: ChatMessage[], b: ChatMessage[]): boolean {
if (a === b) {
return true
}
if (a.length !== b.length) {
return false
}
for (let index = 0; index < a.length; index += 1) {
if (a[index] !== b[index]) {
return false
}
}
return true
}
interface SessionStateCacheOptions {
activeSessionId: string | null
busyRef: MutableRefObject<boolean>
@@ -110,27 +88,10 @@ export function useSessionStateCache({
return
}
// `preserveLocalAssistantErrors` always returns a fresh array, so publishing
// it unconditionally puts a new `$messages` reference on the store every
// flush — including the periodic `session.info` heartbeats that don't touch
// the transcript. That churns ChatView → runtimeMessageRepository → the
// assistant-ui runtime → the virtualizer, which re-measures and visibly
// jerks the scroll position while the user is reading. Skip the publish when
// the merged result is content-identical to what's already on screen.
const currentMessages = $messages.get()
const nextMessages = preserveLocalAssistantErrors(pending.state.messages, currentMessages)
if (!sameMessageList(nextMessages, currentMessages)) {
setMessages(nextMessages)
}
setMessages(preserveLocalAssistantErrors(pending.state.messages, $messages.get()))
setBusy(pending.state.busy)
setMutableRef(busyRef, pending.state.busy)
setAwaitingResponse(pending.state.awaitingResponse)
// Mirror the focused session's per-session turn clock into the global
// atom the statusbar timer reads. Keeps a backgrounded turn's elapsed
// time intact on focus instead of zeroing it (the "timer restarts" bug).
setTurnStartedAt(pending.state.turnStartedAt)
}, [busyRef, setAwaitingResponse, setBusy, setMessages])
const syncSessionStateToView = useCallback(

View File

@@ -1,7 +1,6 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { BrandMark } from '@/components/brand-mark'
import { Button } from '@/components/ui/button'
import { type Translations, useI18n } from '@/i18n'
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
@@ -17,7 +16,6 @@ import {
} from '@/store/updates'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
import { UninstallSection } from './uninstall-section'
const RELEASE_NOTES_URL = 'https://github.com/NousResearch/hermes-agent/releases'
@@ -94,7 +92,9 @@ export function AboutSettings() {
return (
<SettingsContent>
<div className="flex flex-col items-center gap-3 pt-6 pb-2 text-center">
<BrandMark className="size-16" />
<span className="flex size-16 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Sparkles className="size-8" />
</span>
<div>
<h2 className="text-lg font-semibold tracking-tight">{a.heading}</h2>
<p className="mt-1 text-xs text-muted-foreground">
@@ -168,8 +168,6 @@ export function AboutSettings() {
hint={a.branchCommit(status?.branch ?? 'unknown', status?.currentSha?.slice(0, 7) ?? 'unknown')}
title={a.automaticUpdates}
/>
<UninstallSection />
</div>
</SettingsContent>
)

View File

@@ -1,18 +1,16 @@
import { useStore } from '@nanostores/react'
import { LanguageSwitcher } from '@/components/language-switcher'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { Check, Palette } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
import { useTheme } from '@/themes/context'
import { BUILTIN_THEMES } from '@/themes/presets'
import { MODE_OPTIONS } from './constants'
import { ListRow, SectionHeading, SettingsContent } from './primitives'
import { Pill, SectionHeading, SettingsContent } from './primitives'
function ThemePreview({ name }: { name: string }) {
const t = BUILTIN_THEMES[name]
@@ -58,121 +56,169 @@ export function AppearanceSettings() {
const { t, isSavingLocale } = useI18n()
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
const toolViewMode = useStore($toolViewMode)
const profiles = useStore($profiles)
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
const activeTheme = availableThemes.find(theme => theme.name === themeName)
const a = t.settings.appearance
// Themes save per profile. Surface that only when the user actually has more
// than one profile (single-profile installs never see the distinction).
const showProfileNote = profiles.length > 1
const activeProfileName =
profiles.find(profile => normalizeProfileKey(profile.name) === activeProfileKey)?.name ?? activeProfileKey
const modeOptions = MODE_OPTIONS.map(({ id, icon }) => ({ icon, id, label: t.settings.modeOptions[id].label }))
const toolOptions = [
{ id: 'product', label: a.product },
{ id: 'technical', label: a.technical }
] as const
return (
<SettingsContent>
<div>
<SectionHeading icon={Palette} title={a.title} />
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.intro}
</p>
<div className="mt-2 divide-y divide-(--ui-stroke-tertiary)">
<ListRow
action={<LanguageSwitcher />}
description={isSavingLocale ? t.language.saving : t.language.description}
title={t.language.label}
/>
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('crisp')
setMode(id)
}}
options={modeOptions}
value={mode}
/>
}
description={a.colorModeDesc}
title={a.colorMode}
/>
<ListRow
below={
<>
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
return (
<button
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
</button>
)
})}
</div>
{showProfileNote && (
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.themeProfileNote(activeProfileName)}
</p>
)}
</>
}
description={a.themeDesc}
title={a.themeTitle}
wide
/>
<ListRow
action={
<SegmentedControl
onChange={id => {
triggerHaptic('selection')
setToolViewMode(id)
}}
options={toolOptions}
value={toolViewMode}
/>
}
description={a.toolViewDesc}
title={a.toolViewTitle}
/>
<div className="space-y-5">
<div>
<SectionHeading icon={Palette} title={a.title} />
<p className="max-w-2xl text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{a.intro}
</p>
</div>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium">{t.language.label}</div>
<div className="mt-1 text-xs text-muted-foreground">{t.language.description}</div>
{isSavingLocale && <div className="mt-1 text-xs text-muted-foreground">{t.language.saving}</div>}
</div>
<LanguageSwitcher />
</div>
</section>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{a.colorMode}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.colorModeDesc}</div>
</div>
<Pill>{t.settings.modeOptions[mode].label}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{MODE_OPTIONS.map(({ id, icon: Icon }) => {
const active = mode === id
const copy = t.settings.modeOptions[id]
return (
<button
className={cn(
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={id}
onClick={() => {
triggerHaptic('crisp')
setMode(id)
}}
type="button"
>
<div className="flex items-start justify-between gap-3">
<span className="flex size-9 items-center justify-center rounded-lg bg-muted text-foreground transition group-hover:bg-background">
<Icon className="size-4" />
</span>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-2 text-[length:var(--conversation-text-font-size)] font-medium">{copy.label}</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{copy.description}
</div>
</button>
)
})}
</div>
</section>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{a.toolViewTitle}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.toolViewDesc}</div>
</div>
<Pill>{toolViewMode === 'technical' ? a.technical : a.product}</Pill>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{(
[
{ id: 'product', label: a.product, description: a.productDesc },
{ id: 'technical', label: a.technical, description: a.technicalDesc }
] as const
).map(option => {
const active = toolViewMode === option.id
return (
<button
className={cn(
'group rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2.5 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={option.id}
onClick={() => {
triggerHaptic('selection')
setToolViewMode(option.id)
}}
type="button"
>
<div className="flex items-start justify-between gap-3">
<div className="text-[length:var(--conversation-text-font-size)] font-medium">{option.label}</div>
{active && (
<span className="grid size-5 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{option.description}
</div>
</button>
)
})}
</div>
</section>
<section className="rounded-xl border border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) p-3 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium">{a.themeTitle}</div>
<div className="mt-1 text-xs text-muted-foreground">{a.themeDesc}</div>
</div>
{activeTheme && <Pill>{activeTheme.label}</Pill>}
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableThemes.map(theme => {
const active = themeName === theme.name
return (
<button
className={cn(
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
)}
key={theme.name}
onClick={() => {
triggerHaptic('crisp')
setTheme(theme.name)
}}
type="button"
>
<ThemePreview name={theme.name} />
<div className="mt-3 flex items-start justify-between gap-3 px-1">
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
{theme.label}
</div>
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{theme.description}
</div>
</div>
{active && (
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
<Check className="size-3.5" />
</span>
)}
</div>
</button>
)
})}
</div>
</section>
</div>
</SettingsContent>
)

View File

@@ -240,37 +240,9 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'context.engine': ['compressor', 'default', 'custom'],
'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh'],
'memory.provider': ['', 'builtin', 'honcho'],
// Terminal execution backends — kept in sync with the dispatch ladder in
// tools/terminal_tool.py::_create_environment (local/docker/singularity/
// modal/daytona/ssh). Remote backends need extra env (image, tokens, host).
'terminal.backend': ['local', 'docker', 'singularity', 'modal', 'daytona', 'ssh'],
'stt.elevenlabs.model_id': ['scribe_v2', 'scribe_v1'],
'stt.local.model': ['tiny', 'base', 'small', 'medium', 'large-v3'],
// Speech-to-text backends — kept in sync with the stt block in
// hermes_cli/config.py (local/groq/openai/mistral/elevenlabs).
'stt.provider': ['local', 'groq', 'openai', 'mistral', 'xai', 'elevenlabs'],
'tts.openai.voice': ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'],
// Text-to-speech backends — kept in sync with the built-in source of truth
// (agent/tts_registry.py::_BUILTIN_NAMES / tools/tts_tool.py::
// BUILTIN_TTS_PROVIDERS). 'xai' is Grok TTS.
'tts.provider': [
'edge',
'elevenlabs',
'openai',
'xai',
'minimax',
'mistral',
'gemini',
'neutts',
'kittentts',
'piper'
],
'stt.openai.model': ['whisper-1', 'gpt-4o-mini-transcribe', 'gpt-4o-transcribe'],
'stt.mistral.model': ['voxtral-mini-latest', 'voxtral-mini-2602'],
'tts.openai.model': ['gpt-4o-mini-tts', 'tts-1', 'tts-1-hd'],
'tts.elevenlabs.model_id': ['eleven_multilingual_v2', 'eleven_turbo_v2_5', 'eleven_flash_v2_5'],
// NeuTTS local inference device.
'tts.neutts.device': ['cpu', 'cuda', 'mps'],
'updates.non_interactive_local_changes': ['stash', 'discard']
}
@@ -296,11 +268,7 @@ export const FIELD_LABELS: Record<string, string> = defineFieldCopy({
backend: 'Execution Backend',
timeout: 'Command Timeout',
persistentShell: 'Persistent Shell',
envPassthrough: 'Environment Passthrough',
dockerImage: 'Docker Image',
singularityImage: 'Singularity Image',
modalImage: 'Modal Image',
daytonaImage: 'Daytona Image'
envPassthrough: 'Environment Passthrough'
},
fileReadMaxChars: 'File Read Limit',
toolOutput: {
@@ -341,15 +309,6 @@ export const FIELD_LABELS: Record<string, string> = defineFieldCopy({
model: 'Local Transcription Model',
language: 'Transcription Language'
},
openai: {
model: 'OpenAI STT Model'
},
groq: {
model: 'Groq STT Model'
},
mistral: {
model: 'Mistral STT Model'
},
elevenlabs: {
modelId: 'ElevenLabs STT Model',
languageCode: 'ElevenLabs Language',
@@ -369,33 +328,6 @@ export const FIELD_LABELS: Record<string, string> = defineFieldCopy({
elevenlabs: {
voiceId: 'ElevenLabs Voice',
modelId: 'ElevenLabs Model'
},
xai: {
voiceId: 'xAI (Grok) Voice',
language: 'xAI Language'
},
minimax: {
model: 'MiniMax TTS Model',
voiceId: 'MiniMax Voice'
},
mistral: {
model: 'Mistral TTS Model',
voiceId: 'Mistral Voice'
},
gemini: {
model: 'Gemini TTS Model',
voice: 'Gemini Voice'
},
neutts: {
model: 'NeuTTS Model',
device: 'NeuTTS Device'
},
kittentts: {
model: 'KittenTTS Model',
voice: 'KittenTTS Voice'
},
piper: {
voice: 'Piper Voice'
}
},
memory: {
@@ -443,11 +375,7 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = defineFieldCopy({
terminal: {
cwd: 'Default project folder for tool and terminal work.',
persistentShell: 'Keep shell state between commands when the backend supports it.',
envPassthrough: 'Environment variables to pass into tool execution.',
dockerImage: 'Container image used when the execution backend is Docker.',
singularityImage: 'Image used when the execution backend is Singularity.',
modalImage: 'Image used when the execution backend is Modal.',
daytonaImage: 'Image used when the execution backend is Daytona.'
envPassthrough: 'Environment variables to pass into tool execution.'
},
codeExecution: {
mode: 'How strictly code execution is scoped to the current project.'
@@ -476,15 +404,6 @@ export const FIELD_DESCRIPTIONS: Record<string, string> = defineFieldCopy({
voice: {
autoTts: 'Automatically speak assistant responses.'
},
tts: {
xai: {
voiceId: 'xAI voice ID (e.g. eve) or a custom voice ID.',
language: 'Spoken language code, e.g. en.'
},
neutts: {
device: 'Local inference device for NeuTTS.'
}
},
stt: {
enabled: 'Enable local or provider-backed speech transcription.',
elevenlabs: {
@@ -576,24 +495,8 @@ export const SECTIONS: DesktopConfigSection[] = [
'tts.openai.voice',
'tts.elevenlabs.voice_id',
'tts.elevenlabs.model_id',
'tts.xai.voice_id',
'tts.xai.language',
'tts.minimax.model',
'tts.minimax.voice_id',
'tts.mistral.model',
'tts.mistral.voice_id',
'tts.gemini.model',
'tts.gemini.voice',
'tts.neutts.model',
'tts.neutts.device',
'tts.kittentts.model',
'tts.kittentts.voice',
'tts.piper.voice',
'stt.local.model',
'stt.local.language',
'stt.openai.model',
'stt.groq.model',
'stt.mistral.model',
'stt.elevenlabs.model_id',
'stt.elevenlabs.language_code',
'stt.elevenlabs.tag_audio_events',
@@ -610,10 +513,6 @@ export const SECTIONS: DesktopConfigSection[] = [
'toolsets',
'terminal.backend',
'terminal.timeout',
'terminal.docker_image',
'terminal.singularity_image',
'terminal.modal_image',
'terminal.daytona_image',
'tool_output.max_bytes',
'tool_output.max_lines',
'tool_output.max_line_length',

View File

@@ -96,7 +96,7 @@ export function KeyField({
/>
{dirty && (
<Button className="h-8 shrink-0" disabled={busy} onClick={() => void onSave(varKey)} size="sm">
{busy ? <Loader2 className="animate-spin" /> : <Save />}
{busy ? <Loader2 className="size-4 animate-spin" /> : <Save />}
{busy ? t.settings.credentials.saving : t.common.save}
</Button>
)}
@@ -106,10 +106,9 @@ export function KeyField({
{info.is_set && (
<>
<Button
className="text-[0.6875rem] text-destructive hover:text-destructive"
className="h-auto px-0 py-0 text-[0.6875rem] text-destructive hover:text-destructive"
disabled={busy}
onClick={() => void onClear(varKey)}
size="inline"
type="button"
variant="text"
>

View File

@@ -535,13 +535,13 @@ export function GatewaySettings() {
<Check className="size-3" /> {g.signedIn}
</Pill>
<Button disabled={signingIn || state.envOverride} onClick={() => void signOut()} variant="outline">
{signingIn ? <Loader2 className="animate-spin" /> : null}
{signingIn ? <Loader2 className="size-4 animate-spin" /> : null}
{g.signOut}
</Button>
</div>
) : (
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
{signingIn ? <Loader2 className="animate-spin" /> : <LogIn />}
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
{isPasswordProvider ? g.signIn : g.signInWith(providerLabel)}
</Button>
)
@@ -591,14 +591,14 @@ export function GatewaySettings() {
size="sm"
variant="text"
>
{testing ? <Loader2 className="animate-spin" /> : null}
{testing ? <Loader2 className="size-4 animate-spin" /> : null}
{g.testRemote}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
{g.saveForRestart}
</Button>
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
{saving ? <Loader2 className="animate-spin" /> : null}
{saving ? <Loader2 className="size-4 animate-spin" /> : null}
{g.saveAndReconnect}
</Button>
</div>
@@ -607,7 +607,7 @@ export function GatewaySettings() {
<ListRow
action={
<Button onClick={() => void window.hermesDesktop?.revealLogs()} size="sm" variant="textStrong">
<FileText />
<FileText className="size-4" />
{g.openLogs}
</Button>
}

View File

@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import type { HermesConfigRecord } from '@/types/hermes'
import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from './field-copy'
import { enumOptionsFor, getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
import { getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
describe('settings helpers', () => {
describe('defineFieldCopy', () => {
@@ -135,38 +135,4 @@ describe('settings helpers', () => {
expect(providerGroup('SOMETHING_RANDOM')).toBe('Other')
})
})
describe('enumOptionsFor — backend selector dropdowns', () => {
const config: HermesConfigRecord = {}
it('renders a dropdown for the TTS provider including xAI (Grok)', () => {
const opts = enumOptionsFor('tts.provider', 'edge', config)
expect(opts).toBeDefined()
expect(opts).toContain('xai')
expect(opts).toContain('edge')
expect(opts).toContain('elevenlabs')
})
it('renders a dropdown for the STT provider including xAI (Grok)', () => {
const opts = enumOptionsFor('stt.provider', 'local', config)
expect(opts).toEqual(['local', 'groq', 'openai', 'mistral', 'xai', 'elevenlabs'])
})
it('renders dropdowns for per-backend model/device sub-fields', () => {
expect(enumOptionsFor('stt.openai.model', 'whisper-1', config)).toContain('gpt-4o-transcribe')
expect(enumOptionsFor('tts.openai.model', 'gpt-4o-mini-tts', config)).toContain('tts-1-hd')
expect(enumOptionsFor('tts.neutts.device', 'cpu', config)).toEqual(['cpu', 'cuda', 'mps'])
})
it('renders a dropdown for the terminal execution backend', () => {
const opts = enumOptionsFor('terminal.backend', 'local', config)
expect(opts).toEqual(['local', 'docker', 'singularity', 'modal', 'daytona', 'ssh'])
})
it('appends a hand-typed value not in the known list so it stays selected', () => {
const opts = enumOptionsFor('tts.provider', 'my-custom-command-tts', config)
expect(opts).toContain('my-custom-command-tts')
expect(opts).toContain('xai')
})
})
})

View File

@@ -111,9 +111,8 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
<div className="flex flex-wrap items-baseline justify-between gap-x-3">
<SettingsCategoryHeading icon={KeyRound} title={p.connectAccount} />
<Button
className="text-[length:var(--conversation-caption-font-size)]"
className="h-auto px-0 py-0 text-[length:var(--conversation-caption-font-size)]"
onClick={onWantApiKey}
size="inline"
type="button"
variant="textStrong"
>
@@ -144,9 +143,8 @@ function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; pr
)}
{collapsible && (
<Button
className="py-1 text-[length:var(--conversation-caption-font-size)]"
className="h-auto px-0 py-1 text-[length:var(--conversation-caption-font-size)]"
onClick={() => setShowAll(v => !v)}
size="inline"
type="button"
variant="text"
>

View File

@@ -8,17 +8,13 @@ const selectToolsetProvider = vi.fn()
const setEnvVar = vi.fn()
const deleteEnvVar = vi.fn()
const revealEnvVar = vi.fn()
const runToolsetPostSetup = vi.fn()
const getActionStatus = vi.fn()
vi.mock('@/hermes', () => ({
getToolsetConfig: (name: string) => getToolsetConfig(name),
selectToolsetProvider: (name: string, provider: string) => selectToolsetProvider(name, provider),
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
deleteEnvVar: (key: string) => deleteEnvVar(key),
revealEnvVar: (key: string) => revealEnvVar(key),
runToolsetPostSetup: (name: string, key: string) => runToolsetPostSetup(name, key),
getActionStatus: (name: string, lines?: number) => getActionStatus(name, lines)
revealEnvVar: (key: string) => revealEnvVar(key)
}))
vi.mock('@/store/notifications', () => ({
@@ -26,10 +22,6 @@ vi.mock('@/store/notifications', () => ({
notifyError: vi.fn()
}))
vi.mock('@/store/activity', () => ({
upsertDesktopActionTask: vi.fn()
}))
function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
return {
name: 'tts',
@@ -160,130 +152,4 @@ describe('ToolsetConfigPanel', () => {
// No provider selection was triggered — this is purely reflecting state.
expect(selectToolsetProvider).not.toHaveBeenCalled()
})
it('runs a provider post-setup install hook and tails its log', async () => {
// A browser-style toolset whose active provider declares a post_setup hook.
getToolsetConfig.mockResolvedValue(
config({
name: 'browser',
active_provider: 'Camofox',
providers: [
{
name: 'Camofox',
badge: 'local',
tag: 'Stealth local browser',
env_vars: [],
post_setup: 'camofox',
requires_nous_auth: false,
is_active: true
}
]
})
)
runToolsetPostSetup.mockResolvedValue({ ok: true, pid: 4321, name: 'tools-post-setup', key: 'camofox' })
// First poll: still running; second poll: finished cleanly.
getActionStatus
.mockResolvedValueOnce({
exit_code: null,
lines: ['Installing Camofox browser server...'],
name: 'tools-post-setup',
pid: 4321,
running: true
})
.mockResolvedValue({
exit_code: 0,
lines: ['Installing Camofox browser server...', "Post-setup 'camofox' complete"],
name: 'tools-post-setup',
pid: 4321,
running: false
})
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
fireEvent.click(await screen.findByRole('button', { name: /Run setup/ }))
await waitFor(() => expect(runToolsetPostSetup).toHaveBeenCalledWith('browser', 'camofox'))
// The install log is tailed inline. The first poll fires after a 1200ms
// delay (mirrors command-center's poll cadence), so allow >1200ms here.
await waitFor(() => expect(getActionStatus).toHaveBeenCalledWith('tools-post-setup', 300), {
timeout: 4000
})
})
it('does not poll when the spawn endpoint reports ok:false', async () => {
getToolsetConfig.mockResolvedValue(
config({
name: 'browser',
active_provider: 'Camofox',
providers: [
{
name: 'Camofox',
badge: 'local',
tag: 'Stealth local browser',
env_vars: [],
post_setup: 'camofox',
requires_nous_auth: false,
is_active: true
}
]
})
)
// Spawn failed server-side — must NOT proceed to poll a non-existent action.
runToolsetPostSetup.mockResolvedValue({ ok: false, pid: 0, name: 'tools-post-setup' })
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
fireEvent.click(await screen.findByRole('button', { name: /Run setup/ }))
await waitFor(() => expect(runToolsetPostSetup).toHaveBeenCalledWith('browser', 'camofox'))
// Give the would-be first poll delay (1200ms) time to NOT fire.
await new Promise(resolve => setTimeout(resolve, 1500))
expect(getActionStatus).not.toHaveBeenCalled()
})
it('surfaces a non-zero exit code from the setup process', async () => {
getToolsetConfig.mockResolvedValue(
config({
name: 'browser',
active_provider: 'Camofox',
providers: [
{
name: 'Camofox',
badge: 'local',
tag: 'Stealth local browser',
env_vars: [],
post_setup: 'camofox',
requires_nous_auth: false,
is_active: true
}
]
})
)
runToolsetPostSetup.mockResolvedValue({ ok: true, pid: 4321, name: 'tools-post-setup', key: 'camofox' })
// Action finished but failed (non-zero exit).
getActionStatus.mockResolvedValue({
exit_code: 1,
lines: ['Installing...', 'npm ERR! install failed'],
name: 'tools-post-setup',
pid: 4321,
running: false
})
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
fireEvent.click(await screen.findByRole('button', { name: /Run setup/ }))
// The failing install log is still tailed and shown; exit_code:1 routes to
// the error notify branch (asserted via the poll completing on a non-zero
// status without throwing).
await waitFor(() => expect(getActionStatus).toHaveBeenCalledWith('tools-post-setup', 300), {
timeout: 4000
})
await waitFor(() => expect(screen.getByText(/npm ERR! install failed/)).toBeTruthy(), {
timeout: 4000
})
})
})

View File

@@ -1,23 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { PageLoader } from '@/components/page-loader'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
deleteEnvVar,
getActionStatus,
getToolsetConfig,
revealEnvVar,
runToolsetPostSetup,
selectToolsetProvider,
setEnvVar
} from '@/hermes'
import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes'
import { useI18n } from '@/i18n'
import { Check, Loader2, Save, Terminal } from '@/lib/icons'
import { Check, Loader2, Save } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { upsertDesktopActionTask } from '@/store/activity'
import { notify, notifyError } from '@/store/notifications'
import type { ActionStatusResponse, ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes'
import type { ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes'
import { EnvVarActionsMenu, EnvVarActionsTrigger } from './env-var-actions-menu'
import { Pill } from './primitives'
@@ -166,120 +157,6 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
)
}
interface PostSetupRunnerProps {
toolset: string
/** The provider's post_setup hook key (e.g. "camofox", "ddgs"). */
postSetupKey: string
/** Refresh the parent config after the install finishes (a backend may now
* report itself configured). */
onComplete?: () => void
}
/**
* Runs a provider's post-setup install hook (npm / pip / binary) via the
* `/api/tools/toolsets/{name}/post-setup` spawn-action and tails the resulting
* log inline — the GUI equivalent of the install step `hermes tools` runs
* after you pick a backend that needs extra dependencies.
*/
function PostSetupRunner({ toolset, postSetupKey, onComplete }: PostSetupRunnerProps) {
const { t } = useI18n()
const copy = t.settings.toolsets
const [running, setRunning] = useState(false)
const [status, setStatus] = useState<ActionStatusResponse | null>(null)
// Guard against overlapping polls / state updates after unmount.
const activeRef = useRef(false)
useEffect(() => {
return () => {
activeRef.current = false
}
}, [])
const run = useCallback(async () => {
setRunning(true)
setStatus(null)
activeRef.current = true
try {
const started = await runToolsetPostSetup(toolset, postSetupKey)
// The spawn endpoint reports ok:false if it couldn't launch the action
// (e.g. unknown key, server-side spawn failure). Don't poll a status
// that will never exist — surface the failure and stop.
if (!started.ok) {
notifyError(new Error('spawn failed'), copy.postSetupFailed(postSetupKey))
return
}
let last: ActionStatusResponse | null = null
// Mirror command-center's runSystemAction poll loop: poll the action log
// until it exits (or we hit the attempt ceiling), feeding the global
// activity rail as we go.
for (let attempt = 0; attempt < 150 && activeRef.current; attempt += 1) {
await new Promise(resolve => window.setTimeout(resolve, 1200))
if (!activeRef.current) {
break
}
const polled = await getActionStatus(started.name, 300)
last = polled
setStatus(polled)
upsertDesktopActionTask(polled)
if (!polled.running) {
break
}
}
if (activeRef.current) {
const ok = last?.exit_code === 0
notify(
ok
? {
kind: 'success',
title: copy.postSetupCompleteTitle,
message: copy.postSetupCompleteMessage(postSetupKey)
}
: { kind: 'error', title: copy.postSetupErrorTitle, message: copy.postSetupErrorMessage(postSetupKey) }
)
onComplete?.()
}
} catch (err) {
if (activeRef.current) {
notifyError(err, copy.postSetupFailed(postSetupKey))
}
} finally {
if (activeRef.current) {
setRunning(false)
}
}
}, [toolset, postSetupKey, onComplete, copy])
return (
<div className="grid gap-2 rounded-lg bg-background/55 p-2.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-[0.72rem] text-muted-foreground">{copy.postSetupHint(postSetupKey)}</p>
</div>
<Button disabled={running} onClick={() => void run()} size="sm">
{running ? <Loader2 className="size-3.5 animate-spin" /> : <Terminal className="size-3.5" />}
{running ? copy.postSetupRunning : copy.postSetupRun}
</Button>
</div>
{status && (status.lines.length > 0 || status.running) && (
<pre className="max-h-48 overflow-y-auto rounded-md bg-background px-2.5 py-1.5 font-mono text-[0.7rem] leading-relaxed text-muted-foreground whitespace-pre-wrap">
{status.lines.length > 0 ? status.lines.join('\n') : copy.postSetupStarting}
</pre>
)}
</div>
)
}
export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfigPanelProps) {
const { t } = useI18n()
const copy = t.settings.toolsets
@@ -433,11 +310,9 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
))
)}
{provider.post_setup && (
<PostSetupRunner
onComplete={() => void refresh()}
postSetupKey={provider.post_setup}
toolset={toolset}
/>
<p className="text-[0.72rem] text-muted-foreground">
{copy.postSetup(provider.post_setup)}
</p>
)}
</div>
)}

View File

@@ -1,185 +0,0 @@
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { AlertTriangle, Loader2, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
import type { DesktopUninstallMode, DesktopUninstallSummary } from '@/global'
import { SectionHeading } from './primitives'
interface ModeOption {
mode: DesktopUninstallMode
title: string
description: string
/** Shown in the confirm step so people know exactly what disappears. */
consequence: string
/** True when the option removes the Python agent (hidden if no agent). */
needsAgent: boolean
}
const OPTIONS: ModeOption[] = [
{
mode: 'gui',
title: 'Uninstall Chat GUI only',
description: 'Remove this desktop app. The Hermes agent, your config, and chats all stay.',
consequence: 'the desktop Chat GUI (this app and its data)',
needsAgent: false
},
{
mode: 'lite',
title: 'Uninstall GUI + agent, keep my data',
description: 'Remove the app and the Hermes agent, but keep config, chats, and secrets for a future reinstall.',
consequence: 'the Chat GUI and the Hermes agent (config, chats, and secrets are kept)',
needsAgent: true
},
{
mode: 'full',
title: 'Uninstall everything',
description: 'Remove the app, the agent, and all user data — config, chats, scheduled jobs, secrets, logs.',
consequence: 'EVERYTHING — the Chat GUI, the Hermes agent, and all of your config, chats, secrets, and logs',
// full removes the agent (and user data), so it's an agent-removing option:
// hide it on a lite client with no local agent, same as lite. A lite client
// connecting to a remote backend has no local agent OR local user data the
// GUI installed, so gui-only is the correct (and only) option there.
needsAgent: true
}
]
export function UninstallSection() {
const [summary, setSummary] = useState<DesktopUninstallSummary | null>(null)
const [loading, setLoading] = useState(true)
const [pending, setPending] = useState<DesktopUninstallMode | null>(null)
const [running, setRunning] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let alive = true
const bridge = window.hermesDesktop?.uninstall
if (!bridge) {
setLoading(false)
return
}
void bridge
.summary()
.then(result => {
if (alive) {
setSummary(result)
}
})
.catch(() => {
// Non-fatal — we degrade to offering the GUI-only option.
})
.finally(() => {
if (alive) {
setLoading(false)
}
})
return () => {
alive = false
}
}, [])
const bridge = window.hermesDesktop?.uninstall
if (!bridge) {
return null
}
// Gate the agent-removing options on whether an agent is actually present.
// A future lite client that ships without the bundled agent shows GUI-only.
const agentInstalled = summary?.agent_installed ?? false
const visibleOptions = OPTIONS.filter(opt => agentInstalled || !opt.needsAgent)
const handleConfirm = async () => {
if (!pending) {
return
}
setRunning(true)
setError(null)
try {
const result = await bridge.run(pending)
if (!result.ok) {
setError(result.message || result.error || 'Uninstall could not start.')
setRunning(false)
setPending(null)
}
// On success the app quits shortly; keep the spinner up until it does.
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setRunning(false)
setPending(null)
}
}
const pendingOption = OPTIONS.find(opt => opt.mode === pending) ?? null
return (
<div className="mx-auto mt-8 w-full max-w-2xl">
<SectionHeading icon={AlertTriangle} title="Danger zone" />
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3">
{loading ? (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
Checking what&apos;s installed
</div>
) : pendingOption ? (
<div>
<p className="text-sm font-medium text-destructive">Confirm uninstall</p>
<p className="mt-1 text-xs text-muted-foreground">
This removes {pendingOption.consequence}. This can&apos;t be undone.
</p>
{summary?.running_app_path && (
<p className="mt-1 font-mono text-[0.68rem] text-muted-foreground/60">
App: {summary.running_app_path}
</p>
)}
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
<div className="mt-3 flex flex-wrap items-center gap-3">
<Button
disabled={running}
onClick={() => void handleConfirm()}
size="sm"
variant="destructive"
>
{running && <Loader2 className="size-3 animate-spin" />}
{running ? 'Uninstalling…' : 'Yes, uninstall'}
</Button>
<Button disabled={running} onClick={() => setPending(null)} size="sm" variant="text">
Cancel
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Uninstall Hermes</p>
<p className="text-xs text-muted-foreground">
Choose how much to remove. The app closes to finish the job; reopen the installer any time to come back.
</p>
<div className="mt-1 flex flex-col gap-2">
{visibleOptions.map(opt => (
<button
className={cn(
'flex items-start gap-3 rounded-lg border border-border/60 bg-background/40 px-3 py-2.5 text-left transition',
'hover:border-destructive/40 hover:bg-destructive/5'
)}
key={opt.mode}
onClick={() => {
setError(null)
setPending(opt.mode)
}}
type="button"
>
<Trash2 className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0">
<span className="block text-sm font-medium text-foreground">{opt.title}</span>
<span className="mt-0.5 block text-xs text-muted-foreground">{opt.description}</span>
</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
)
}

View File

@@ -5,7 +5,6 @@ import { useSyncExternalStore } from 'react'
import { NotificationStack } from '@/components/notifications'
import { PaneShell } from '@/components/pane-shell'
import { SidebarProvider } from '@/components/ui/sidebar'
import { useMediaQuery } from '@/hooks/use-media-query'
import {
$fileBrowserOpen,
$panesFlipped,
@@ -17,8 +16,6 @@ import {
import { $paneWidthOverride } from '@/store/panes'
import { $connection } from '@/store/session'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '../layout-constants'
import { KeybindPanel } from './keybind-panel'
import { StatusbarControls, type StatusbarItem } from './statusbar-controls'
import { TITLEBAR_HEIGHT, titlebarControlsPosition } from './titlebar'
@@ -61,7 +58,6 @@ export function AppShell({
const sidebarOpen = useStore($sidebarOpen)
const fileBrowserOpen = useStore($fileBrowserOpen)
const panesFlipped = useStore($panesFlipped)
const narrowViewport = useMediaQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)
const fileBrowserWidthOverride = useStore($paneWidthOverride(FILE_BROWSER_PANE_ID))
const connection = useStore($connection)
const viewportFullscreen = useSyncExternalStore(subscribeWindowSize, viewportIsFullscreen, () => false)
@@ -75,10 +71,8 @@ export function AppShell({
// The inset clears the top-left titlebar buttons when nothing covers the
// window's left edge. Default layout: the sessions sidebar sits there.
// Flipped layout: the file browser does instead. Below the collapse
// breakpoint both rails are force-collapsed (hover-reveal overlay), so the
// edge is uncovered regardless of their stored open state.
const leftEdgePaneOpen = !narrowViewport && (panesFlipped ? fileBrowserOpen : sidebarOpen)
// Flipped layout: the file browser does instead.
const leftEdgePaneOpen = panesFlipped ? fileBrowserOpen : sidebarOpen
const titlebarContentInset = leftEdgePaneOpen
? 0

View File

@@ -111,15 +111,13 @@ export function GatewayMenuPanel({
</Tip>
))}
</ul>
<Button
className="-ml-2 mt-1.5 font-medium text-muted-foreground"
<button
className="mt-1.5 text-[0.66rem] font-medium text-muted-foreground hover:text-foreground"
onClick={onOpenSystem}
size="xs"
type="button"
variant="text"
>
{copy.viewAllLogs}
</Button>
</button>
</div>
)}

View File

@@ -4,7 +4,6 @@ import { useCallback, useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { useI18n } from '@/i18n'
import {
Activity,
AlertCircle,
@@ -17,17 +16,17 @@ import {
Zap,
ZapFilled
} from '@/lib/icons'
import { useI18n } from '@/i18n'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
import { cn } from '@/lib/utils'
import { setGlobalYolo, setSessionYolo } from '@/lib/yolo-session'
import { setSessionYolo } from '@/lib/yolo-session'
import { $desktopActionTasks } from '@/store/activity'
import { $previewServerRestartStatus } from '@/store/preview'
import {
$activeSessionId,
$busy,
$connection,
$currentFastMode,
$currentModel,
$currentProvider,
@@ -41,18 +40,11 @@ import {
setYoloActive
} from '@/store/session'
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
import {
$backendUpdateApply,
$backendUpdateStatus,
$desktopVersion,
$updateApply,
$updateStatus,
openUpdateOverlayFor
} from '@/store/updates'
import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } from '@/store/updates'
import type { StatusResponse } from '@/types/hermes'
import { CRON_ROUTE } from '../../routes'
import type { StatusbarItem, StatusbarSelectModifiers } from '../statusbar-controls'
import type { StatusbarItem } from '../statusbar-controls'
interface StatusbarItemsOptions {
agentsOpen: boolean
@@ -105,10 +97,7 @@ export function useStatusbarItems({
const subagentsBySession = useStore($subagentsBySession)
const updateStatus = useStore($updateStatus)
const updateApply = useStore($updateApply)
const backendUpdateStatus = useStore($backendUpdateStatus)
const backendUpdateApply = useStore($backendUpdateApply)
const desktopVersion = useStore($desktopVersion)
const connection = useStore($connection)
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
@@ -116,39 +105,22 @@ export function useStatusbarItems({
// Per-session approval bypass (same scope as the TUI's Shift+Tab). On a
// new-chat draft (no runtime session yet) we arm locally; the session-create
// path applies it once the backend session exists.
//
// Shift+click flips the GLOBAL approvals.mode instead — a persistent,
// all-sessions/CLI/TUI/cron bypass that survives restarts.
const toggleYolo = useCallback(
async (modifiers?: StatusbarSelectModifiers) => {
const next = !$yoloActive.get()
const toggleYolo = useCallback(async () => {
const next = !$yoloActive.get()
const sid = $activeSessionId.get()
setYoloActive(next)
setYoloActive(next)
if (modifiers?.shiftKey) {
try {
await setGlobalYolo(requestGateway, next)
} catch {
setYoloActive(!next)
}
if (!sid) {
return
}
return
}
const sid = $activeSessionId.get()
if (!sid) {
return
}
try {
await setSessionYolo(requestGateway, sid, next)
} catch {
setYoloActive(!next)
}
},
[requestGateway]
)
try {
await setSessionYolo(requestGateway, sid, next)
} catch {
setYoloActive(!next)
}
}, [requestGateway])
const showYoloToggle = gatewayState === 'open' && (!!activeSessionId || freshDraftReady)
@@ -205,19 +177,18 @@ export function useStatusbarItems({
? 'text-amber-600 hover:text-amber-600'
: 'text-destructive hover:text-destructive'
const clientVersionItem = useMemo<StatusbarItem>(() => {
const versionItem = useMemo<StatusbarItem>(() => {
const appVersion = desktopVersion?.appVersion
const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
const behind = updateStatus?.behind ?? 0
const applying = updateApply.applying || updateApply.stage === 'restart'
const remote = connection?.mode === 'remote'
const version = appVersion ? `v${appVersion}` : (sha ?? copy.unknown)
const base = remote ? copy.clientLabel(appVersion ?? sha ?? copy.unknown) : version
const base = appVersion ? `v${appVersion}` : (sha ?? copy.unknown)
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
const label = applying
? `${base} · ${updateApply.stage === 'restart' ? copy.restart : copy.update}`
? updateApply.stage === 'restart'
? `${base} · ${copy.restart}`
: `${base} · ${copy.update}`
: `${base}${behindHint}`
const tooltip = [
@@ -232,18 +203,17 @@ export function useStatusbarItems({
return {
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
detail: appVersion && sha && !applying && !remote ? sha : undefined,
detail: appVersion && sha && !applying ? sha : undefined,
hidden: !appVersion && !sha,
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
id: 'version-client',
id: 'version',
label,
onSelect: () => openUpdateOverlayFor('client'),
onSelect: () => setUpdateOverlayOpen(true),
title: tooltip || undefined,
variant: 'action'
}
}, [
desktopVersion?.appVersion,
connection?.mode,
copy,
updateApply.applying,
updateApply.message,
@@ -253,50 +223,6 @@ export function useStatusbarItems({
updateStatus?.currentSha
])
const backendVersionItem = useMemo<StatusbarItem | null>(() => {
if (connection?.mode !== 'remote') {
return null
}
const backendVersion = statusSnapshot?.version
const behind = backendUpdateStatus?.behind ?? 0
const applying = backendUpdateApply.applying || backendUpdateApply.stage === 'restart'
const base = copy.backendLabel(backendVersion ?? copy.unknown)
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
const label = applying
? `${base} · ${backendUpdateApply.stage === 'restart' ? copy.restart : copy.update}`
: `${base}${behindHint}`
const tooltip = [
applying ? backendUpdateApply.message || copy.updateInProgress : null,
!applying && behind > 0 && copy.commitsBehind(behind, 'main'),
backendVersion && copy.backendVersion(backendVersion)
]
.filter(Boolean)
.join(' · ')
return {
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
hidden: !backendVersion,
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
id: 'version-backend',
label,
onSelect: () => openUpdateOverlayFor('backend'),
title: tooltip || undefined,
variant: 'action'
}
}, [
connection?.mode,
statusSnapshot?.version,
backendUpdateStatus?.behind,
backendUpdateApply.applying,
backendUpdateApply.message,
backendUpdateApply.stage,
copy
])
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
() => [
{
@@ -407,7 +333,7 @@ export function useStatusbarItems({
<Zap className="size-3.5 shrink-0 opacity-70" />
),
id: 'yolo',
onSelect: modifiers => void toggleYolo(modifiers),
onSelect: () => void toggleYolo(),
title: yoloActive ? copy.yoloOn : copy.yoloOff,
variant: 'action'
},
@@ -442,8 +368,7 @@ export function useStatusbarItems({
variant: 'action' as const
})
},
clientVersionItem,
...(backendVersionItem ? [backendVersionItem] : [])
versionItem
],
[
busy,
@@ -459,8 +384,7 @@ export function useStatusbarItems({
showYoloToggle,
toggleYolo,
turnStartedAt,
clientVersionItem,
backendVersionItem,
versionItem,
yoloActive
]
)

View File

@@ -2,7 +2,6 @@ import { useStore } from '@nanostores/react'
import { Dialog as DialogPrimitive } from 'radix-ui'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { useI18n } from '@/i18n'
@@ -57,7 +56,7 @@ export function KeybindPanel() {
<DialogPrimitive.Overlay className="fixed inset-0 z-[200] bg-black/25 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
aria-describedby={undefined}
className="fixed left-1/2 top-[9vh] z-[210] flex max-h-[82vh] w-[min(38rem,calc(100vw-2rem))] -translate-x-1/2 flex-col overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
className="fixed left-1/2 top-[9vh] z-[210] flex max-h-[82vh] w-[min(38rem,calc(100vw-2rem))] -translate-x-1/2 flex-col overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-[0_20px_48px_-24px_rgba(0,0,0,0.55)] duration-150 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
>
{/* Header */}
<div className="flex items-center justify-between gap-3 border-b border-(--ui-stroke-tertiary) px-4 py-3">
@@ -125,10 +124,14 @@ function CategoryHeader({ label, onToggle, open }: { label: string; onToggle: ()
function HeaderButton({ icon, label, onClick }: { icon: string; label: string; onClick: () => void }) {
return (
<Button className="shrink-0 text-[0.72rem]" onClick={onClick} size="xs" variant="text">
<button
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-[0.72rem] font-medium text-muted-foreground transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground"
onClick={onClick}
type="button"
>
<Codicon name={icon} size="0.8125rem" />
{label}
</Button>
</button>
)
}
@@ -149,6 +152,8 @@ function KeybindRow({ action }: { action: KeybindActionMeta }) {
return (
<div className="group flex items-center gap-2.5 rounded-lg px-2.5 py-1 transition-colors hover:bg-(--chrome-action-hover)">
{/* Mirrors the reset button's footprint on the right so rows stay uniform. */}
<span aria-hidden className="size-6 shrink-0" />
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/90">{label}</span>
{conflict && (
@@ -206,6 +211,7 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
return (
<div className="flex items-center gap-2.5 rounded-lg px-2.5 py-1">
<span aria-hidden className="size-6 shrink-0" />
<span className="min-w-0 flex-1 truncate text-[0.82rem] text-foreground/75">{label}</span>
<div className="flex shrink-0 items-center gap-1">
{shortcut.keys.map(key => (

View File

@@ -24,7 +24,6 @@ import {
$visibleModels,
collapseModelFamilies,
DEFAULT_VISIBLE_PER_PROVIDER,
effectiveVisibleKeys,
type ModelFamily,
modelVisibilityKey,
setModelVisibilityOpen
@@ -87,17 +86,13 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
: null
const providers = modelOptions.data?.providers
const effectiveVisibleModels = useMemo(
() => effectiveVisibleKeys(visibleModels, providers ?? []),
[visibleModels, providers]
)
const switchTo = (model: string, provider: string) =>
onSelectModel({ model, persistGlobal: !activeSessionId, provider })
const groups = useMemo(
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, effectiveVisibleModels),
[providers, search, optionsModel, optionsProvider, effectiveVisibleModels]
() => groupModels(providers ?? [], search, { model: optionsModel, provider: optionsProvider }, visibleModels),
[providers, search, optionsModel, optionsProvider, visibleModels]
)
return (

View File

@@ -35,16 +35,12 @@ export interface StatusbarItem {
menuClassName?: string
menuContent?: ReactNode
menuItems?: readonly StatusbarMenuItem[]
onSelect?: (modifiers: StatusbarSelectModifiers) => void
onSelect?: () => void
title?: string
to?: string
variant?: 'action' | 'link' | 'menu' | 'text'
}
export interface StatusbarSelectModifiers {
shiftKey: boolean
}
export type StatusbarItemSide = 'left' | 'right'
export type SetStatusbarItemGroup = (id: string, items: readonly StatusbarItem[], side?: StatusbarItemSide) => void
@@ -174,12 +170,12 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
<button
className={cn(STATUSBAR_ACTION_CLASS, item.className)}
disabled={item.disabled}
onClick={event => {
onClick={() => {
if (item.to) {
navigate(item.to)
}
item.onSelect?.({ shiftKey: event.shiftKey })
item.onSelect?.()
}}
type="button"
>

View File

@@ -15,7 +15,6 @@ import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { useRefreshHotkey } from '../hooks/use-refresh-hotkey'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PAGE_INSET_X } from '../layout-constants'
import { PageSearchShell } from '../page-search-shell'
import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers'
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
@@ -192,22 +191,32 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<PageSearchShell
{...props}
filters={
mode === 'skills' && categories.length > 0 ? (
<>
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
{t.skills.all} <TextTabMeta>{totalSkills}</TextTabMeta>
<>
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
{t.skills.tabSkills}
</TextTab>
{categories.map(category => (
<TextTab
active={activeCategory === category.key}
key={category.key}
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
>
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
{t.skills.tabToolsets}
</TextTab>
</div>
{mode === 'skills' && categories.length > 0 && (
<div className="flex flex-wrap justify-center gap-x-2 gap-y-1">
<TextTab active={activeCategory === null} onClick={() => setActiveCategory(null)}>
{t.skills.all} <TextTabMeta>{totalSkills}</TextTabMeta>
</TextTab>
))}
</>
) : undefined
{categories.map(category => (
<TextTab
active={activeCategory === category.key}
key={category.key}
onClick={() => setActiveCategory(activeCategory === category.key ? null : category.key)}
>
{prettyName(category.key)} <TextTabMeta>{category.count}</TextTabMeta>
</TextTab>
))}
</div>
)}
</>
}
onSearchChange={setQuery}
searchHidden={mode === 'skills' ? (skills?.length ?? 0) === 0 : (toolsets?.length ?? 0) === 0}
@@ -227,33 +236,21 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
</Button>
}
searchValue={query}
tabs={
<>
<TextTab active={mode === 'skills'} onClick={() => setMode('skills')}>
{t.skills.tabSkills}
</TextTab>
<TextTab active={mode === 'toolsets'} onClick={() => setMode('toolsets')}>
{t.skills.tabToolsets}
</TextTab>
</>
}
>
{!skills || !toolsets ? (
<PageLoader label={t.skills.loading} />
) : mode === 'skills' ? (
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
<div className="h-full overflow-y-auto px-4 py-3">
{visibleSkills.length === 0 ? (
<EmptyState description={t.skills.noSkillsDesc} title={t.skills.noSkillsTitle} />
) : (
<div className="space-y-4">
{skillGroups.map(([category, list]) => (
<div className="space-y-1.5" key={category}>
{activeCategory === null && (
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
)}
<div>
<div className="text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
<div className="divide-y divide-(--ui-stroke-quaternary)">
{list.map(skill => (
<div
className="grid gap-3 px-0 py-2.5 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
@@ -279,7 +276,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
)}
</div>
) : (
<div className={cn('h-full overflow-y-auto py-3', PAGE_INSET_X)}>
<div className="h-full overflow-y-auto px-4 py-3">
{visibleToolsets.length === 0 ? (
<EmptyState description={t.skills.noToolsetsDesc} title={t.skills.noToolsetsTitle} />
) : (
@@ -287,7 +284,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
<div className="text-xs text-muted-foreground">
{t.skills.toolsetsEnabled(enabledToolsets, toolsets.length)}
</div>
<div>
<div className="divide-y divide-(--ui-stroke-quaternary)">
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = toolsetDisplayLabel(toolset)

View File

@@ -13,13 +13,6 @@ export interface ImageAttachResponse {
path?: string
text?: string
message?: string
// Returned by the byte-upload variant (image.attach_bytes) used in remote mode.
count?: number
bytes?: number
name?: string
width?: number
height?: number
token_estimate?: number
}
export interface ImageDetachResponse {
@@ -98,9 +91,4 @@ export interface ClientSessionState {
/** A blocking clarify prompt is waiting on the user for this session. Drives
* the sidebar "needs input" indicator; cleared when the turn resumes/ends. */
needsInput: boolean
/** Epoch ms the current turn started, or null when idle. Per-session so a
* background turn's elapsed timer keeps counting while another session is
* focused, and switching sessions doesn't zero a still-running turn's clock.
* The global $turnStartedAt mirrors whichever session is currently viewed. */
turnStartedAt: number | null
}

View File

@@ -1,30 +1,21 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { BrandMark } from '@/components/brand-mark'
import { Button } from '@/components/ui/button'
import { writeClipboardText } from '@/components/ui/copy-button'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
import { ErrorIcon, ErrorState } from '@/components/ui/error-state'
import { Loader } from '@/components/ui/loader'
import { ErrorState } from '@/components/ui/error-state'
import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
import { useI18n } from '@/i18n'
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
import { AlertCircle, Check, CheckCircle2, Copy, Terminal } from '@/lib/icons'
import { AlertCircle, Check, CheckCircle2, Copy, Loader2, Sparkles, Terminal } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { resolveUpdateCopy, type UpdateTarget } from '@/lib/update-copy'
import {
$backendUpdateApply,
$backendUpdateChecking,
$backendUpdateStatus,
$updateApply,
$updateChecking,
$updateOverlayOpen,
$updateOverlayTarget,
$updateStatus,
applyBackendUpdate,
applyUpdates,
checkBackendUpdates,
checkUpdates,
resetUpdateApplyState,
setUpdateOverlayOpen,
@@ -37,27 +28,15 @@ function totalItems(groups: readonly CommitGroup[]) {
export function UpdatesOverlay() {
const open = useStore($updateOverlayOpen)
const target = useStore($updateOverlayTarget)
const clientStatus = useStore($updateStatus)
const clientChecking = useStore($updateChecking)
const clientApply = useStore($updateApply)
const backendStatus = useStore($backendUpdateStatus)
const backendChecking = useStore($backendUpdateChecking)
const backendApply = useStore($backendUpdateApply)
const isBackend = target === 'backend'
const status = isBackend ? backendStatus : clientStatus
const checking = isBackend ? backendChecking : clientChecking
const apply = isBackend ? backendApply : clientApply
const check = isBackend ? checkBackendUpdates : checkUpdates
const install = isBackend ? applyBackendUpdate : applyUpdates
const status = useStore($updateStatus)
const checking = useStore($updateChecking)
const apply = useStore($updateApply)
useEffect(() => {
if (open && !status && !checking) {
void check()
void checkUpdates()
}
}, [check, checking, open, status])
}, [checking, open, status])
const behind = status?.behind ?? 0
@@ -83,7 +62,7 @@ export function UpdatesOverlay() {
}
const handleInstall = () => {
void install()
void applyUpdates()
}
return (
@@ -92,7 +71,7 @@ export function UpdatesOverlay() {
className="max-w-sm overflow-hidden border-border/70 p-0 gap-0"
showCloseButton={phase !== 'applying'}
>
{phase === 'applying' && <ApplyingView apply={apply} isBackend={isBackend} />}
{phase === 'applying' && <ApplyingView apply={apply} />}
{phase === 'manual' && (
<ManualView command={apply.command ?? 'hermes update'} onDone={() => handleClose(false)} />
@@ -109,9 +88,8 @@ export function UpdatesOverlay() {
commits={status?.commits ?? []}
onInstall={handleInstall}
onLater={() => handleClose(false)}
onRetryCheck={() => void check()}
onRetryCheck={() => void checkUpdates()}
status={status}
target={target}
/>
)}
</DialogContent>
@@ -126,8 +104,7 @@ function IdleView({
onInstall,
onLater,
onRetryCheck,
status,
target
status
}: {
behind: number
checking: boolean
@@ -136,14 +113,13 @@ function IdleView({
onLater: () => void
onRetryCheck: () => void
status: DesktopUpdateStatus | null
target: UpdateTarget
}) {
const { t } = useI18n()
const u = t.updates
if (!status && checking) {
return (
<CenteredStatus icon={<Loader className="size-12" label={u.checking} type="lemniscate-bloom" />} title={u.checking} />
<CenteredStatus icon={<Loader2 className="size-6 animate-spin text-primary" />} title={u.checking} />
)
}
@@ -155,7 +131,7 @@ function IdleView({
{u.tryAgain}
</Button>
}
icon={<ErrorIcon />}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
title={u.checkFailedTitle}
/>
)
@@ -180,7 +156,7 @@ function IdleView({
</Button>
}
body={u.connectionRetry}
icon={<ErrorIcon />}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
title={u.checkFailedTitle}
/>
)
@@ -189,7 +165,7 @@ function IdleView({
if (behind === 0) {
return (
<CenteredStatus
body={target === 'backend' ? u.latestBodyBackend : u.latestBody}
body={u.latestBody}
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
title={u.allSetTitle}
/>
@@ -200,20 +176,16 @@ function IdleView({
const shownItems = totalItems(groups)
const remaining = Math.max(0, behind - shownItems)
// Name what's being updated. In remote mode the overlay acts on the connected
// backend, not the local client — say so. When there are no commit rows to
// show (e.g. pip/non-git backend), degrade to honest "no release notes" copy
// instead of generic filler.
const { title, body } = resolveUpdateCopy({ target, shownItems, copy: u })
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<BrandMark className="size-16" />
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Sparkles className="size-7" />
</span>
<DialogTitle className="text-center text-xl">{title}</DialogTitle>
<DialogTitle className="text-center text-xl">{u.availableTitle}</DialogTitle>
<DialogDescription className="text-center text-sm">
{body}
{u.availableBody}
</DialogDescription>
</div>
@@ -237,9 +209,13 @@ function IdleView({
<Button className="font-semibold" onClick={onInstall} size="lg">
{u.updateNow}
</Button>
<Button className="font-medium" onClick={onLater} type="button" variant="text">
<button
className="text-center text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
onClick={onLater}
type="button"
>
{u.maybeLater}
</Button>
</button>
</div>
{remaining > 0 && (
@@ -266,7 +242,9 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<Terminal className="size-8 text-primary" />
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Terminal className="size-7" />
</span>
<DialogTitle className="text-center text-xl">{u.manualTitle}</DialogTitle>
<DialogDescription className="text-center text-sm">
@@ -302,18 +280,17 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
{u.manualPickedUp}
</p>
<Button className="font-semibold" onClick={onDone} size="lg" variant="secondary">
<Button className="font-semibold" onClick={onDone} size="lg" variant="outline">
{u.done}
</Button>
</div>
)
}
function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend: boolean }) {
function ApplyingView({ apply }: { apply: UpdateApplyState }) {
const { t } = useI18n()
const u = t.updates
const label = u.stages[apply.stage as DesktopUpdateStage] ?? u.stages.idle
const body = isBackend ? u.applyingBodyBackend : u.applyingBody
const percent =
typeof apply.percent === 'number' && Number.isFinite(apply.percent)
@@ -323,11 +300,13 @@ function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend
return (
<div className="grid gap-5 px-6 pb-6 pt-7">
<div className="flex flex-col items-center gap-3 text-center">
<Loader className="size-16" label={label} type="lemniscate-bloom" />
<span className="relative flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Loader2 className="size-7 animate-spin" />
</span>
<DialogTitle className="text-center text-xl">{label}</DialogTitle>
<DialogDescription className="text-center text-sm">
{body}
{u.applyingBody}
</DialogDescription>
</div>
@@ -386,7 +365,7 @@ function CenteredStatus({
return (
<div className="grid gap-4 px-6 pb-6 pt-8 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
{icon}
<span className="flex size-14 items-center justify-center rounded-2xl bg-muted/40">{icon}</span>
<DialogTitle className="text-center text-lg">{title}</DialogTitle>
{body && <DialogDescription className="text-center text-sm">{body}</DialogDescription>}

View File

@@ -264,16 +264,14 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
{!typing && hasChoices && (
<div className="flex justify-end">
<Button
className="-mr-2"
<button
className="bg-transparent text-[0.6875rem] text-muted-foreground/70 underline-offset-4 hover:text-foreground hover:underline disabled:cursor-not-allowed disabled:opacity-50"
disabled={!ready || submitting}
onClick={() => void respond('')}
size="xs"
type="button"
variant="text"
>
{copy.skip}
</Button>
Skip
</button>
</div>
)}
</div>

View File

@@ -7,7 +7,6 @@ import { Fragment, useEffect, useMemo, useState } from 'react'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { extractEmbeddedImages } from '@/lib/embedded-images'
import { gatewayMediaDataUrl, isRemoteGateway } from '@/lib/media'
const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const
type HermesRefType = (typeof HERMES_REF_TYPES)[number]
@@ -328,32 +327,25 @@ export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePar
* messages render after the backend embeds the data URL, so the UX is stable
* across initial send and refresh. */
const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => {
const isUrl = /^(?:https?|data):/i.test(id)
const [src, setSrc] = useState<string | null>(isUrl ? id : null)
const remote = /^(?:https?|data):/i.test(id)
const [src, setSrc] = useState<string | null>(remote ? id : null)
const [failed, setFailed] = useState(false)
useEffect(() => {
if (isUrl || !id) {
if (remote || !id) {
return
}
let alive = true
// Remote gateway: the image lives on the gateway's disk, not ours — fetch
// it over the authenticated API. Local: read it straight off this disk.
const load =
window.hermesDesktop && isRemoteGateway()
? gatewayMediaDataUrl(id)
: window.hermesDesktop?.readFileDataUrl(id)
void Promise.resolve(load)
.then(url => alive && url && setSrc(url))
void window.hermesDesktop
?.readFileDataUrl(id)
.then(url => alive && setSrc(url))
.catch(() => alive && setFailed(true))
return () => {
alive = false
}
}, [id, isUrl])
}, [id, remote])
if (failed) {
return <DirectiveChip id={id} label={label} type="image" />

View File

@@ -172,33 +172,4 @@ describe('preprocessMarkdown', () => {
'<https://www.getyourguide.com/en-gb/san-juan-puerto-rico-l355/san-juan-old-san-juan-sunset-cruise-with-drinks-transfer-t405191/>'
)
})
it('does not swallow trailing emphasis asterisks into an autolinked url', () => {
const input = '**PR opened: https://github.com/NousResearch/hermes-agent/pull/12345**'
const output = preprocessMarkdown(input)
// The URL is autolinked WITHOUT the trailing `**` glued into the href,
// and the bold emphasis run stays intact so it renders as bold + a link.
expect(output).toContain('<https://github.com/NousResearch/hermes-agent/pull/12345>')
expect(output).not.toContain('pull/12345**>')
expect(output).not.toContain('12345*')
})
it('stops an autolinked url at mid-string bold markers', () => {
const input = 'See https://github.com/foo/bar**bold** for details.'
const output = preprocessMarkdown(input)
expect(output).toContain('<https://github.com/foo/bar>')
expect(output).toContain('**bold**')
})
it('keeps underscores and tildes inside autolinked url paths', () => {
const input = 'Docs at https://example.com/a_b/c~d/page'
const output = preprocessMarkdown(input)
expect(output).toContain('<https://example.com/a_b/c~d/page>')
})
})

View File

@@ -17,8 +17,6 @@ import { createMemoizedMathPlugin } from '@/lib/katex-memo'
import { preprocessMarkdown } from '@/lib/markdown-preprocess'
import {
filePathFromMediaPath,
gatewayMediaDataUrl,
isRemoteGateway,
mediaExternalUrl,
mediaKind,
mediaName,
@@ -53,12 +51,6 @@ async function mediaSrc(path: string): Promise<string> {
return mediaStreamUrl(path)
}
// Remote gateway: the image lives on the gateway machine, so read it over the
// authenticated API rather than this machine's disk.
if (window.hermesDesktop && isRemoteGateway()) {
return gatewayMediaDataUrl(path)
}
if (!window.hermesDesktop?.readFileDataUrl) {
return mediaExternalUrl(path)
}
@@ -425,7 +417,7 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
<div className="aui-md-table my-2 max-w-full overflow-x-auto rounded-[0.375rem] border border-border">
<table
className={cn(
'm-0 w-full min-w-[18rem] border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
'm-0 w-full border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
className
)}
{...props}
@@ -438,7 +430,7 @@ function MarkdownTextSurface({ containerClassName, containerProps }: MarkdownTex
th: ({ className, ...props }: ComponentProps<'th'>) => (
<th
className={cn(
'whitespace-nowrap px-2.5 py-1.5 text-left align-middle text-[0.75rem] font-medium text-muted-foreground',
'px-2.5 py-1.5 text-left align-middle text-[0.75rem] font-medium text-muted-foreground',
className
)}
{...props}

View File

@@ -489,7 +489,7 @@ describe('assistant-ui streaming renderer', () => {
expect(viewport.scrollTop).toBe(420)
})
it('does not follow streaming content growth even while parked at the bottom', async () => {
it('keeps sticky-bottom armed through viewport height changes during streaming', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
@@ -508,7 +508,6 @@ describe('assistant-ui streaming renderer', () => {
await wait(80)
// Park the user at the bottom of the current content.
await act(async () => {
viewport.scrollTop = 800
fireEvent.scroll(viewport)
@@ -521,9 +520,6 @@ describe('assistant-ui streaming renderer', () => {
fireEvent.scroll(viewport)
})
// Content grows as tokens stream in. Streaming auto-follow is removed, so
// the viewport must NOT chase the new bottom — it stays where the user
// last left it.
scrollHeight = 1_200
await act(async () => {
@@ -533,7 +529,7 @@ describe('assistant-ui streaming renderer', () => {
})
await wait(0)
expect(viewport.scrollTop).toBe(760)
expect(viewport.scrollTop).toBe(1_200)
})
it('honors the first upward wheel scroll even when a programmatic bottom-pin scroll event is still pending', async () => {
@@ -570,7 +566,7 @@ describe('assistant-ui streaming renderer', () => {
expect(viewport.scrollTop).toBe(420)
})
it('does not snap to the bottom on final code-highlight growth after a run completes', async () => {
it('keeps following final code-highlight growth when a run completes at bottom', async () => {
const { container } = render(<StreamingHarness />)
const content = container.querySelector('[data-slot="aui_thread-content"]') as HTMLDivElement
@@ -592,13 +588,10 @@ describe('assistant-ui streaming renderer', () => {
await wait(650)
// Completion re-measures (Shiki highlight) and grows the content. The
// post-run bottom lock is removed, so the viewport stays put instead of
// snapping to the new bottom.
scrollHeight = 1_700
await wait(0)
expect(viewport.scrollTop).toBe(800)
expect(viewport.scrollTop).toBe(1_700)
})
it('does not restart bottom-follow after completion when the user scrolled up', async () => {

View File

@@ -19,6 +19,7 @@ import { setThreadScrolledUp } from '@/store/thread-scroll'
const ESTIMATED_ITEM_HEIGHT = 220
const OVERSCAN = 4
const AT_BOTTOM_THRESHOLD = 4
const POST_RUN_BOTTOM_LOCK_MS = 1_200
type ThreadMessageComponents = ComponentProps<typeof ThreadPrimitive.MessageByIndex>['components']
@@ -264,27 +265,8 @@ function useThreadScrollAnchor({
return
}
// Already parked at the bottom: writing `scrollTop` is a no-op and the
// browser fires NO scroll event, so arming the programmatic gate here would
// leave it permanently set. Repeated pins (streaming heartbeats, the
// post-run lock loop) then accumulate the gate, and the next genuine user
// scroll-up is misread as one of our programmatic scrolls — re-arming
// sticky-bottom and yanking the viewport back down. Refresh trackers, bail.
const distFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight)
if (distFromBottom <= AT_BOTTOM_THRESHOLD) {
lastTopRef.current = el.scrollTop
lastHeightRef.current = el.scrollHeight
lastClientHeightRef.current = el.clientHeight
return
}
// Hold the disarm gate across the scroll event the next line will fire.
// Set to 1 rather than incrementing: coalesced writes within a frame fire a
// single scroll event, so a counter > 1 can never drain and would swallow a
// later real user scroll.
programmaticScrollPendingRef.current = 1
programmaticScrollPendingRef.current += 1
scrollElementToBottom(el)
lastTopRef.current = el.scrollTop
lastHeightRef.current = el.scrollHeight
@@ -387,15 +369,51 @@ function useThreadScrollAnchor({
}
}, [scrollerRef, stickyBottomRef])
// Intentionally NO streaming auto-follow. Earlier builds ran a
// ResizeObserver here that re-pinned the viewport to the bottom on every
// content growth while a turn was running, so the chat tracked tokens as
// they streamed. That behavior is removed by request: once a turn is in
// flight the viewport stays exactly where the user left it. The viewport
// is still moved to the bottom ONCE per user submit / new turn / session
// change (see the layout effect and the session-change effect below) so a
// freshly submitted message lands in view — but it does not chase the
// stream afterward.
// Follow content growth (streaming, item measurements, loading indicator)
// while armed. During fast streaming the ResizeObserver can fire many
// times per frame as Streamdown re-tokenizes; coalesce to one pin per
// animation frame so we don't run the scroll-event/re-pin chain
// (~20+ ms self in `Virtualizer.getMaxScrollOffset`) several times per
// token.
useEffect(() => {
if (!enabled || !isRunning) {
return undefined
}
const el = scrollerRef.current
if (!el) {
return undefined
}
let pinRafScheduled = false
const schedulePin = () => {
if (pinRafScheduled || !stickyBottomRef.current) {
return
}
pinRafScheduled = true
requestAnimationFrame(() => {
pinRafScheduled = false
if (stickyBottomRef.current) {
pinToBottom()
}
})
}
const observer = new ResizeObserver(schedulePin)
// Observe ONLY the content (firstElementChild), not the scroller `el`
// itself. Resizes of the viewport/scroller (window resize, devtools
// panel toggle) shouldn't trigger a pin — only content growth should.
if (el.firstElementChild) {
observer.observe(el.firstElementChild)
}
return () => observer.disconnect()
}, [enabled, isRunning, pinToBottom, scrollerRef, stickyBottomRef])
// Jump to bottom on session change OR when an empty thread first gets
// content. Both share the same intent and the same effect.
@@ -411,21 +429,22 @@ function useThreadScrollAnchor({
}
}, [enabled, groupCount, jumpToBottom, sessionKey])
// Pre-paint pin: when groupCount increases while armed (a new turn arriving
// from the user submit or assistant turn start), pin BEFORE the browser
// commits the layout to screen. Using useLayoutEffect rather than useEffect
// so this runs synchronously after React commits the DOM mutation but before
// the browser paints. Without this, there's a ~50ms visual window where the
// new message sits below the fold.
// Pre-paint pin: when groupCount increases while armed (optimistic user
// message insert, streaming assistant turn arriving, etc.), pin BEFORE
// the browser commits the layout to screen. Using useLayoutEffect rather
// than useEffect so this runs synchronously after React commits the DOM
// mutation but before the browser paints. Without this, there's a ~50ms
// visual window where the new message sits below the fold while we wait
// for the ResizeObserver / scroll event chain to fire and re-pin.
//
// We pin TWICE in this critical path — once synchronously, then once on
// the next rAF. The second pin catches the case where React mounts the
// new message in the second commit (after our layout effect ran), which
// grows scrollHeight again; without the rAF pin the user briefly sees a
// ~15 px gap below the new message. This fires once per user submit / new
// turn arrival — it is NOT streaming-token follow (that path is removed
// above), so a turn that streams a long response after this initial jump
// will not chase the bottom.
// ~15 px gap below the new message until the RO catches up. Streaming
// tokens use the rate-limited RO path only; only the group-count change
// (which fires once per user submit / new turn arrival) pays for the
// extra pin.
const prevGroupCountForLayoutRef = useRef(groupCount)
useLayoutEffect(() => {
if (!enabled) {
@@ -449,17 +468,45 @@ function useThreadScrollAnchor({
prevGroupCountForLayoutRef.current = groupCount
}, [enabled, groupCount, pinToBottom, stickyBottomRef])
// Intentionally NO post-run bottom lock. Earlier builds kept pinning to
// the bottom for POST_RUN_BOTTOM_LOCK_MS after `isRunning` flipped false to
// chase final Shiki re-highlight measurement. With streaming follow gone,
// re-pinning at completion would yank the viewport back to the bottom even
// though the user is reading earlier content — the opposite of what's
// wanted. The one-time submit / new-turn jump already covers landing a
// fresh message in view.
// Completion swaps streaming placeholders/plain code for final rendered DOM
// (notably Shiki-highlighted code). Keep following the bottom briefly after
// `isRunning` flips false so that final measurement pass cannot strand the
// viewport near the top of a large code block.
const prevIsRunningForLayoutRef = useRef(isRunning)
useLayoutEffect(() => {
const finishedRun = prevIsRunningForLayoutRef.current && !isRunning
prevIsRunningForLayoutRef.current = isRunning
}, [isRunning])
if (!enabled || !finishedRun || !stickyBottomRef.current) {
return undefined
}
const lockUntil = performance.now() + POST_RUN_BOTTOM_LOCK_MS
let lockRaf: number | null = null
const lockFrame = () => {
lockRaf = null
if (!stickyBottomRef.current) {
return
}
pinToBottom()
if (performance.now() < lockUntil) {
lockRaf = requestAnimationFrame(lockFrame)
}
}
pinToBottom()
lockRaf = requestAnimationFrame(lockFrame)
return () => {
if (lockRaf !== null) {
cancelAnimationFrame(lockRaf)
}
}
}, [enabled, isRunning, pinToBottom, stickyBottomRef])
useAuiEvent('thread.runStart', jumpToBottom)
}

View File

@@ -77,7 +77,6 @@ import type { HermesGateway } from '@/hermes'
import { useResizeObserver } from '@/hooks/use-resize-observer'
import { useI18n } from '@/i18n'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { LinkifiedText } from '@/lib/external-link'
import { triggerHaptic } from '@/lib/haptics'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets'
@@ -151,7 +150,10 @@ export const Thread: FC<{
)
const emptyPlaceholder = intro ? (
<div className="flex min-h-0 w-full flex-col items-center justify-center pt-[var(--composer-measured-height)]">
<div
className="flex min-h-0 w-full flex-col items-center justify-center"
style={{ paddingBottom: 'var(--composer-measured-height)' }}
>
<Intro {...intro} />
</div>
) : undefined
@@ -468,7 +470,9 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
s =>
s.thread.isRunning &&
s.message.status?.type === 'running' &&
s.message.parts.slice(Math.max(0, startIndex)).some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
s.message.parts
.slice(Math.max(0, startIndex))
.some(p => p?.type === 'reasoning' && p.status?.type !== 'complete')
)
// A reasoning group with no actual text is pure noise — drop the whole
@@ -920,7 +924,7 @@ const SystemMessage: FC = () => {
>
<span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
<span className="mx-1.5 text-muted-foreground/35">·</span>
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={slashStatus.groups.output.trim()} />
<span className="whitespace-pre-wrap">{slashStatus.groups.output.trim()}</span>
</MessagePrimitive.Root>
)
}
@@ -931,7 +935,7 @@ const SystemMessage: FC = () => {
data-role="system"
data-slot="aui_system-message-root"
>
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={text} />
<span className="whitespace-pre-wrap">{text}</span>
</MessagePrimitive.Root>
)
}

View File

@@ -90,7 +90,6 @@ const TOOL_META: Record<string, ToolMeta> = {
},
browser_type: { done: 'Typed on page', pending: 'Typing on page', icon: 'globe', tone: 'browser' },
clarify: { done: 'Asked a question', pending: 'Asking a question', icon: 'question', tone: 'agent' },
cronjob: { done: 'Cron job', pending: 'Scheduling cron job', icon: 'watch', tone: 'agent' },
edit_file: { done: 'Edited file', pending: 'Editing file', icon: 'edit', tone: 'file' },
execute_code: { done: 'Ran code', pending: 'Running code', icon: 'terminal', tone: 'terminal' },
image_generate: { done: 'Generated image', pending: 'Generating image', icon: 'file-media', tone: 'image' },
@@ -900,80 +899,6 @@ function fallbackDetailText(args: unknown, result: unknown): string {
return formatToolResultSummary(args) || minimalValueSummary(args)
}
function cronScalar(value: unknown): string {
if (typeof value === 'string') return value.trim()
if (typeof value === 'number' && Number.isFinite(value)) return String(value)
return ''
}
function formatCronTime(iso: string): string {
const ts = Date.parse(iso)
if (Number.isNaN(ts)) return iso
return new Date(ts).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
function cronjobSubtitle(
argsRecord: Record<string, unknown>,
resultRecord: Record<string, unknown>
): string {
const jobs = Array.isArray(resultRecord.jobs) ? resultRecord.jobs : null
if (jobs) {
return jobs.length ? `${jobs.length} cron job${jobs.length === 1 ? '' : 's'}` : 'No cron jobs'
}
const message = firstStringField(resultRecord, ['message'])
if (message) return message
const action = firstStringField(argsRecord, ['action']) || 'manage'
const name = firstStringField(resultRecord, ['name']) || firstStringField(argsRecord, ['name', 'job_id'])
const label = `${action[0]?.toUpperCase() ?? ''}${action.slice(1)}`
return name ? `${label} ${name}` : `Cron ${action}`
}
function cronjobDetail(
argsRecord: Record<string, unknown>,
resultRecord: Record<string, unknown>
): string {
const jobs = Array.isArray(resultRecord.jobs) ? resultRecord.jobs : null
if (jobs) {
if (!jobs.length) return 'No cron jobs scheduled'
return jobs
.slice(0, 20)
.map(job => {
const row = isRecord(job) ? job : {}
const name = firstStringField(row, ['name', 'id']) || 'job'
const sched = firstStringField(row, ['schedule_display', 'schedule'])
return sched ? `- ${name} · ${sched}` : `- ${name}`
})
.join('\n')
}
const nextRun = cronScalar(resultRecord.next_run_at)
const rows: [string, string][] = [
['Schedule', cronScalar(resultRecord.schedule)],
['Repeat', cronScalar(resultRecord.repeat)],
['Delivery', cronScalar(resultRecord.deliver)],
['Next run', nextRun ? formatCronTime(nextRun) : '']
]
const lines = rows.filter(([, value]) => value).map(([key, value]) => `${key}: ${value}`)
return lines.length ? lines.join('\n') : fallbackDetailText(argsRecord, resultRecord)
}
function toolSubtitle(
part: ToolPart,
argsRecord: Record<string, unknown>,
@@ -1067,10 +992,6 @@ function toolSubtitle(
return url ? hostnameOf(url) : 'Fetched webpage'
}
if (toolName === 'cronjob') {
return cronjobSubtitle(argsRecord, resultRecord)
}
return (
compactPreview(formatToolResultSummary(part.result), 120) ||
compactPreview(resultRecord, 120) ||
@@ -1171,10 +1092,6 @@ function toolDetailText(
.replace(/\bDuration\s+S\s*:/gi, 'Duration:')
}
if (part.toolName === 'cronjob') {
return cronjobDetail(argsRecord, resultRecord)
}
return fallbackDetailText(argsRecord, resultRecord)
}

View File

@@ -2,11 +2,9 @@ import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import { ErrorIcon } from '@/components/ui/error-state'
import { LogView } from '@/components/ui/log-view'
import type { DesktopConnectionConfig } from '@/global'
import { useI18n } from '@/i18n'
import { FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons'
import { AlertTriangle, FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons'
import { $desktopBoot } from '@/store/boot'
import { notify, notifyError } from '@/store/notifications'
import { $desktopOnboarding } from '@/store/onboarding'
@@ -174,9 +172,11 @@ export function BootFailureOverlay() {
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous">
<div className="flex items-start gap-3 px-5 py-4">
<ErrorIcon className="mt-0.5" size="1.25rem" />
<div className="w-full max-w-[40rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background) shadow-sm">
<div className="flex items-start gap-3 border-b border-(--ui-stroke-tertiary) px-5 py-4">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10 text-destructive">
<AlertTriangle className="size-5" />
</div>
<div>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">
{remoteReauth ? copy.remoteTitle : copy.title}
@@ -196,27 +196,27 @@ export function BootFailureOverlay() {
<div className="flex flex-wrap gap-2">
{remoteReauth ? (
<Button disabled={Boolean(busy)} onClick={() => void signInRemote()}>
{busy === 'signin' ? <Loader2 className="animate-spin" /> : <LogIn />}
{busy === 'signin' ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
{label}
</Button>
) : (
<Button disabled={Boolean(busy)} onClick={() => void retry()}>
{busy === 'retry' ? <Loader2 className="animate-spin" /> : <RefreshCw />}
{busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
{copy.retry}
</Button>
)}
{!remoteReauth ? (
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="secondary">
{busy === 'repair' ? <Loader2 className="animate-spin" /> : <Wrench />}
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline">
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />}
{copy.repairInstall}
</Button>
) : null}
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="secondary">
{busy === 'local' ? <Loader2 className="animate-spin" /> : null}
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="outline">
{busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null}
{copy.useLocalGateway}
</Button>
<Button onClick={openLogs} variant="ghost">
<FileText />
<FileText className="size-4" />
{copy.openLogs}
</Button>
</div>
@@ -227,16 +227,18 @@ export function BootFailureOverlay() {
{logs.length > 0 ? (
<div className="grid gap-2">
<Button
className="-ml-2 self-start font-medium"
<button
className="self-start text-xs font-medium text-muted-foreground transition hover:text-foreground"
onClick={() => setShowLogs(v => !v)}
size="xs"
type="button"
variant="text"
>
{showLogs ? copy.hideRecentLogs : copy.showRecentLogs}
</Button>
{showLogs ? <LogView className="max-h-48">{logs.slice(-40).join('')}</LogView> : null}
</button>
{showLogs ? (
<pre className="max-h-48 overflow-auto rounded-2xl border border-border bg-secondary/30 p-3 font-mono text-[0.7rem] leading-4 text-muted-foreground">
{logs.slice(-40).join('')}
</pre>
) : null}
</div>
) : null}
</div>

View File

@@ -1,19 +0,0 @@
import { cn } from '@/lib/utils'
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
// Brand badge: nous-girl mark on a white tile, identical in light/dark.
// Fills the tile (softly rounded); size via className (default size-14).
export function BrandMark({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
className={cn(
'inline-flex size-14 shrink-0 items-center justify-center overflow-hidden rounded-md bg-white',
className
)}
{...props}
>
<img alt="" className="size-full object-contain" src={assetPath('nous-girl.jpg')} />
</span>
)
}

View File

@@ -160,14 +160,14 @@ export function Intro({ personality, seed }: IntroProps) {
return (
<div
className="pointer-events-none flex w-full min-w-0 flex-col items-center justify-center px-0.5 py-6 text-center text-muted-foreground sm:px-6 lg:px-8"
className="pointer-events-none flex w-full min-w-0 flex-col items-center justify-center px-3 py-6 text-center text-muted-foreground sm:px-6 lg:px-8"
data-slot="aui_intro"
>
<div className="w-full min-w-0">
<p
aria-label={WORDMARK}
className="fit-text mx-auto mb-1 w-[calc(100%-1rem)] font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
style={{ '--fit-min': '2.75rem' } as CSSProperties}
className="fit-text mx-auto mb-3 w-[88%] font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
style={{ '--fit-text-line-height': '0.9', '--fit-text-min': '2.75rem' } as CSSProperties}
>
<span>
<span>{WORDMARK}</span>

View File

@@ -1,8 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Loader } from '@/components/ui/loader'
import { LogView } from '@/components/ui/log-view'
import type {
DesktopBootstrapEvent,
DesktopBootstrapStageDescriptor,
@@ -352,7 +350,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md">
<div className="w-full max-w-xl rounded-xl border border-(--stroke-nous) bg-card p-8 shadow-nous">
<div className="w-full max-w-xl rounded-xl border bg-card p-8 shadow-xl">
<h2 className="text-2xl font-semibold tracking-tight">{copy.oneTimeTitle}</h2>
<p className="mt-2 text-sm text-muted-foreground">
{copy.unsupportedDesc(platformLabel)}
@@ -413,7 +411,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
return (
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md p-4">
<div className="flex w-full max-w-2xl max-h-[90vh] flex-col rounded-xl border border-(--stroke-nous) bg-card shadow-nous">
<div className="flex w-full max-w-2xl max-h-[90vh] flex-col rounded-xl border bg-card shadow-xl">
{/* Header -- always visible, never scrolls */}
<div className="flex-shrink-0 p-8 pb-4">
<h2 className="text-2xl font-semibold tracking-tight">
@@ -446,8 +444,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
)}
{totalCount === 0 && state.active && (
<div className="mb-4 flex items-center gap-2.5 text-sm text-muted-foreground">
<Loader className="size-5" type="lemniscate-bloom" />
<div className="mb-4 flex items-center gap-2 rounded-md border border-dashed bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span>{copy.fetchingManifest}</span>
</div>
)}
@@ -476,44 +474,53 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</ol>
)}
<div className="pt-3">
<Button
className="-ml-2 text-muted-foreground hover:text-foreground"
<div className="border-t pt-3">
<button
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setLogOpen(v => !v)}
size="xs"
type="button"
variant="ghost"
>
{logOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<span>{logOpen ? copy.hideOutput : copy.showOutput}</span>
<span className="ml-1 tabular-nums">
({copy.lines(state.log.length)})
</span>
</Button>
</button>
{logOpen && (
<LogView className={cn('mt-2', failed ? 'max-h-96' : 'max-h-64')}>
<div
className={cn(
'mt-2 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed',
failed ? 'max-h-96' : 'max-h-64'
)}
>
{state.log.length === 0 ? (
<div>{copy.noOutput}</div>
<div className="text-muted-foreground">{copy.noOutput}</div>
) : (
<>
{state.log.map((entry, i) => (
<div className={cn(entry.stream === 'stderr' && 'text-muted-foreground/70')} key={i}>
{entry.stage ? <span className="text-muted-foreground/60">[{entry.stage}] </span> : null}
<div
className={cn(
'whitespace-pre-wrap break-words',
entry.stream === 'stderr' && 'text-muted-foreground'
)}
key={i}
>
{entry.stage ? <span className="text-muted-foreground/70">[{entry.stage}] </span> : null}
<span>{entry.line}</span>
</div>
))}
<div ref={logEndRef} />
</>
)}
</LogView>
</div>
)}
</div>
</div>
{/* Active footer: let the user actually cancel a running install. */}
{state.active && !failed && (
<div className="flex-shrink-0 bg-card p-4">
<div className="flex-shrink-0 border-t bg-card p-4">
<div className="flex items-center justify-end">
<Button
disabled={cancelling}
@@ -538,7 +545,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
{/* Footer -- always visible, never scrolls; only renders on failure */}
{failed && (
<div className="flex-shrink-0 bg-card p-4">
<div className="flex-shrink-0 border-t bg-card p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{copy.transcriptSaved}{' '}

View File

@@ -5,9 +5,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { ModelPickerDialog } from '@/components/model-picker'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { ErrorIcon } from '@/components/ui/error-state'
import { Input } from '@/components/ui/input'
import { Loader } from '@/components/ui/loader'
import { getGlobalModelOptions } from '@/hermes'
import { useI18n } from '@/i18n'
import {
@@ -18,6 +16,7 @@ import {
ExternalLink,
KeyRound,
Loader2,
Sparkles,
Terminal
} from '@/lib/icons'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
@@ -31,8 +30,6 @@ import {
confirmOnboardingModel,
copyDeviceCode,
copyExternalCommand,
DEFAULT_MANUAL_ONBOARDING_REASON,
DEFAULT_ONBOARDING_REASON,
dismissFirstRunOnboarding,
type OnboardingContext,
type OnboardingFlow,
@@ -186,11 +183,6 @@ const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
export const sortProviders = (providers: OAuthProvider[]) =>
[...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name))
// Exit choreography, mirroring the gateway "connecting" overlay's timing:
// text-out (360ms: CONNECTED fades down, rest scrambles+fades) → hold (300ms)
// → surface-out (520ms, held back by [transition-delay:660ms]). Finalize after.
const ONBOARDING_EXIT_MS = 1180
export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) {
const { t } = useI18n()
const onboarding = useStore($desktopOnboarding)
@@ -206,29 +198,6 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
[]
)
// Cinematic exit on "Begin": dissolve the panel + overlay (revealing the chat
// behind), THEN finalize so the unmount lands after the fade — mirrors the
// connecting overlay's exit choreography instead of cutting instantly.
const [leaving, setLeaving] = useState(false)
const finalizeOnboarding = () => {
if (leaving) {
return
}
const reduce =
typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
if (reduce) {
confirmOnboardingModel(ctx)
return
}
setLeaving(true)
window.setTimeout(() => confirmOnboardingModel(ctx), ONBOARDING_EXIT_MS)
}
useEffect(() => {
if (enabled || onboarding.requested) {
void refreshOnboarding(ctx)
@@ -282,52 +251,18 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
}
const { flow } = onboarding
// Show the launch reason only when it's a meaningful, caller-supplied prompt —
// suppress the generic defaults (useless noise) and provider-setup errors
// (those are surfaced by FlowPanel, not as a banner).
const rawReason = onboarding.reason?.trim() || null
const reason =
rawReason &&
!isProviderSetupErrorMessage(rawReason) &&
rawReason !== DEFAULT_ONBOARDING_REASON &&
rawReason !== DEFAULT_MANUAL_ONBOARDING_REASON
? rawReason
: null
const reason = rawReason && !isProviderSetupErrorMessage(rawReason) ? rawReason : null
// In manual mode the app is already configured, so the flow is "ready"
// immediately — no runtime gate needed. Otherwise wait for the readiness
// check (configured === false) before showing the picker.
const ready = onboarding.manual || (enabled && onboarding.configured === false)
const showPicker = flow.status === 'idle' || flow.status === 'success'
// The final "you're in" screen drops the card chrome and floats centered on
// the surface — same bare, cinematic treatment as the connecting overlay.
const bare = ready && !showPicker && flow.status === 'confirming_model'
return (
<div
className={cn(
'fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6 transition-opacity duration-[520ms] ease-out',
// On the bare confirm screen, hold the surface (text-out + hold) so the
// per-element exit plays before it dissolves.
bare && leaving ? '[transition-delay:660ms]' : '',
leaving ? 'pointer-events-none opacity-0' : 'opacity-100'
)}
>
<div
className={cn(
'relative w-full max-w-[45rem] transition-all duration-500 ease-out',
bare
? ''
: 'overflow-hidden rounded-xl border border-(--stroke-nous) bg-(--ui-chat-bubble-background) shadow-nous',
// Bare confirm screen orchestrates its own per-element exit; the
// carded states use the simple lift/blur dissolve.
leaving && !bare
? '-translate-y-1 scale-[0.985] opacity-0 blur-[2px]'
: 'translate-y-0 scale-100 opacity-100 blur-0'
)}
>
{showPicker || !ready ? <Header /> : null}
<div className="fixed inset-0 z-1300 flex items-center justify-center bg-(--ui-chat-surface-background) p-6">
<div className="relative w-full max-w-[45rem] overflow-hidden rounded-xl border border-(--ui-stroke-secondary) bg-(--ui-chat-bubble-background)">
<Header />
{onboarding.manual ? (
<Button
aria-label={t.common.close}
@@ -341,24 +276,16 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
) : null}
<div className="grid gap-3 p-5">
{reason ? <ReasonNotice reason={reason} /> : null}
{ready ? (
showPicker ? (
<Picker ctx={ctx} />
) : (
<FlowPanel ctx={ctx} flow={flow} leaving={leaving} onBegin={finalizeOnboarding} />
)
) : (
<Preparing boot={boot} />
)}
{ready ? showPicker ? <Picker ctx={ctx} /> : <FlowPanel ctx={ctx} flow={flow} /> : <Preparing boot={boot} />}
</div>
</div>
</div>
)
}
// The launch reason is a prompt ("why am I seeing this"), not an error. Only
// rendered for meaningful caller-supplied reasons (defaults are filtered out
// upstream), so it never shows the generic "no provider configured" noise.
// The launch reason is a prompt ("why am I seeing this"), not an error — real
// provider-setup failures are filtered out upstream and surfaced by FlowPanel.
// Keep it neutral so it never reads as a failure.
function ReasonNotice({ reason }: { reason: string }) {
return (
<div className="rounded-2xl border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/40 px-4 py-3 text-sm text-muted-foreground">
@@ -400,9 +327,18 @@ function Header() {
const { t } = useI18n()
return (
<div className="bg-(--ui-chat-bubble-background) px-5 pt-5 pb-1">
<h2 className="text-[0.9375rem] font-semibold tracking-tight">{t.onboarding.headerTitle}</h2>
<p className="mt-1 max-w-xl text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">{t.onboarding.headerDesc}</p>
<div className="border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-bubble-background) px-5 py-4">
<div className="flex items-start gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)">
<Sparkles className="size-5" />
</div>
<div>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">{t.onboarding.headerTitle}</h2>
<p className="mt-1 max-w-xl text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
{t.onboarding.headerDesc}
</p>
</div>
</div>
</div>
)
}
@@ -480,31 +416,27 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
) : null}
</div>
{collapsible ? (
<Button
className="mt-1 self-center font-medium"
<button
className="flex items-center justify-center gap-1.5 pt-1 text-xs font-medium text-muted-foreground transition hover:text-foreground"
onClick={() => setShowAll(persistShowAll(!showAll))}
size="xs"
type="button"
variant="text"
>
{showAll ? t.onboarding.collapse : t.onboarding.otherProviders}
<ChevronDown className={cn('size-3.5 transition', showAll && 'rotate-180')} />
</Button>
</button>
) : null}
<div className="flex items-center justify-between gap-3 pt-1">
{/* First run only: let the user defer the choice and land in the app.
In manual mode the overlay already has a close affordance, so the
"choose later" escape would be redundant — hide it. */}
{manual ? <span /> : <ChooseLaterLink />}
<Button
className="-mr-2 font-medium"
<button
className="text-xs font-medium text-muted-foreground hover:text-foreground"
onClick={() => setOnboardingMode('apikey')}
size="xs"
type="button"
variant="text"
>
{t.onboarding.haveApiKey}
</Button>
</button>
</div>
</div>
)
@@ -517,15 +449,13 @@ function ChooseLaterLink() {
const { t } = useI18n()
return (
<Button
className="font-medium"
<button
className="text-xs font-medium text-muted-foreground hover:text-foreground"
onClick={() => dismissFirstRunOnboarding()}
size="xs"
type="button"
variant="text"
>
{t.onboarding.chooseLater}
</Button>
</button>
)
}
@@ -579,14 +509,15 @@ function ConnectedTag() {
)
}
const PROVIDER_ROW_CLASS =
'group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)'
export function KeyProviderRow({ onClick }: { onClick: () => void }) {
const { t } = useI18n()
return (
<button className={PROVIDER_ROW_CLASS} onClick={onClick} type="button">
<button
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
onClick={onClick}
type="button"
>
<div className="min-w-0">
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">OpenRouter</span>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{t.onboarding.openRouterPitch}</p>
@@ -608,7 +539,11 @@ export function ProviderRow({
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
return (
<button className={PROVIDER_ROW_CLASS} onClick={() => onSelect(provider)} type="button">
<button
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
onClick={() => onSelect(provider)}
type="button"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
@@ -707,19 +642,17 @@ export function ApiKeyForm({
return (
<div className="grid gap-4">
{canGoBack ? (
<Button
className="-mt-1 self-start font-medium"
<button
className="-mt-1 flex items-center gap-1 self-start text-xs font-medium text-muted-foreground hover:text-foreground"
onClick={onBack}
size="xs"
type="button"
variant="text"
>
<ChevronLeft className="size-3" />
{t.onboarding.backToSignIn}
</Button>
</button>
) : null}
<div className="grid max-h-[42dvh] gap-2 overflow-y-auto p-1 sm:grid-cols-2">
<div className="grid max-h-[60dvh] gap-2 overflow-y-auto p-1 sm:grid-cols-2">
{options.map(o => (
<button
className={cn(
@@ -771,7 +704,7 @@ export function ApiKeyForm({
) : null}
</div>
<Button disabled={!canSave || saving} onClick={() => void submit()}>
{saving ? <Loader2 className="animate-spin" /> : <KeyRound />}
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
{saving ? t.onboarding.connecting : alreadySet ? t.onboarding.update : t.common.connect}
</Button>
</div>
@@ -779,17 +712,7 @@ export function ApiKeyForm({
)
}
function FlowPanel({
ctx,
flow,
leaving,
onBegin
}: {
ctx: OnboardingContext
flow: OnboardingFlow
leaving: boolean
onBegin: () => void
}) {
function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow }) {
const { t } = useI18n()
const title = 'provider' in flow && flow.provider ? providerTitle(flow.provider) : ''
@@ -803,20 +726,22 @@ function FlowPanel({
if (flow.status === 'success') {
return (
<DecodedLabel text={t.onboarding.connectedPicking(title)} />
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
<Check className="size-4" />
{t.onboarding.connectedPicking(title)}
</div>
)
}
if (flow.status === 'confirming_model') {
return <ConfirmingModelPanel flow={flow} leaving={leaving} onBegin={onBegin} />
return <ConfirmingModelPanel ctx={ctx} flow={flow} />
}
if (flow.status === 'error') {
return (
<div className="grid gap-3">
<div className="flex items-center gap-1.5 text-sm text-destructive">
<ErrorIcon className="shrink-0" size="0.875rem" />
<span>{flow.message || t.onboarding.signInFailed}</span>
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{flow.message || t.onboarding.signInFailed}
</div>
<div className="flex justify-end">
<Button onClick={cancelOnboardingFlow} variant="outline">
@@ -880,7 +805,10 @@ function FlowPanel({
}
>
<CancelBtn />
<Button onClick={() => void recheckExternalSignin(ctx)}>{t.onboarding.signedIn}</Button>
<Button onClick={() => void recheckExternalSignin(ctx)}>
<Check className="size-4" />
{t.onboarding.signedIn}
</Button>
</FlowFooter>
</Step>
)
@@ -893,7 +821,7 @@ function FlowPanel({
return (
<Step title={t.onboarding.signInWith(title)}>
<p className="text-sm text-muted-foreground">{t.onboarding.deviceCodeOpened(title)}</p>
<DeviceCode code={flow.start.user_code} copied={flow.copied} onCopy={() => void copyDeviceCode()} />
<CodeBlock copied={flow.copied} large onCopy={() => void copyDeviceCode()} text={flow.start.user_code} />
<FlowFooter left={<DocsLink href={flow.start.verification_url}>{t.onboarding.reopenVerification}</DocsLink>}>
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
@@ -914,53 +842,24 @@ function Step({ children, title }: { children: React.ReactNode; title: string })
)
}
// Device-code display: OTP-style — each character in its own readonly cell.
// The whole row is the copy button (no side button, no checkmark); on copy the
// cells flash emerald for feedback. Dashes render as quiet separators.
function DeviceCode({ code, copied, onCopy }: { code: string; copied: boolean; onCopy: () => void }) {
function CodeBlock({
copied,
large,
onCopy,
text
}: {
copied: boolean
large?: boolean
onCopy: () => void
text: string
}) {
const { t } = useI18n()
return (
<button
aria-label={t.onboarding.copy}
className="group flex w-full items-center justify-center gap-1.5"
onClick={onCopy}
type="button"
>
{[...code].map((ch, i) =>
ch === '-' || ch === ' ' ? (
<span className="w-1.5 text-center text-lg text-muted-foreground" key={i}>
</span>
) : (
<span
className={cn(
'flex size-10 items-center justify-center rounded-md border font-mono text-xl font-semibold uppercase transition-colors',
copied
? 'border-primary/50 text-primary'
: 'border-(--stroke-nous) text-foreground group-hover:border-(--ui-stroke-secondary)'
)}
key={i}
>
{ch}
</span>
)
)}
</button>
)
}
function CodeBlock({ copied, onCopy, text }: { copied: boolean; onCopy: () => void; text: string }) {
const { t } = useI18n()
return (
<div className="flex items-center justify-between gap-3 rounded-md border border-(--stroke-nous) px-3 py-2">
<code className="min-w-0 flex-1 truncate font-mono text-sm">
<span className="mr-2 select-none text-muted-foreground">$</span>
{text}
</code>
<div className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-secondary/30 px-4 py-3">
<code className={cn('font-mono', large ? 'text-2xl tracking-[0.4em]' : 'text-sm')}>{text}</code>
<Button onClick={onCopy} size="sm" variant="outline">
{copied ? t.common.copied : t.onboarding.copy}
{copied ? <Check className="size-4" /> : t.onboarding.copy}
</Button>
</div>
)
@@ -985,184 +884,14 @@ function CancelBtn({ size = 'default' }: { size?: 'default' | 'sm' }) {
)
}
// Borrowed from the gateway "connecting" overlay: a mono, letter-spaced label
// that decodes left-to-right from scrambled glyphs into the real text, with a
// blinking block cursor. Ties onboarding's success moment to that same motif.
// Cuneiform glyphs (array, since each is a surrogate pair) for the scramble.
// Hero "X CONNECTED" decode uses the SAME ascii map as the connecting overlay.
const ASCII_GLYPHS = [...'/\\|-_=+<>~:*']
const pickAscii = () => ASCII_GLYPHS[(Math.random() * ASCII_GLYPHS.length) | 0]
// Cuneiform is reserved for the subtle "other text" (model name + BEGIN) easter egg.
const SCRAMBLE_GLYPHS = [...'𒀀𒀁𒀂𒀅𒀊𒀖𒀜𒀭𒀲𒀸𒁀𒁉𒁒𒁕𒁹𒂊𒃻𒄆𒄴𒅀𒆍𒇽𒈨𒉡']
const GLYPH_SET = new Set(SCRAMBLE_GLYPHS)
const pickGlyph = () => SCRAMBLE_GLYPHS[(Math.random() * SCRAMBLE_GLYPHS.length) | 0]
// How many trailing characters of each word scramble during decode-in.
const DECODE_TAIL = 4
// Renders text where cuneiform scramble-glyphs are dropped to a smaller em-size
// (resolved Latin chars stay full size) — keeps the easter-egg glyphs subtle.
function GlyphText({ text }: { text: string }) {
return (
<>
{Array.from(text, (ch, i) =>
GLYPH_SET.has(ch) ? (
<span className="text-[0.62em]" key={i}>
{ch}
</span>
) : (
ch
)
)}
</>
)
}
function useDecoded(text: string): string {
const [out, setOut] = useState(text)
useEffect(() => {
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {
setOut(text)
return
}
// Each WORD keeps its head static and only churns its tail (last few chars),
// resolving left-to-right across all tails — same anchor-the-prefix trick the
// connecting overlay uses ("CONN" static, "ECTING" churns), applied per word
// so both the provider and "CONNECTED" decode and time stays constant.
const chars = [...text]
const scrambleable = chars.map(() => false)
for (let i = 0; i < chars.length; ) {
if (!/[a-z0-9]/i.test(chars[i])) {
i += 1
continue
}
let j = i
while (j < chars.length && /[a-z0-9]/i.test(chars[j])) {
j += 1
}
for (let k = Math.max(i, j - DECODE_TAIL); k < j; k += 1) {
scrambleable[k] = true
}
i = j
}
const tailIndices = chars.map((_, idx) => idx).filter(idx => scrambleable[idx])
let resolved = 0
const id = window.setInterval(() => {
resolved += 0.5
const settled = new Set(tailIndices.slice(0, Math.floor(resolved)))
setOut(chars.map((ch, idx) => (scrambleable[idx] && !settled.has(idx) ? pickAscii() : ch)).join(''))
if (Math.floor(resolved) >= tailIndices.length) {
window.clearInterval(id)
}
}, 45)
return () => window.clearInterval(id)
}, [text])
return out
}
// Continuously scrambles alphanumeric chars while `active` (used on exit so the
// model name / button decay into ascii noise as they fade).
function useScramble(text: string, active: boolean): string {
const [out, setOut] = useState(text)
useEffect(() => {
if (!active) {
setOut(text)
return
}
const id = window.setInterval(() => {
setOut(Array.from(text, ch => (/[a-z0-9]/i.test(ch) ? pickGlyph() : ch)).join(''))
}, 45)
return () => window.clearInterval(id)
}, [text, active])
return out
}
function DecodedLabel({ leaving, text }: { leaving?: boolean; text: string }) {
const decoded = useDecoded(text.toUpperCase())
return (
<span
className={cn(
'inline-flex items-center font-mono text-xs font-semibold uppercase tracking-[0.28em] tabular-nums text-primary transition duration-[360ms] ease-out',
leaving ? 'translate-y-2 opacity-0 saturate-0' : 'translate-y-0 opacity-100 saturate-100'
)}
>
<GlyphText text={decoded} />
<span
aria-hidden="true"
className="dither ml-1.5 -mr-[0.875rem] inline-block size-2 shrink-0 -translate-y-px rounded-[1px] text-primary"
style={{ animation: 'ob-decode-cursor 1s step-end infinite' }}
/>
<style>{'@keyframes ob-decode-cursor { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }'}</style>
</span>
)
}
// Terminal-flavored CTA to match the connecting overlay's hacker aesthetic:
// mono, uppercase, letter-spaced, wrapped in primary brackets that light up on
// hover. The whole onboarding "you're in" moment leans into this motif.
function HackeryButton({
disabled,
label,
loading,
onClick
}: {
disabled?: boolean
label: React.ReactNode
loading?: boolean
onClick: () => void
}) {
return (
<button
className={cn(
'group inline-flex items-center gap-2 rounded-md border border-(--stroke-nous) px-6 py-2.5',
'font-mono text-xs font-semibold uppercase text-primary',
'transition-all duration-150 hover:border-primary/60 hover:bg-primary/[0.06]',
'disabled:pointer-events-none disabled:opacity-50'
)}
disabled={disabled}
onClick={onClick}
type="button"
>
<span className="text-primary/40 transition-colors group-hover:text-primary">[</span>
{loading ? <Loader2 className="size-3 animate-spin" /> : null}
<span className="-mr-[0.25em] pl-[0.25em] tracking-[0.25em]">{label}</span>
<span className="text-primary/40 transition-colors group-hover:text-primary">]</span>
</button>
)
}
function ConfirmingModelPanel({
flow,
leaving,
onBegin
ctx,
flow
}: {
ctx: OnboardingContext
flow: Extract<OnboardingFlow, { status: 'confirming_model' }>
leaving: boolean
onBegin: () => void
}) {
const { t } = useI18n()
const scrambledModel = useScramble(flow.currentModel, leaving)
const scrambledBegin = useScramble(t.onboarding.startChatting, leaving)
// Local state controls whether the model picker dialog is open.
// We reuse the existing ModelPickerDialog component (the same picker
// available from the chat shell) rather than building an inline
@@ -1185,61 +914,46 @@ function ConfirmingModelPanel({
const freeTier = providerRow?.free_tier
return (
<div className="grid place-items-center gap-7 py-6 text-center">
<DecodedLabel leaving={leaving} text={t.onboarding.connectedProvider(flow.label)} />
<div
className={cn(
'grid justify-items-center gap-1.5 transition duration-[360ms] ease-out',
leaving ? 'opacity-0 saturate-0' : 'opacity-100 saturate-100'
)}
>
<div className="flex items-center gap-2">
<span className="font-mono text-[0.625rem] uppercase tracking-[0.2em] text-muted-foreground">
{t.onboarding.defaultModel}
</span>
{freeTier === true && (
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
{t.onboarding.freeTier}
</span>
)}
{freeTier === false && (
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
{t.onboarding.pro}
</span>
)}
</div>
<p className="font-mono text-base">
<GlyphText text={scrambledModel} />
</p>
{price && (price.input || price.output) && (
<p className="font-mono text-xs text-muted-foreground">
{price.free ? t.onboarding.free : t.onboarding.price(price.input || '?', price.output || '?')}
</p>
)}
<Button
className="mt-0.5 text-xs"
disabled={flow.saving}
onClick={() => setPickerOpen(true)}
size="inline"
variant="text"
>
{t.onboarding.change}
</Button>
<div className="grid gap-4">
<div className="flex items-center gap-2 rounded-2xl border border-primary/30 bg-primary/10 px-4 py-3 text-sm text-primary">
<Check className="size-4 shrink-0" />
<span>{t.onboarding.connectedProvider(flow.label)}</span>
</div>
<div
className={cn(
'transition duration-[360ms] ease-out',
leaving ? 'opacity-0 saturate-0' : 'opacity-100 saturate-100'
)}
>
<HackeryButton
disabled={flow.saving}
label={<GlyphText text={scrambledBegin} />}
loading={flow.saving}
onClick={onBegin}
/>
<div className="grid gap-3 rounded-2xl border border-border bg-background/60 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground">{t.onboarding.defaultModel}</p>
{freeTier === true && (
<span className="rounded-sm bg-emerald-500/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-emerald-600 dark:text-emerald-400">
{t.onboarding.freeTier}
</span>
)}
{freeTier === false && (
<span className="rounded-sm bg-primary/15 px-1 py-0.5 text-[0.6rem] font-semibold uppercase tracking-wide text-primary">
{t.onboarding.pro}
</span>
)}
</div>
<p className="mt-1 truncate font-mono text-sm">{flow.currentModel}</p>
{price && (price.input || price.output) && (
<p className="mt-1 font-mono text-xs text-muted-foreground">
{price.free ? t.onboarding.free : t.onboarding.price(price.input || '?', price.output || '?')}
</p>
)}
</div>
<Button disabled={flow.saving} onClick={() => setPickerOpen(true)} size="sm" variant="outline">
{t.onboarding.change}
</Button>
</div>
</div>
<div className="flex justify-end">
<Button disabled={flow.saving} onClick={() => confirmOnboardingModel(ctx)}>
{flow.saving ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
{t.onboarding.startChatting}
</Button>
</div>
{/*
@@ -1267,7 +981,7 @@ function ConfirmingModelPanel({
function DocsLink({ children, href }: { children: React.ReactNode; href: string }) {
return (
<Button asChild size="xs" variant="text">
<Button asChild size="xs" variant="ghost">
<a href={href} rel="noreferrer" target="_blank">
<ExternalLink className="size-3" />
{children}
@@ -1278,8 +992,8 @@ function DocsLink({ children, href }: { children: React.ReactNode; href: string
function Status({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-2.5 py-1 text-sm text-muted-foreground" role="status">
<Loader className="size-7" type="lemniscate-bloom" />
<div className="flex items-center gap-3 rounded-2xl bg-muted/30 px-4 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
{children}
</div>
)

View File

@@ -128,7 +128,7 @@ export function ModelPickerDialog({
</CommandList>
</Command>
<DialogFooter className="flex-row items-center justify-between gap-3 bg-card p-3 sm:justify-between">
<DialogFooter className="flex-row items-center justify-between gap-3 border-t border-border bg-card p-3 sm:justify-between">
<label className="flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
<Checkbox
checked={persistGlobal || !sessionId}

View File

@@ -3,7 +3,6 @@ import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Switch } from '@/components/ui/switch'
import type { HermesGateway } from '@/hermes'
@@ -136,18 +135,16 @@ export function ModelVisibilityDialog({
</div>
<div className="px-3 py-2">
<Button
className="-ml-2 text-(--ui-text-tertiary)"
<button
className="text-xs text-(--ui-text-tertiary) transition-colors hover:text-foreground"
onClick={() => {
onOpenChange(false)
onOpenProviders()
}}
size="xs"
type="button"
variant="text"
>
{copy.addProvider}
</Button>
</button>
</div>
</DialogContent>
</Dialog>

View File

@@ -3,7 +3,6 @@ import { type ReactNode, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { useI18n } from '@/i18n'
@@ -27,7 +26,8 @@ const tone: Record<NotificationKind, { icon: IconComponent; iconClass: string; v
success: { icon: CheckCircle2, iconClass: 'text-primary', variant: 'success' }
}
const STACK_SURFACE = 'pointer-events-auto border border-(--stroke-nous) bg-popover/95 shadow-nous backdrop-blur-md'
const STACK_SURFACE = 'pointer-events-auto border-border/80 bg-popover/95 shadow-lg shadow-black/5 backdrop-blur-md'
const GHOST_BTN = 'bg-transparent text-muted-foreground hover:text-foreground'
export function NotificationStack() {
const notifications = useStore($notifications)
@@ -83,12 +83,12 @@ export function NotificationStack() {
{expanded && olderNotifications.map(n => <NotificationItem key={n.id} notification={n} />)}
{overflowCount > 0 && (
<div className={cn(STACK_SURFACE, 'flex min-h-8 items-center justify-between rounded-lg px-3 text-xs')}>
<Button className="-ml-2 font-medium" onClick={() => setExpanded(v => !v)} size="xs" type="button" variant="text">
<button className={cn(GHOST_BTN, 'font-medium')} onClick={() => setExpanded(v => !v)} type="button">
{expanded ? copy.hide : copy.show} {copy.more(overflowCount)}
</Button>
<Button className="-mr-2" onClick={clearNotifications} size="xs" type="button" variant="text">
</button>
<button className={GHOST_BTN} onClick={clearNotifications} type="button">
{copy.clearAll}
</Button>
</button>
</div>
)}
</div>,
@@ -117,31 +117,27 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
<p className="m-0">{notification.message}</p>
{hasDetail && <NotificationDetail detail={notification.detail || ''} />}
{notification.action && (
<Button
className="mt-1.5 bg-primary/15 font-medium text-primary hover:bg-primary/25 hover:text-primary"
<button
className="mt-1.5 inline-flex items-center rounded-md bg-primary/15 px-2 py-1 text-xs font-medium text-primary transition-colors hover:bg-primary/25"
onClick={() => {
notification.action?.onClick()
dismissNotification(notification.id)
}}
size="xs"
type="button"
variant="ghost"
>
{notification.action.label}
</Button>
</button>
)}
</AlertDescription>
</div>
<Button
<button
aria-label={copy.dismiss}
className="col-start-3 -mr-1 text-muted-foreground"
className="col-start-3 -mr-1 grid size-6 place-items-center rounded-md bg-transparent text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => dismissNotification(notification.id)}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.875rem" />
</Button>
</button>
</Alert>
)
}
@@ -153,7 +149,7 @@ function NotificationDetail({ detail }: { detail: string }) {
return (
<details className="mt-2 text-xs text-muted-foreground">
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">{copy.details}</summary>
<div className="mt-1 rounded-md bg-background/65 p-2">
<div className="mt-1 rounded-md border border-border/70 bg-background/65 p-2">
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">
{detail}
</pre>

View File

@@ -1,4 +1,4 @@
export type { PaneShellContextValue, PaneSlot } from './context'
export { PaneShellContext } from './context'
export { Pane, PANE_TOGGLE_REVEAL_EVENT, PaneMain, PaneShell } from './pane-shell'
export { Pane, PaneMain, PaneShell } from './pane-shell'
export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell'

View File

@@ -10,8 +10,7 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState
useRef
} from 'react'
import { cn } from '@/lib/utils'
@@ -32,12 +31,6 @@ export interface PaneProps {
defaultOpen?: boolean
/** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */
disabled?: boolean
/** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */
forceCollapsed?: boolean
/** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */
hoverReveal?: boolean
/** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */
onOverlayActiveChange?: (overlayActive: boolean) => void
id: string
maxWidth?: WidthValue
minWidth?: WidthValue
@@ -60,7 +53,6 @@ export interface PaneShellProps {
interface CollectedPane {
defaultOpen: boolean
disabled: boolean
forceCollapsed: boolean
id: string
resizable: boolean
side: PaneSide
@@ -70,22 +62,6 @@ interface CollectedPane {
const DEFAULT_WIDTH = '16rem'
const DEFAULT_RESIZE_MIN_WIDTH = 160
// Hover-reveal slide. The enter delay is a pure-CSS hover-intent gate: a fast
// pass-by doesn't dwell on the trigger long enough for the delay to elapse.
const HOVER_REVEAL_SLIDE_MS = 220
const HOVER_REVEAL_ENTER_DELAY_MS = 130
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.
const HOVER_REVEAL_TRIGGER_WIDTH = 14
const HOVER_REVEAL_EDGE_GUTTER = 6
// 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.
export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal'
const widthToCss = (value: WidthValue | undefined, fallback: string) =>
value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value
@@ -134,7 +110,6 @@ function collectPanes(children: ReactNode) {
const entry: CollectedPane = {
defaultOpen: props.defaultOpen ?? true,
disabled: props.disabled ?? false,
forceCollapsed: props.forceCollapsed ?? false,
id: props.id,
resizable: props.resizable ?? false,
side: props.side,
@@ -149,7 +124,7 @@ function collectPanes(children: ReactNode) {
function trackForPane(pane: CollectedPane, states: Record<string, { open: boolean; widthOverride?: number }>) {
const stateOpen = states[pane.id]?.open ?? pane.defaultOpen
const open = !pane.disabled && !pane.forceCollapsed && stateOpen
const open = !pane.disabled && stateOpen
if (!open) {
return { open: false, track: '0px' }
@@ -218,29 +193,14 @@ export function Pane({
className,
defaultOpen = true,
disabled = false,
hoverReveal = false,
id,
maxWidth,
minWidth,
onOverlayActiveChange,
resizable = false,
width
resizable = false
}: PaneProps) {
const ctx = useContext(PaneShellContext)
const paneStates = useStore($paneStates)
const registered = useRef(false)
const paneRef = useRef<HTMLDivElement | null>(null)
// Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS.
const [forced, setForced] = useState(false)
const slot = ctx?.paneById.get(id)
const open = Boolean(slot?.open && !disabled)
const side = slot?.side ?? 'left'
// Collapsed + hoverReveal: float the pane contents over the main column on
// hover/focus instead of hiding them. Honors any persisted resize width.
const overlayActive = !open && hoverReveal && !disabled
const override = resizable ? paneStates[id]?.widthOverride : undefined
const overlayWidth = override !== undefined ? `${override}px` : widthToCss(width, DEFAULT_WIDTH)
useEffect(() => {
if (registered.current) {
@@ -251,34 +211,12 @@ export function Pane({
ensurePaneRegistered(id, { open: defaultOpen })
}, [defaultOpen, id])
// Keyboard toggle pins/unpins the reveal while collapsed; clear when no longer
// a collapsed overlay (reopened / widened).
useEffect(() => {
if (typeof window === 'undefined' || !overlayActive) {
setForced(false)
return
}
const onToggle = (e: Event) => {
if ((e as CustomEvent<{ id: string }>).detail?.id === id) {
setForced(v => !v)
}
}
window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
return () => window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle)
}, [id, overlayActive])
// Keep contents mounted while collapsed so reveal is a pure CSS transform.
useEffect(() => {
onOverlayActiveChange?.(overlayActive)
}, [onOverlayActiveChange, overlayActive])
const slot = ctx?.paneById.get(id)
const open = Boolean(slot?.open && !disabled)
const canResize = open && resizable
const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH
const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY
const side = slot?.side ?? 'left'
const startResize = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
@@ -335,58 +273,6 @@ export function Pane({
return null
}
// Collapsed hover-reveal track: a 0px, pointer-transparent grid cell holding a
// thin edge trigger + the floating panel (both absolute, escaping the zero
// box). group-hover (or data-forced from the keyboard) drives the slide; the
// enter-delay is the hover-intent gate. No JS pointer math.
if (overlayActive) {
const edge = side === 'left' ? 'left' : 'right'
const offscreen = side === 'left' ? '-translate-x-[calc(100%+1rem)]' : 'translate-x-[calc(100%+1rem)]'
return (
<div
className={cn('group/reveal pointer-events-none relative row-start-1 min-w-0', className)}
data-forced={forced ? '' : undefined}
data-pane-hover-reveal={forced ? 'open' : 'closed'}
data-pane-id={id}
data-pane-open="false"
data-pane-side={side}
ref={paneRef}
style={{ gridColumn: `${slot.column} / ${slot.column + 1}` }}
>
<div
aria-hidden="true"
className="pointer-events-auto absolute inset-y-0 z-30 [-webkit-app-region:no-drag]"
style={{ [edge]: HOVER_REVEAL_EDGE_GUTTER, width: HOVER_REVEAL_TRIGGER_WIDTH }}
/>
{/* Keyed on side so flipping panes remounts off-screen on the new edge
instead of transitioning the transform across the viewport. */}
<div
className={cn(
'pointer-events-none absolute inset-y-0 z-30 overflow-hidden transition-transform delay-0',
offscreen,
'group-hover/reveal:pointer-events-auto group-hover/reveal:translate-x-0 group-hover/reveal:delay-[var(--reveal-enter-delay)] group-hover/reveal:shadow-[var(--reveal-shadow)]',
'group-data-[forced]/reveal:pointer-events-auto group-data-[forced]/reveal:translate-x-0 group-data-[forced]/reveal:delay-0 group-data-[forced]/reveal:shadow-[var(--reveal-shadow)]'
)}
key={edge}
style={
{
[edge]: 0,
width: overlayWidth,
'--reveal-shadow': HOVER_REVEAL_SHADOW,
transitionDuration: `${HOVER_REVEAL_SLIDE_MS}ms`,
transitionTimingFunction: HOVER_REVEAL_EASE,
'--reveal-enter-delay': `${HOVER_REVEAL_ENTER_DELAY_MS}ms`
} as CSSProperties
}
>
<div className="flex h-full w-full flex-col">{children}</div>
</div>
</div>
)
}
return (
<div
aria-hidden={!open}

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