Compare commits
3 Commits
endless_te
...
feat/web-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13bbd56438 | ||
|
|
572d7bd9f4 | ||
|
|
6d13dab7c9 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -38,7 +38,7 @@ agent-browser/
|
||||
privvy*
|
||||
images/
|
||||
__pycache__/
|
||||
hermes_agent.egg-info/
|
||||
*.egg-info/
|
||||
wandb/
|
||||
testlogs
|
||||
|
||||
@@ -51,6 +51,9 @@ ignored/
|
||||
.worktrees/
|
||||
environments/benchmarks/evals/
|
||||
|
||||
# Web UI build output
|
||||
hermes_cli/web_dist/
|
||||
|
||||
# Release script temp files
|
||||
.release_notes.md
|
||||
mini-swe-agent/
|
||||
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.11
|
||||
@@ -1,249 +0,0 @@
|
||||
# Hermes Agent v0.6.0 (v2026.3.30)
|
||||
|
||||
**Release Date:** March 30, 2026
|
||||
|
||||
> The multi-instance release — Profiles for running isolated agent instances, MCP server mode, Docker container, fallback provider chains, two new messaging platforms (Feishu/Lark and WeCom), Telegram webhook mode, Slack multi-workspace OAuth, 95 PRs and 16 resolved issues in 2 days.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **Profiles — Multi-Instance Hermes** — Run multiple isolated Hermes instances from the same installation. Each profile gets its own config, memory, sessions, skills, and gateway service. Create with `hermes profile create`, switch with `hermes -p <name>`, export/import for sharing. Full token-lock isolation prevents two profiles from using the same bot credential. ([#3681](https://github.com/NousResearch/hermes-agent/pull/3681))
|
||||
|
||||
- **MCP Server Mode** — Expose Hermes conversations and sessions to any MCP-compatible client (Claude Desktop, Cursor, VS Code, etc.) via `hermes mcp serve`. Browse conversations, read messages, search across sessions, and manage attachments — all through the Model Context Protocol. Supports both stdio and Streamable HTTP transports. ([#3795](https://github.com/NousResearch/hermes-agent/pull/3795))
|
||||
|
||||
- **Docker Container** — Official Dockerfile for running Hermes Agent in a container. Supports both CLI and gateway modes with volume-mounted config. ([#3668](https://github.com/NousResearch/hermes-agent/pull/3668), closes [#850](https://github.com/NousResearch/hermes-agent/issues/850))
|
||||
|
||||
- **Ordered Fallback Provider Chain** — Configure multiple inference providers with automatic failover. When your primary provider returns errors or is unreachable, Hermes automatically tries the next provider in the chain. Configure via `fallback_providers` in config.yaml. ([#3813](https://github.com/NousResearch/hermes-agent/pull/3813), closes [#1734](https://github.com/NousResearch/hermes-agent/issues/1734))
|
||||
|
||||
- **Feishu/Lark Platform Support** — Full gateway adapter for Feishu (飞书) and Lark with event subscriptions, message cards, group chat, image/file attachments, and interactive card callbacks. ([#3799](https://github.com/NousResearch/hermes-agent/pull/3799), [#3817](https://github.com/NousResearch/hermes-agent/pull/3817), closes [#1788](https://github.com/NousResearch/hermes-agent/issues/1788))
|
||||
|
||||
- **WeCom (Enterprise WeChat) Platform Support** — New gateway adapter for WeCom (企业微信) with text/image/voice messages, group chats, and callback verification. ([#3847](https://github.com/NousResearch/hermes-agent/pull/3847))
|
||||
|
||||
- **Slack Multi-Workspace OAuth** — Connect a single Hermes gateway to multiple Slack workspaces via OAuth token file. Each workspace gets its own bot token, resolved dynamically per incoming event. ([#3903](https://github.com/NousResearch/hermes-agent/pull/3903))
|
||||
|
||||
- **Telegram Webhook Mode & Group Controls** — Run the Telegram adapter in webhook mode as an alternative to polling — faster response times and better for production deployments behind a reverse proxy. New group mention gating controls when the bot responds: always, only when @mentioned, or via regex triggers. ([#3880](https://github.com/NousResearch/hermes-agent/pull/3880), [#3870](https://github.com/NousResearch/hermes-agent/pull/3870))
|
||||
|
||||
- **Exa Search Backend** — Add Exa as an alternative web search and content extraction backend alongside Firecrawl and DuckDuckGo. Set `EXA_API_KEY` and configure as preferred backend. ([#3648](https://github.com/NousResearch/hermes-agent/pull/3648))
|
||||
|
||||
- **Skills & Credentials on Remote Backends** — Mount skill directories and credential files into Modal and Docker containers, so remote terminal sessions have access to the same skills and secrets as local execution. ([#3890](https://github.com/NousResearch/hermes-agent/pull/3890), [#3671](https://github.com/NousResearch/hermes-agent/pull/3671), closes [#3665](https://github.com/NousResearch/hermes-agent/issues/3665), [#3433](https://github.com/NousResearch/hermes-agent/issues/3433))
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Core Agent & Architecture
|
||||
|
||||
### Provider & Model Support
|
||||
- **Ordered fallback provider chain** — automatic failover across multiple configured providers ([#3813](https://github.com/NousResearch/hermes-agent/pull/3813))
|
||||
- **Fix api_mode on provider switch** — switching providers via `hermes model` now correctly clears stale `api_mode` instead of hardcoding `chat_completions`, fixing 404s for providers with Anthropic-compatible endpoints ([#3726](https://github.com/NousResearch/hermes-agent/pull/3726), [#3857](https://github.com/NousResearch/hermes-agent/pull/3857), closes [#3685](https://github.com/NousResearch/hermes-agent/issues/3685))
|
||||
- **Stop silent OpenRouter fallback** — when no provider is configured, Hermes now raises a clear error instead of silently routing to OpenRouter ([#3807](https://github.com/NousResearch/hermes-agent/pull/3807), [#3862](https://github.com/NousResearch/hermes-agent/pull/3862))
|
||||
- **Gemini 3.1 preview models** — added to OpenRouter and Nous Portal catalogs ([#3803](https://github.com/NousResearch/hermes-agent/pull/3803), closes [#3753](https://github.com/NousResearch/hermes-agent/issues/3753))
|
||||
- **Gemini direct API context length** — full context length resolution for direct Google AI endpoints ([#3876](https://github.com/NousResearch/hermes-agent/pull/3876))
|
||||
- **gpt-5.4-mini** added to Codex fallback catalog ([#3855](https://github.com/NousResearch/hermes-agent/pull/3855))
|
||||
- **Curated model lists preferred** over live API probe when the probe returns fewer models ([#3856](https://github.com/NousResearch/hermes-agent/pull/3856), [#3867](https://github.com/NousResearch/hermes-agent/pull/3867))
|
||||
- **User-friendly 429 rate limit messages** with Retry-After countdown ([#3809](https://github.com/NousResearch/hermes-agent/pull/3809))
|
||||
- **Auxiliary client placeholder key** for local servers without auth requirements ([#3842](https://github.com/NousResearch/hermes-agent/pull/3842))
|
||||
- **INFO-level logging** for auxiliary provider resolution ([#3866](https://github.com/NousResearch/hermes-agent/pull/3866))
|
||||
|
||||
### Agent Loop & Conversation
|
||||
- **Subagent status reporting** — reports `completed` status when summary exists instead of generic failure ([#3829](https://github.com/NousResearch/hermes-agent/pull/3829))
|
||||
- **Session log file updated during compression** — prevents stale file references after context compression ([#3835](https://github.com/NousResearch/hermes-agent/pull/3835))
|
||||
- **Omit empty tools param** — sends no `tools` parameter when empty instead of `None`, fixing compatibility with strict providers ([#3820](https://github.com/NousResearch/hermes-agent/pull/3820))
|
||||
|
||||
### Profiles & Multi-Instance
|
||||
- **Profiles system** — `hermes profile create/list/switch/delete/export/import/rename`. Each profile gets isolated HERMES_HOME, gateway service, CLI wrapper. Token locks prevent credential collisions. Tab completion for profile names. ([#3681](https://github.com/NousResearch/hermes-agent/pull/3681))
|
||||
- **Profile-aware display paths** — all user-facing `~/.hermes` paths replaced with `display_hermes_home()` to show the correct profile directory ([#3623](https://github.com/NousResearch/hermes-agent/pull/3623))
|
||||
- **Lazy display_hermes_home imports** — prevents `ImportError` during `hermes update` when modules cache stale bytecode ([#3776](https://github.com/NousResearch/hermes-agent/pull/3776))
|
||||
- **HERMES_HOME for protected paths** — `.env` write-deny path now respects HERMES_HOME instead of hardcoded `~/.hermes` ([#3840](https://github.com/NousResearch/hermes-agent/pull/3840))
|
||||
|
||||
---
|
||||
|
||||
## 📱 Messaging Platforms (Gateway)
|
||||
|
||||
### New Platforms
|
||||
- **Feishu/Lark** — Full adapter with event subscriptions, message cards, group chat, image/file attachments, interactive card callbacks ([#3799](https://github.com/NousResearch/hermes-agent/pull/3799), [#3817](https://github.com/NousResearch/hermes-agent/pull/3817))
|
||||
- **WeCom (Enterprise WeChat)** — Text/image/voice messages, group chats, callback verification ([#3847](https://github.com/NousResearch/hermes-agent/pull/3847))
|
||||
|
||||
### Telegram
|
||||
- **Webhook mode** — run as webhook endpoint instead of polling for production deployments ([#3880](https://github.com/NousResearch/hermes-agent/pull/3880))
|
||||
- **Group mention gating & regex triggers** — configurable bot response behavior in groups: always, @mention-only, or regex-matched ([#3870](https://github.com/NousResearch/hermes-agent/pull/3870))
|
||||
- **Gracefully handle deleted reply targets** — no more crashes when the message being replied to was deleted ([#3858](https://github.com/NousResearch/hermes-agent/pull/3858), closes [#3229](https://github.com/NousResearch/hermes-agent/issues/3229))
|
||||
|
||||
### Discord
|
||||
- **Message processing reactions** — adds a reaction emoji while processing and removes it when done, giving visual feedback in channels ([#3871](https://github.com/NousResearch/hermes-agent/pull/3871))
|
||||
- **DISCORD_IGNORE_NO_MENTION** — skip messages that @mention other users/bots but not Hermes ([#3640](https://github.com/NousResearch/hermes-agent/pull/3640))
|
||||
- **Clean up deferred "thinking..."** — properly removes the "thinking..." indicator after slash commands complete ([#3674](https://github.com/NousResearch/hermes-agent/pull/3674), closes [#3595](https://github.com/NousResearch/hermes-agent/issues/3595))
|
||||
|
||||
### Slack
|
||||
- **Multi-workspace OAuth** — connect to multiple Slack workspaces from a single gateway via OAuth token file ([#3903](https://github.com/NousResearch/hermes-agent/pull/3903))
|
||||
|
||||
### WhatsApp
|
||||
- **Persistent aiohttp session** — reuse HTTP sessions across requests instead of creating new ones per message ([#3818](https://github.com/NousResearch/hermes-agent/pull/3818))
|
||||
- **LID↔phone alias resolution** — correctly match Linked ID and phone number formats in allowlists ([#3830](https://github.com/NousResearch/hermes-agent/pull/3830))
|
||||
- **Skip reply prefix in bot mode** — cleaner message formatting when running as a WhatsApp bot ([#3931](https://github.com/NousResearch/hermes-agent/pull/3931))
|
||||
|
||||
### Matrix
|
||||
- **Native voice messages via MSC3245** — send voice messages as proper Matrix voice events instead of file attachments ([#3877](https://github.com/NousResearch/hermes-agent/pull/3877))
|
||||
|
||||
### Mattermost
|
||||
- **Configurable mention behavior** — respond to messages without requiring @mention ([#3664](https://github.com/NousResearch/hermes-agent/pull/3664))
|
||||
|
||||
### Signal
|
||||
- **URL-encode phone numbers** and correct attachment RPC parameter — fixes delivery failures with certain phone number formats ([#3670](https://github.com/NousResearch/hermes-agent/pull/3670)) — @kshitijk4poor
|
||||
|
||||
### Email
|
||||
- **Close SMTP/IMAP connections on failure** — prevents connection leaks during error scenarios ([#3804](https://github.com/NousResearch/hermes-agent/pull/3804))
|
||||
|
||||
### Gateway Core
|
||||
- **Atomic config writes** — use atomic file writes for config.yaml to prevent data loss during crashes ([#3800](https://github.com/NousResearch/hermes-agent/pull/3800))
|
||||
- **Home channel env overrides** — apply environment variable overrides for home channels consistently ([#3796](https://github.com/NousResearch/hermes-agent/pull/3796), [#3808](https://github.com/NousResearch/hermes-agent/pull/3808))
|
||||
- **Replace print() with logger** — BasePlatformAdapter now uses proper logging instead of print statements ([#3669](https://github.com/NousResearch/hermes-agent/pull/3669))
|
||||
- **Cron delivery labels** — resolve human-friendly delivery labels via channel directory ([#3860](https://github.com/NousResearch/hermes-agent/pull/3860), closes [#1945](https://github.com/NousResearch/hermes-agent/issues/1945))
|
||||
- **Cron [SILENT] tightening** — prevent agents from prefixing reports with [SILENT] to suppress delivery ([#3901](https://github.com/NousResearch/hermes-agent/pull/3901))
|
||||
- **Background task media delivery** and vision download timeout fixes ([#3919](https://github.com/NousResearch/hermes-agent/pull/3919))
|
||||
- **Boot-md hook** — example built-in hook to run a BOOT.md file on gateway startup ([#3733](https://github.com/NousResearch/hermes-agent/pull/3733))
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ CLI & User Experience
|
||||
|
||||
### Interactive CLI
|
||||
- **Configurable tool preview length** — show full file paths by default instead of truncating at 40 chars ([#3841](https://github.com/NousResearch/hermes-agent/pull/3841))
|
||||
- **Tool token context display** — `hermes tools` checklist now shows estimated token cost per toolset ([#3805](https://github.com/NousResearch/hermes-agent/pull/3805))
|
||||
- **/bg spinner TUI fix** — route background task spinner through the TUI widget to prevent status bar collision ([#3643](https://github.com/NousResearch/hermes-agent/pull/3643))
|
||||
- **Prevent status bar wrapping** into duplicate rows ([#3883](https://github.com/NousResearch/hermes-agent/pull/3883)) — @kshitijk4poor
|
||||
- **Handle closed stdout ValueError** in safe print paths — fixes crashes when stdout is closed during gateway thread shutdown ([#3843](https://github.com/NousResearch/hermes-agent/pull/3843), closes [#3534](https://github.com/NousResearch/hermes-agent/issues/3534))
|
||||
- **Remove input() from /tools disable** — eliminates freeze in terminal when disabling tools ([#3918](https://github.com/NousResearch/hermes-agent/pull/3918))
|
||||
- **TTY guard for interactive CLI commands** — prevent CPU spin when launched without a terminal ([#3933](https://github.com/NousResearch/hermes-agent/pull/3933))
|
||||
- **Argparse entrypoint** — use argparse in the top-level launcher for cleaner error handling ([#3874](https://github.com/NousResearch/hermes-agent/pull/3874))
|
||||
- **Lazy-initialized tools show yellow** in banner instead of red, reducing false alarm about "missing" tools ([#3822](https://github.com/NousResearch/hermes-agent/pull/3822))
|
||||
- **Honcho tools shown in banner** when configured ([#3810](https://github.com/NousResearch/hermes-agent/pull/3810))
|
||||
|
||||
### Setup & Configuration
|
||||
- **Auto-install matrix-nio** during `hermes setup` when Matrix is selected ([#3802](https://github.com/NousResearch/hermes-agent/pull/3802), [#3873](https://github.com/NousResearch/hermes-agent/pull/3873))
|
||||
- **Session export stdout support** — export sessions to stdout with `-` for piping ([#3641](https://github.com/NousResearch/hermes-agent/pull/3641), closes [#3609](https://github.com/NousResearch/hermes-agent/issues/3609))
|
||||
- **Configurable approval timeouts** — set how long dangerous command approval prompts wait before auto-denying ([#3886](https://github.com/NousResearch/hermes-agent/pull/3886), closes [#3765](https://github.com/NousResearch/hermes-agent/issues/3765))
|
||||
- **Clear __pycache__ during update** — prevents stale bytecode ImportError after `hermes update` ([#3819](https://github.com/NousResearch/hermes-agent/pull/3819))
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tool System
|
||||
|
||||
### MCP
|
||||
- **MCP Server Mode** — `hermes mcp serve` exposes conversations, sessions, and attachments to MCP clients via stdio or Streamable HTTP ([#3795](https://github.com/NousResearch/hermes-agent/pull/3795))
|
||||
- **Dynamic tool discovery** — respond to `notifications/tools/list_changed` events to pick up new tools from MCP servers without reconnecting ([#3812](https://github.com/NousResearch/hermes-agent/pull/3812))
|
||||
- **Non-deprecated HTTP transport** — switched from `sse_client` to `streamable_http_client` ([#3646](https://github.com/NousResearch/hermes-agent/pull/3646))
|
||||
|
||||
### Web Tools
|
||||
- **Exa search backend** — alternative to Firecrawl and DuckDuckGo for web search and extraction ([#3648](https://github.com/NousResearch/hermes-agent/pull/3648))
|
||||
|
||||
### Browser
|
||||
- **Guard against None LLM responses** in browser snapshot and vision tools ([#3642](https://github.com/NousResearch/hermes-agent/pull/3642))
|
||||
|
||||
### Terminal & Remote Backends
|
||||
- **Mount skill directories** into Modal and Docker containers ([#3890](https://github.com/NousResearch/hermes-agent/pull/3890))
|
||||
- **Mount credential files** into remote backends with mtime+size caching ([#3671](https://github.com/NousResearch/hermes-agent/pull/3671))
|
||||
- **Preserve partial output** when commands time out instead of losing everything ([#3868](https://github.com/NousResearch/hermes-agent/pull/3868))
|
||||
- **Stop marking persisted env vars as missing** on remote backends ([#3650](https://github.com/NousResearch/hermes-agent/pull/3650))
|
||||
|
||||
### Audio
|
||||
- **.aac format support** in transcription tool ([#3865](https://github.com/NousResearch/hermes-agent/pull/3865), closes [#1963](https://github.com/NousResearch/hermes-agent/issues/1963))
|
||||
- **Audio download retry** — retry logic for `cache_audio_from_url` matching the existing image download pattern ([#3401](https://github.com/NousResearch/hermes-agent/pull/3401)) — @binhnt92
|
||||
|
||||
### Vision
|
||||
- **Reject non-image files** and enforce website-only policy for vision analysis ([#3845](https://github.com/NousResearch/hermes-agent/pull/3845))
|
||||
|
||||
### Tool Schema
|
||||
- **Ensure name field** always present in tool definitions, fixing `KeyError: 'name'` crashes ([#3811](https://github.com/NousResearch/hermes-agent/pull/3811), closes [#3729](https://github.com/NousResearch/hermes-agent/issues/3729))
|
||||
|
||||
### ACP (Editor Integration)
|
||||
- **Complete session management surface** for VS Code/Zed/JetBrains clients — proper task lifecycle, cancel support, session persistence ([#3675](https://github.com/NousResearch/hermes-agent/pull/3675))
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Skills & Plugins
|
||||
|
||||
### Skills System
|
||||
- **External skill directories** — configure additional skill directories via `skills.external_dirs` in config.yaml ([#3678](https://github.com/NousResearch/hermes-agent/pull/3678))
|
||||
- **Category path traversal blocked** — prevents `../` attacks in skill category names ([#3844](https://github.com/NousResearch/hermes-agent/pull/3844))
|
||||
- **parallel-cli moved to optional-skills** — reduces default skill footprint ([#3673](https://github.com/NousResearch/hermes-agent/pull/3673)) — @kshitijk4poor
|
||||
|
||||
### New Skills
|
||||
- **memento-flashcards** — spaced repetition flashcard system ([#3827](https://github.com/NousResearch/hermes-agent/pull/3827))
|
||||
- **songwriting-and-ai-music** — songwriting craft and AI music generation prompts ([#3834](https://github.com/NousResearch/hermes-agent/pull/3834))
|
||||
- **SiYuan Note** — integration with SiYuan note-taking app ([#3742](https://github.com/NousResearch/hermes-agent/pull/3742))
|
||||
- **Scrapling** — web scraping skill using Scrapling library ([#3742](https://github.com/NousResearch/hermes-agent/pull/3742))
|
||||
- **one-three-one-rule** — communication framework skill ([#3797](https://github.com/NousResearch/hermes-agent/pull/3797))
|
||||
|
||||
### Plugin System
|
||||
- **Plugin enable/disable commands** — `hermes plugins enable/disable <name>` for managing plugin state without removing them ([#3747](https://github.com/NousResearch/hermes-agent/pull/3747))
|
||||
- **Plugin message injection** — plugins can now inject messages into the conversation stream on behalf of the user via `ctx.inject_message()` ([#3778](https://github.com/NousResearch/hermes-agent/pull/3778)) — @winglian
|
||||
- **Honcho self-hosted support** — allow local Honcho instances without requiring an API key ([#3644](https://github.com/NousResearch/hermes-agent/pull/3644))
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Security & Reliability
|
||||
|
||||
### Security Hardening
|
||||
- **Hardened dangerous command detection** — expanded pattern matching for risky shell commands and added file tool path guards for sensitive locations (`/etc/`, `/boot/`, docker.sock) ([#3872](https://github.com/NousResearch/hermes-agent/pull/3872))
|
||||
- **Sensitive path write checks** in approval system — catch writes to system config files through file tools, not just terminal ([#3859](https://github.com/NousResearch/hermes-agent/pull/3859))
|
||||
- **Secret redaction expansion** — now covers ElevenLabs, Tavily, and Exa API keys ([#3920](https://github.com/NousResearch/hermes-agent/pull/3920))
|
||||
- **Vision file rejection** — reject non-image files passed to vision analysis to prevent information disclosure ([#3845](https://github.com/NousResearch/hermes-agent/pull/3845))
|
||||
- **Category path traversal blocking** — prevent directory traversal in skill category names ([#3844](https://github.com/NousResearch/hermes-agent/pull/3844))
|
||||
|
||||
### Reliability
|
||||
- **Atomic config.yaml writes** — prevent data loss during gateway crashes ([#3800](https://github.com/NousResearch/hermes-agent/pull/3800))
|
||||
- **Clear __pycache__ on update** — prevent stale bytecode from causing ImportError after updates ([#3819](https://github.com/NousResearch/hermes-agent/pull/3819))
|
||||
- **Lazy imports for update safety** — prevent ImportError chains during `hermes update` when modules reference new functions ([#3776](https://github.com/NousResearch/hermes-agent/pull/3776))
|
||||
- **Restore terminalbench2 from patch corruption** — recovered file damaged by patch tool's secret redaction ([#3801](https://github.com/NousResearch/hermes-agent/pull/3801))
|
||||
- **Terminal timeout preserves partial output** — no more lost command output on timeout ([#3868](https://github.com/NousResearch/hermes-agent/pull/3868))
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Notable Bug Fixes
|
||||
|
||||
- **OpenClaw migration model config overwrite** — migration no longer overwrites model config dict with a string ([#3924](https://github.com/NousResearch/hermes-agent/pull/3924)) — @0xbyt4
|
||||
- **OpenClaw migration expanded** — covers full data footprint including sessions, cron, memory ([#3869](https://github.com/NousResearch/hermes-agent/pull/3869))
|
||||
- **Telegram deleted reply targets** — gracefully handle replies to deleted messages instead of crashing ([#3858](https://github.com/NousResearch/hermes-agent/pull/3858))
|
||||
- **Discord "thinking..." persistence** — properly cleans up deferred response indicators ([#3674](https://github.com/NousResearch/hermes-agent/pull/3674))
|
||||
- **WhatsApp LID↔phone aliases** — fixes allowlist matching failures with Linked ID format ([#3830](https://github.com/NousResearch/hermes-agent/pull/3830))
|
||||
- **Signal URL-encoded phone numbers** — fixes delivery failures with certain formats ([#3670](https://github.com/NousResearch/hermes-agent/pull/3670))
|
||||
- **Email connection leaks** — properly close SMTP/IMAP connections on error ([#3804](https://github.com/NousResearch/hermes-agent/pull/3804))
|
||||
- **_safe_print ValueError** — no more gateway thread crashes on closed stdout ([#3843](https://github.com/NousResearch/hermes-agent/pull/3843))
|
||||
- **Tool schema KeyError 'name'** — ensure name field always present in tool definitions ([#3811](https://github.com/NousResearch/hermes-agent/pull/3811))
|
||||
- **api_mode stale on provider switch** — correctly clear when switching providers via `hermes model` ([#3857](https://github.com/NousResearch/hermes-agent/pull/3857))
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
- Resolved 10+ CI failures across hooks, tiktoken, plugins, and skill tests ([#3848](https://github.com/NousResearch/hermes-agent/pull/3848), [#3721](https://github.com/NousResearch/hermes-agent/pull/3721), [#3936](https://github.com/NousResearch/hermes-agent/pull/3936))
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **Comprehensive OpenClaw migration guide** — step-by-step guide for migrating from OpenClaw/Claw3D to Hermes Agent ([#3864](https://github.com/NousResearch/hermes-agent/pull/3864), [#3900](https://github.com/NousResearch/hermes-agent/pull/3900))
|
||||
- **Credential file passthrough docs** — document how to forward credential files and env vars to remote backends ([#3677](https://github.com/NousResearch/hermes-agent/pull/3677))
|
||||
- **DuckDuckGo requirements clarified** — note runtime dependency on duckduckgo-search package ([#3680](https://github.com/NousResearch/hermes-agent/pull/3680))
|
||||
- **Skills catalog updated** — added red-teaming category and optional skills listing ([#3745](https://github.com/NousResearch/hermes-agent/pull/3745))
|
||||
- **Feishu docs MDX fix** — escape angle-bracket URLs that break Docusaurus build ([#3902](https://github.com/NousResearch/hermes-agent/pull/3902))
|
||||
|
||||
---
|
||||
|
||||
## 👥 Contributors
|
||||
|
||||
### Core
|
||||
- **@teknium1** — 90 PRs across all subsystems
|
||||
|
||||
### Community Contributors
|
||||
- **@kshitijk4poor** — 3 PRs: Signal phone number fix ([#3670](https://github.com/NousResearch/hermes-agent/pull/3670)), parallel-cli to optional-skills ([#3673](https://github.com/NousResearch/hermes-agent/pull/3673)), status bar wrapping fix ([#3883](https://github.com/NousResearch/hermes-agent/pull/3883))
|
||||
- **@winglian** — 1 PR: Plugin message injection interface ([#3778](https://github.com/NousResearch/hermes-agent/pull/3778))
|
||||
- **@binhnt92** — 1 PR: Audio download retry logic ([#3401](https://github.com/NousResearch/hermes-agent/pull/3401))
|
||||
- **@0xbyt4** — 1 PR: OpenClaw migration model config fix ([#3924](https://github.com/NousResearch/hermes-agent/pull/3924))
|
||||
|
||||
### Issues Resolved from Community
|
||||
@Material-Scientist ([#850](https://github.com/NousResearch/hermes-agent/issues/850)), @hanxu98121 ([#1734](https://github.com/NousResearch/hermes-agent/issues/1734)), @penwyp ([#1788](https://github.com/NousResearch/hermes-agent/issues/1788)), @dan-and ([#1945](https://github.com/NousResearch/hermes-agent/issues/1945)), @AdrianScott ([#1963](https://github.com/NousResearch/hermes-agent/issues/1963)), @clawdbot47 ([#3229](https://github.com/NousResearch/hermes-agent/issues/3229)), @alanfwilliams ([#3404](https://github.com/NousResearch/hermes-agent/issues/3404)), @kentimsit ([#3433](https://github.com/NousResearch/hermes-agent/issues/3433)), @hayka-pacha ([#3534](https://github.com/NousResearch/hermes-agent/issues/3534)), @primmer ([#3595](https://github.com/NousResearch/hermes-agent/issues/3595)), @dagelf ([#3609](https://github.com/NousResearch/hermes-agent/issues/3609)), @HenkDz ([#3685](https://github.com/NousResearch/hermes-agent/issues/3685)), @tmdgusya ([#3729](https://github.com/NousResearch/hermes-agent/issues/3729)), @TypQxQ ([#3753](https://github.com/NousResearch/hermes-agent/issues/3753)), @acsezen ([#3765](https://github.com/NousResearch/hermes-agent/issues/3765))
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: [v2026.3.28...v2026.3.30](https://github.com/NousResearch/hermes-agent/compare/v2026.3.28...v2026.3.30)
|
||||
@@ -37,9 +37,6 @@ _PREFIX_PATTERNS = [
|
||||
r"dop_v1_[A-Za-z0-9]{10,}", # DigitalOcean PAT
|
||||
r"doo_v1_[A-Za-z0-9]{10,}", # DigitalOcean OAuth
|
||||
r"am_[A-Za-z0-9_-]{10,}", # AgentMail API key
|
||||
r"sk_[A-Za-z0-9_]{10,}", # ElevenLabs TTS key (sk_ underscore, not sk- dash)
|
||||
r"tvly-[A-Za-z0-9]{10,}", # Tavily search API key
|
||||
r"exa_[A-Za-z0-9]{10,}", # Exa search API key
|
||||
]
|
||||
|
||||
# ENV assignment patterns: KEY=value where KEY contains a secret-like name
|
||||
|
||||
4
cli.py
4
cli.py
@@ -3846,6 +3846,10 @@ class HermesCLI:
|
||||
self._show_insights(cmd_original)
|
||||
elif canonical == "paste":
|
||||
self._handle_paste_command()
|
||||
elif canonical == "reload":
|
||||
from hermes_cli.config import reload_env
|
||||
count = reload_env()
|
||||
print(f" Reloaded .env ({count} var(s) updated)")
|
||||
elif canonical == "reload-mcp":
|
||||
with self._busy_command(self._slow_command_status(cmd_original)):
|
||||
self._reload_mcp()
|
||||
|
||||
@@ -13,7 +13,6 @@ Core layers:
|
||||
Concrete environments:
|
||||
- terminal_test_env/: Simple file-creation tasks for testing the stack
|
||||
- hermes_swe_env/: SWE-bench style tasks with Modal sandboxes
|
||||
- endless_terminals/: Terminal tasks from HuggingFace dataset with Apptainer containers
|
||||
|
||||
Benchmarks (eval-only):
|
||||
- benchmarks/terminalbench_2/: Terminal-Bench 2.0 evaluation
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Endless Terminals Environment - Terminal task training from HuggingFace dataset."""
|
||||
|
||||
from .endless_terminals_env import EndlessTerminalsEnv, EndlessTerminalsEnvConfig
|
||||
|
||||
__all__ = ["EndlessTerminalsEnv", "EndlessTerminalsEnvConfig"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,91 +0,0 @@
|
||||
# Endless Terminals - Qwen3-4B-Instruct-2507
|
||||
# Single config for both trainer (launch_training.py) and env (endless_terminals_env.py serve)
|
||||
#
|
||||
# Usage:
|
||||
# Terminal 1: run-api
|
||||
# Terminal 2: cd tinker-atropos && python launch_training.py --config ../environments/endless_terminals/tinker_qwen.yaml
|
||||
# Terminal 3: python environments/endless_terminals/endless_terminals_env.py serve --config environments/endless_terminals/tinker_qwen.yaml
|
||||
|
||||
env:
|
||||
# Toolsets
|
||||
enabled_toolsets: ["terminal", "file"]
|
||||
|
||||
# Model / tokenizer
|
||||
tokenizer_name: "Qwen/Qwen3-4B-Instruct-2507"
|
||||
|
||||
# Agent configuration
|
||||
max_agent_turns: 16
|
||||
max_token_length: 2048
|
||||
agent_temperature: 0.6
|
||||
extra_body:
|
||||
chat_template_kwargs:
|
||||
enable_thinking: false
|
||||
tool_call_parser: "hermes"
|
||||
|
||||
# Terminal backend
|
||||
terminal_backend: "docker"
|
||||
|
||||
# Dataset settings
|
||||
use_dataset: true
|
||||
dataset_name: "obiwan96/endless-terminals"
|
||||
dataset_split: "train"
|
||||
dataset_cache_dir: "~/.cache/huggingface/datasets"
|
||||
tasks_base_dir: "/Users/samherring/Desktop/Projects/Hermes-Agent/endless-terminals"
|
||||
|
||||
# Test execution
|
||||
test_timeout_s: 180
|
||||
default_docker_image: "ubuntu:22.04"
|
||||
max_concurrent_containers: 16
|
||||
|
||||
# Training configuration
|
||||
group_size: 16
|
||||
batch_size: 64 # 4 groups × 16 rollouts per step
|
||||
total_steps: 500
|
||||
steps_per_eval: 5
|
||||
min_items_sent_before_logging: 1
|
||||
ensure_scores_are_not_same: true
|
||||
max_num_workers: 2048
|
||||
worker_timeout: 3600
|
||||
inference_weight: 1.0
|
||||
eval_limit_ratio: 0.1
|
||||
rollout_server_url: "http://localhost:8000"
|
||||
|
||||
# Evaluation configuration
|
||||
num_eval_tasks: 20
|
||||
eval_split_ratio: 0.1
|
||||
|
||||
# Logging
|
||||
use_wandb: true
|
||||
wandb_name: "endless-terminals-qwen3-4b"
|
||||
|
||||
# System prompt
|
||||
system_prompt: >
|
||||
You are a skilled Linux system administrator and programmer.
|
||||
You have access to a terminal and file tools to complete system administration
|
||||
and programming tasks. Use the tools effectively to solve the given task,
|
||||
and verify your solution works correctly before finishing.
|
||||
Keep each command short and focused — break complex tasks into multiple steps
|
||||
rather than writing long one-liners.
|
||||
|
||||
tinker:
|
||||
lora_rank: 32
|
||||
learning_rate: 0.0000005
|
||||
max_token_trainer_length: 32768
|
||||
checkpoint_dir: "./temp/"
|
||||
save_checkpoint_interval: 50
|
||||
wandb_project: "endless-terminals"
|
||||
wandb_group: null
|
||||
wandb_run_name: "qwen3-4b"
|
||||
tool_call_parser: "hermes"
|
||||
|
||||
openai:
|
||||
- model_name: "Qwen/Qwen3-4B-Instruct-2507"
|
||||
base_url: "http://localhost:8001/v1"
|
||||
api_key: "x"
|
||||
weight: 1.0
|
||||
num_requests_for_eval: 64
|
||||
timeout: 600
|
||||
server_type: "sglang"
|
||||
|
||||
slurm: false
|
||||
testing: false
|
||||
@@ -298,6 +298,7 @@ class HermesAgentBaseEnv(BaseEnv):
|
||||
return False
|
||||
|
||||
server = self.server.servers[0]
|
||||
# If the server is an OpenAI server (not VLLM/SGLang), use direct mode
|
||||
from atroposlib.envs.server_handling.openai_server import OpenAIServer
|
||||
return not isinstance(server, OpenAIServer)
|
||||
|
||||
|
||||
@@ -48,13 +48,7 @@ class HermesToolCallParser(ToolCallParser):
|
||||
if not raw_json.strip():
|
||||
continue
|
||||
|
||||
try:
|
||||
tc_data = json.loads(raw_json)
|
||||
except json.JSONDecodeError:
|
||||
# Fix invalid backslash escapes from shell commands in JSON strings
|
||||
# e.g. \s \w \d \n (unescaped) → \\s \\w \\d \\n
|
||||
fixed = re.sub(r'\\([^"\\/bfnrtu0-9\n])', r'\\\\\1', raw_json)
|
||||
tc_data = json.loads(fixed)
|
||||
tc_data = json.loads(raw_json)
|
||||
tool_calls.append(
|
||||
ChatCompletionMessageToolCall(
|
||||
id=f"call_{uuid.uuid4().hex[:8]}",
|
||||
|
||||
@@ -904,9 +904,8 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
# Use cached local path for images (voice messages already handled above).
|
||||
if cached_path:
|
||||
media_urls = [cached_path]
|
||||
# Use cached local path for images, HTTP URL for other media types
|
||||
media_urls = [cached_path] if cached_path else ([http_url] if http_url else None)
|
||||
media_types = [media_type] if media_urls else None
|
||||
|
||||
msg_event = MessageEvent(
|
||||
|
||||
@@ -11,5 +11,5 @@ Provides subcommands for:
|
||||
- hermes cron - Manage cron jobs
|
||||
"""
|
||||
|
||||
__version__ = "0.6.0"
|
||||
__release_date__ = "2026.3.30"
|
||||
__version__ = "0.5.0"
|
||||
__release_date__ = "2026.3.28"
|
||||
|
||||
@@ -5,7 +5,6 @@ toggleable list of items. Falls back to a numbered text UI when
|
||||
curses is unavailable (Windows without curses, piped stdin, etc.).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from typing import List, Set
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
@@ -27,10 +26,6 @@ def curses_checklist(
|
||||
The indices the user confirmed as checked. On cancel (ESC/q),
|
||||
returns ``pre_selected`` unchanged.
|
||||
"""
|
||||
# Safety: return defaults when stdin is not a terminal.
|
||||
if not sys.stdin.isatty():
|
||||
return set(pre_selected)
|
||||
|
||||
try:
|
||||
import curses
|
||||
selected = set(pre_selected)
|
||||
|
||||
@@ -109,6 +109,7 @@ COMMAND_REGISTRY: list[CommandDef] = [
|
||||
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
||||
cli_only=True, args_hint="[subcommand]",
|
||||
subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")),
|
||||
CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills"),
|
||||
CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills",
|
||||
aliases=("reload_mcp",)),
|
||||
CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills",
|
||||
|
||||
@@ -1672,6 +1672,51 @@ def save_env_value_secure(key: str, value: str) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def delete_env_value(key: str) -> bool:
|
||||
"""Remove a key from ~/.hermes/.env. Returns True if the key was found and removed."""
|
||||
env_path = get_env_path()
|
||||
if not env_path.exists():
|
||||
return False
|
||||
|
||||
read_kw = {"encoding": "utf-8", "errors": "replace"} if _IS_WINDOWS else {}
|
||||
write_kw = {"encoding": "utf-8"} if _IS_WINDOWS else {}
|
||||
|
||||
with open(env_path, **read_kw) as f:
|
||||
lines = f.readlines()
|
||||
|
||||
new_lines = [l for l in lines if not l.strip().startswith(f"{key}=")]
|
||||
if len(new_lines) == len(lines):
|
||||
return False
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(env_path.parent), suffix='.tmp', prefix='.env_')
|
||||
try:
|
||||
with os.fdopen(fd, 'w', **write_kw) as f:
|
||||
f.writelines(new_lines)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp_path, env_path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
_secure_file(env_path)
|
||||
|
||||
os.environ.pop(key, None)
|
||||
return True
|
||||
|
||||
|
||||
def reload_env() -> int:
|
||||
"""Re-read ~/.hermes/.env into os.environ. Returns count of vars updated."""
|
||||
env_vars = load_env()
|
||||
count = 0
|
||||
for key, value in env_vars.items():
|
||||
if os.environ.get(key) != value:
|
||||
os.environ[key] = value
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def get_env_value(key: str) -> Optional[str]:
|
||||
"""Get a value from ~/.hermes/.env or environment."""
|
||||
|
||||
@@ -4,7 +4,6 @@ Used by `hermes tools` and `hermes skills` for interactive checklists.
|
||||
Provides a curses multi-select with keyboard navigation, plus a
|
||||
text-based numbered fallback for terminals without curses support.
|
||||
"""
|
||||
import sys
|
||||
from typing import Callable, List, Optional, Set
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
@@ -32,11 +31,6 @@ def curses_checklist(
|
||||
if cancel_returns is None:
|
||||
cancel_returns = set(selected)
|
||||
|
||||
# Safety: curses and input() both hang or spin when stdin is not a
|
||||
# terminal (e.g. subprocess pipe). Return defaults immediately.
|
||||
if not sys.stdin.isatty():
|
||||
return cancel_returns
|
||||
|
||||
try:
|
||||
import curses
|
||||
chosen = set(selected)
|
||||
|
||||
@@ -41,6 +41,7 @@ Usage:
|
||||
hermes sessions browse Interactive session picker with search
|
||||
|
||||
hermes claw migrate --dry-run # Preview migration without changes
|
||||
hermes web # Start web UI dashboard
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -50,23 +51,6 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
def _require_tty(command_name: str) -> None:
|
||||
"""Exit with a clear error if stdin is not a terminal.
|
||||
|
||||
Interactive TUI commands (hermes tools, hermes setup, hermes model) use
|
||||
curses or input() prompts that spin at 100% CPU when stdin is a pipe.
|
||||
This guard prevents accidental non-interactive invocation.
|
||||
"""
|
||||
if not sys.stdin.isatty():
|
||||
print(
|
||||
f"Error: 'hermes {command_name}' requires an interactive terminal.\n"
|
||||
f"It cannot be run through a pipe or non-interactive subprocess.\n"
|
||||
f"Run it directly in your terminal instead.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# Add project root to path
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
@@ -634,7 +618,6 @@ def cmd_gateway(args):
|
||||
|
||||
def cmd_whatsapp(args):
|
||||
"""Set up WhatsApp: choose mode, configure, install bridge, pair via QR."""
|
||||
_require_tty("whatsapp")
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
@@ -821,14 +804,12 @@ def cmd_whatsapp(args):
|
||||
|
||||
def cmd_setup(args):
|
||||
"""Interactive setup wizard."""
|
||||
_require_tty("setup")
|
||||
from hermes_cli.setup import run_setup_wizard
|
||||
run_setup_wizard(args)
|
||||
|
||||
|
||||
def cmd_model(args):
|
||||
"""Select default model — starts with provider selection, then model picker."""
|
||||
_require_tty("model")
|
||||
from hermes_cli.auth import (
|
||||
resolve_provider, AuthError, format_auth_error,
|
||||
)
|
||||
@@ -2479,7 +2460,6 @@ def cmd_version(args):
|
||||
|
||||
def cmd_uninstall(args):
|
||||
"""Uninstall Hermes Agent."""
|
||||
_require_tty("uninstall")
|
||||
from hermes_cli.uninstall import run_uninstall
|
||||
run_uninstall(args)
|
||||
|
||||
@@ -2510,6 +2490,48 @@ def _clear_bytecode_cache(root: Path) -> int:
|
||||
pass
|
||||
dirnames.clear() # nothing left to recurse into
|
||||
return removed
|
||||
def cmd_web(args):
|
||||
"""Start the web UI server."""
|
||||
try:
|
||||
import fastapi # noqa: F401
|
||||
import uvicorn # noqa: F401
|
||||
except ImportError:
|
||||
print("Web UI dependencies not installed.")
|
||||
print("Install them with: pip install hermes-agent[web]")
|
||||
sys.exit(1)
|
||||
|
||||
web_dist = PROJECT_ROOT / "hermes_cli" / "web_dist"
|
||||
web_src = PROJECT_ROOT / "web"
|
||||
if not web_dist.exists() and (web_src / "package.json").exists():
|
||||
import shutil
|
||||
npm = shutil.which("npm")
|
||||
if npm:
|
||||
import subprocess
|
||||
print("→ Web UI not built yet — building now...")
|
||||
r1 = subprocess.run([npm, "install", "--silent"], cwd=web_src, capture_output=True)
|
||||
if r1.returncode == 0:
|
||||
r2 = subprocess.run([npm, "run", "build"], cwd=web_src, capture_output=True)
|
||||
if r2.returncode == 0:
|
||||
print(" ✓ Web UI built")
|
||||
else:
|
||||
print(" ✗ Web UI build failed")
|
||||
print(" Run manually: cd web && npm install && npm run build")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(" ✗ npm install failed")
|
||||
print(" Run manually: cd web && npm install && npm run build")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("Web UI frontend not built and npm is not available.")
|
||||
print("Install Node.js, then run: cd web && npm install && npm run build")
|
||||
sys.exit(1)
|
||||
|
||||
from hermes_cli.web_server import start_server
|
||||
start_server(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
open_browser=not args.no_open,
|
||||
)
|
||||
|
||||
|
||||
def _update_via_zip(args):
|
||||
@@ -2620,6 +2642,20 @@ def _update_via_zip(args):
|
||||
print(" ⚠ Optional extras failed, installing base dependencies...")
|
||||
subprocess.run(pip_cmd + ["install", "-e", ".", "--quiet"], cwd=PROJECT_ROOT, check=True)
|
||||
|
||||
# Build web UI frontend
|
||||
web_dir = PROJECT_ROOT / "web"
|
||||
if (web_dir / "package.json").exists() and shutil.which("npm"):
|
||||
print("→ Building web UI...")
|
||||
r1 = subprocess.run(["npm", "install", "--silent"], cwd=web_dir, capture_output=True)
|
||||
if r1.returncode == 0:
|
||||
r2 = subprocess.run(["npm", "run", "build"], cwd=web_dir, capture_output=True)
|
||||
if r2.returncode == 0:
|
||||
print(" ✓ Web UI built")
|
||||
else:
|
||||
print(" ⚠ Web UI build failed (hermes web will not be available)")
|
||||
else:
|
||||
print(" ⚠ Web UI npm install failed (hermes web will not be available)")
|
||||
|
||||
# Sync skills
|
||||
try:
|
||||
from tools.skills_sync import sync_skills
|
||||
@@ -3031,6 +3067,22 @@ def cmd_update(args):
|
||||
print("→ Updating Node.js dependencies...")
|
||||
subprocess.run(["npm", "install", "--silent"], cwd=PROJECT_ROOT, check=False)
|
||||
|
||||
# Build web UI frontend
|
||||
web_dir = PROJECT_ROOT / "web"
|
||||
if (web_dir / "package.json").exists():
|
||||
import shutil
|
||||
if shutil.which("npm"):
|
||||
print("→ Building web UI...")
|
||||
r1 = subprocess.run(["npm", "install", "--silent"], cwd=web_dir, capture_output=True)
|
||||
if r1.returncode == 0:
|
||||
r2 = subprocess.run(["npm", "run", "build"], cwd=web_dir, capture_output=True)
|
||||
if r2.returncode == 0:
|
||||
print(" ✓ Web UI built")
|
||||
else:
|
||||
print(" ⚠ Web UI build failed (hermes web will not be available)")
|
||||
else:
|
||||
print(" ⚠ Web UI npm install failed (hermes web will not be available)")
|
||||
|
||||
print()
|
||||
print("✓ Code updated!")
|
||||
|
||||
@@ -3289,7 +3341,7 @@ def _coalesce_session_name_args(argv: list) -> list:
|
||||
"chat", "model", "gateway", "setup", "whatsapp", "login", "logout",
|
||||
"status", "cron", "doctor", "config", "pairing", "skills", "tools",
|
||||
"mcp", "sessions", "insights", "version", "update", "uninstall",
|
||||
"profile",
|
||||
"profile", "web",
|
||||
}
|
||||
_SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"}
|
||||
|
||||
@@ -4152,7 +4204,6 @@ For more help on a command:
|
||||
def cmd_skills(args):
|
||||
# Route 'config' action to skills_config module
|
||||
if getattr(args, 'skills_action', None) == 'config':
|
||||
_require_tty("skills config")
|
||||
from hermes_cli.skills_config import skills_command as skills_config_command
|
||||
skills_config_command(args)
|
||||
else:
|
||||
@@ -4363,7 +4414,6 @@ For more help on a command:
|
||||
from hermes_cli.tools_config import tools_disable_enable_command
|
||||
tools_disable_enable_command(args)
|
||||
else:
|
||||
_require_tty("tools")
|
||||
from hermes_cli.tools_config import tools_command
|
||||
tools_command(args)
|
||||
|
||||
@@ -4836,6 +4886,17 @@ For more help on a command:
|
||||
help="Shell type (default: bash)",
|
||||
)
|
||||
completion_parser.set_defaults(func=cmd_completion)
|
||||
# web command
|
||||
# =========================================================================
|
||||
web_parser = subparsers.add_parser(
|
||||
"web",
|
||||
help="Start the web UI",
|
||||
description="Launch the Hermes Agent web dashboard"
|
||||
)
|
||||
web_parser.add_argument("--port", type=int, default=9119, help="Port (default 9119)")
|
||||
web_parser.add_argument("--host", default="127.0.0.1", help="Host (default 127.0.0.1)")
|
||||
web_parser.add_argument("--no-open", action="store_true", help="Don't open browser automatically")
|
||||
web_parser.set_defaults(func=cmd_web)
|
||||
|
||||
# =========================================================================
|
||||
# Parse and execute
|
||||
|
||||
@@ -511,10 +511,6 @@ def _interpolate_value(value: str) -> str:
|
||||
|
||||
def cmd_mcp_configure(args):
|
||||
"""Reconfigure which tools are enabled for an existing MCP server."""
|
||||
import sys as _sys
|
||||
if not _sys.stdin.isatty():
|
||||
print("Error: 'hermes mcp configure' requires an interactive terminal.", file=_sys.stderr)
|
||||
_sys.exit(1)
|
||||
name = args.name
|
||||
servers = _get_mcp_servers()
|
||||
|
||||
|
||||
@@ -354,14 +354,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
extra_metadata.update(getattr(bundle, "metadata", {}) or {})
|
||||
|
||||
# Quarantine the bundle
|
||||
try:
|
||||
q_path = quarantine_bundle(bundle)
|
||||
except ValueError as exc:
|
||||
c.print(f"[bold red]Installation blocked:[/] {exc}\n")
|
||||
from tools.skills_hub import append_audit_log
|
||||
append_audit_log("BLOCKED", bundle.name, bundle.source,
|
||||
bundle.trust_level, "invalid_path", str(exc))
|
||||
return
|
||||
q_path = quarantine_bundle(bundle)
|
||||
c.print(f"[dim]Quarantined to {q_path.relative_to(q_path.parent.parent.parent)}[/]")
|
||||
|
||||
# Scan
|
||||
@@ -421,15 +414,7 @@ def do_install(identifier: str, category: str = "", force: bool = False,
|
||||
return
|
||||
|
||||
# Install
|
||||
try:
|
||||
install_dir = install_from_quarantine(q_path, bundle.name, category, bundle, result)
|
||||
except ValueError as exc:
|
||||
c.print(f"[bold red]Installation blocked:[/] {exc}\n")
|
||||
shutil.rmtree(q_path, ignore_errors=True)
|
||||
from tools.skills_hub import append_audit_log
|
||||
append_audit_log("BLOCKED", bundle.name, bundle.source,
|
||||
bundle.trust_level, "invalid_path", str(exc))
|
||||
return
|
||||
install_dir = install_from_quarantine(q_path, bundle.name, category, bundle, result)
|
||||
from tools.skills_hub import SKILLS_DIR
|
||||
c.print(f"[bold green]Installed:[/] {install_dir.relative_to(SKILLS_DIR)}")
|
||||
c.print(f"[dim]Files: {', '.join(bundle.files.keys())}[/]\n")
|
||||
|
||||
346
hermes_cli/web_server.py
Normal file
346
hermes_cli/web_server.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
Hermes Agent — Web UI server.
|
||||
|
||||
Provides a FastAPI backend serving the Vite/React frontend and REST API
|
||||
endpoints for managing configuration, environment variables, and sessions.
|
||||
|
||||
Usage:
|
||||
python -m hermes_cli.main web # Start on http://127.0.0.1:9119
|
||||
python -m hermes_cli.main web --port 8080
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from hermes_cli import __version__, __release_date__
|
||||
from hermes_cli.config import (
|
||||
DEFAULT_CONFIG,
|
||||
OPTIONAL_ENV_VARS,
|
||||
get_config_path,
|
||||
get_env_path,
|
||||
get_hermes_home,
|
||||
load_config,
|
||||
load_env,
|
||||
save_config,
|
||||
save_env_value,
|
||||
delete_env_value,
|
||||
check_config_version,
|
||||
redact_key,
|
||||
)
|
||||
from gateway.status import get_running_pid, read_runtime_status
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
except ImportError:
|
||||
raise SystemExit(
|
||||
"Web UI requires fastapi and uvicorn.\n"
|
||||
"Run 'hermes web' to auto-install, or: pip install hermes-agent[web]"
|
||||
)
|
||||
|
||||
WEB_DIST = Path(__file__).parent / "web_dist"
|
||||
|
||||
app = FastAPI(title="Hermes Agent", version=__version__)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = {
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Default model for chat",
|
||||
"category": "general",
|
||||
},
|
||||
"provider": {
|
||||
"type": "select",
|
||||
"description": "LLM provider",
|
||||
"options": ["auto", "openrouter", "nous", "anthropic", "openai", "codex", "custom"],
|
||||
"category": "general",
|
||||
},
|
||||
"system_prompt": {
|
||||
"type": "text",
|
||||
"description": "System prompt prepended to every conversation",
|
||||
"category": "general",
|
||||
},
|
||||
"toolsets": {
|
||||
"type": "list",
|
||||
"description": "Enabled toolsets",
|
||||
"category": "general",
|
||||
},
|
||||
"agent.max_turns": {
|
||||
"type": "number",
|
||||
"description": "Maximum agent turns per conversation",
|
||||
"category": "agent",
|
||||
},
|
||||
"terminal.backend": {
|
||||
"type": "select",
|
||||
"description": "Terminal execution backend",
|
||||
"options": ["local", "docker", "ssh", "modal", "daytona", "singularity"],
|
||||
"category": "terminal",
|
||||
},
|
||||
"terminal.timeout": {
|
||||
"type": "number",
|
||||
"description": "Command timeout (seconds)",
|
||||
"category": "terminal",
|
||||
},
|
||||
"terminal.cwd": {
|
||||
"type": "string",
|
||||
"description": "Working directory for terminal commands",
|
||||
"category": "terminal",
|
||||
},
|
||||
"browser.inactivity_timeout": {
|
||||
"type": "number",
|
||||
"description": "Browser inactivity timeout (seconds)",
|
||||
"category": "browser",
|
||||
},
|
||||
"compression.enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable context compression",
|
||||
"category": "compression",
|
||||
},
|
||||
"compression.threshold": {
|
||||
"type": "number",
|
||||
"description": "Context window usage threshold to trigger compression (0-1)",
|
||||
"category": "compression",
|
||||
},
|
||||
"display.compact": {
|
||||
"type": "boolean",
|
||||
"description": "Compact display mode",
|
||||
"category": "display",
|
||||
},
|
||||
"display.personality": {
|
||||
"type": "select",
|
||||
"description": "Agent personality",
|
||||
"options": ["kawaii", "professional", "minimal", "hacker"],
|
||||
"category": "display",
|
||||
},
|
||||
"display.show_reasoning": {
|
||||
"type": "boolean",
|
||||
"description": "Show model reasoning/thinking",
|
||||
"category": "display",
|
||||
},
|
||||
"display.bell_on_complete": {
|
||||
"type": "boolean",
|
||||
"description": "Ring terminal bell when agent finishes",
|
||||
"category": "display",
|
||||
},
|
||||
"tts.provider": {
|
||||
"type": "select",
|
||||
"description": "Text-to-speech provider",
|
||||
"options": ["edge", "elevenlabs", "openai"],
|
||||
"category": "tts",
|
||||
},
|
||||
"checkpoints.enabled": {
|
||||
"type": "boolean",
|
||||
"description": "Enable filesystem checkpoints before destructive ops",
|
||||
"category": "checkpoints",
|
||||
},
|
||||
"checkpoints.max_snapshots": {
|
||||
"type": "number",
|
||||
"description": "Max checkpoint snapshots per directory",
|
||||
"category": "checkpoints",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ConfigUpdate(BaseModel):
|
||||
config: dict
|
||||
|
||||
|
||||
class EnvVarUpdate(BaseModel):
|
||||
key: str
|
||||
value: str
|
||||
|
||||
|
||||
class EnvVarDelete(BaseModel):
|
||||
key: str
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def get_status():
|
||||
current_ver, latest_ver = check_config_version()
|
||||
|
||||
gateway_pid = get_running_pid()
|
||||
gateway_running = gateway_pid is not None
|
||||
|
||||
gateway_state = None
|
||||
gateway_platforms: dict = {}
|
||||
gateway_exit_reason = None
|
||||
gateway_updated_at = None
|
||||
runtime = read_runtime_status()
|
||||
if runtime:
|
||||
gateway_state = runtime.get("gateway_state")
|
||||
gateway_platforms = runtime.get("platforms") or {}
|
||||
gateway_exit_reason = runtime.get("exit_reason")
|
||||
gateway_updated_at = runtime.get("updated_at")
|
||||
if not gateway_running:
|
||||
gateway_state = gateway_state if gateway_state in ("stopped", "startup_failed") else "stopped"
|
||||
|
||||
active_sessions = 0
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
sessions = db.list_sessions_rich(limit=50)
|
||||
now = time.time()
|
||||
active_sessions = sum(
|
||||
1 for s in sessions
|
||||
if s.get("ended_at") is None
|
||||
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"version": __version__,
|
||||
"release_date": __release_date__,
|
||||
"hermes_home": str(get_hermes_home()),
|
||||
"config_path": str(get_config_path()),
|
||||
"env_path": str(get_env_path()),
|
||||
"config_version": current_ver,
|
||||
"latest_config_version": latest_ver,
|
||||
"gateway_running": gateway_running,
|
||||
"gateway_pid": gateway_pid,
|
||||
"gateway_state": gateway_state,
|
||||
"gateway_platforms": gateway_platforms,
|
||||
"gateway_exit_reason": gateway_exit_reason,
|
||||
"gateway_updated_at": gateway_updated_at,
|
||||
"active_sessions": active_sessions,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/sessions")
|
||||
async def get_sessions():
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
sessions = db.list_sessions_rich(limit=20)
|
||||
now = time.time()
|
||||
for s in sessions:
|
||||
s["is_active"] = (
|
||||
s.get("ended_at") is None
|
||||
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
||||
)
|
||||
return sessions
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/config")
|
||||
async def get_config():
|
||||
return load_config()
|
||||
|
||||
|
||||
@app.get("/api/config/defaults")
|
||||
async def get_defaults():
|
||||
return DEFAULT_CONFIG
|
||||
|
||||
|
||||
@app.get("/api/config/schema")
|
||||
async def get_schema():
|
||||
return CONFIG_SCHEMA
|
||||
|
||||
|
||||
@app.put("/api/config")
|
||||
async def update_config(body: ConfigUpdate):
|
||||
try:
|
||||
save_config(body.config)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.get("/api/env")
|
||||
async def get_env_vars():
|
||||
env_on_disk = load_env()
|
||||
result = {}
|
||||
for var_name, info in OPTIONAL_ENV_VARS.items():
|
||||
value = env_on_disk.get(var_name)
|
||||
result[var_name] = {
|
||||
"is_set": bool(value),
|
||||
"redacted_value": redact_key(value) if value else None,
|
||||
"description": info.get("description", ""),
|
||||
"url": info.get("url"),
|
||||
"category": info.get("category", ""),
|
||||
"is_password": info.get("password", False),
|
||||
"tools": info.get("tools", []),
|
||||
"advanced": info.get("advanced", False),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
@app.put("/api/env")
|
||||
async def set_env_var(body: EnvVarUpdate):
|
||||
try:
|
||||
save_env_value(body.key, body.value)
|
||||
return {"ok": True, "key": body.key}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@app.delete("/api/env")
|
||||
async def remove_env_var(body: EnvVarDelete):
|
||||
try:
|
||||
removed = delete_env_value(body.key)
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail=f"{body.key} not found in .env")
|
||||
return {"ok": True, "key": body.key}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def mount_spa(application: FastAPI):
|
||||
"""Mount the built SPA. Falls back to index.html for client-side routing."""
|
||||
if not WEB_DIST.exists():
|
||||
@application.get("/{full_path:path}")
|
||||
async def no_frontend(full_path: str):
|
||||
return JSONResponse(
|
||||
{"error": "Frontend not built. Run: cd web && npm run build"},
|
||||
status_code=404,
|
||||
)
|
||||
return
|
||||
|
||||
application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
|
||||
|
||||
@application.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
file_path = WEB_DIST / full_path
|
||||
if full_path and file_path.exists() and file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
return FileResponse(WEB_DIST / "index.html")
|
||||
|
||||
|
||||
mount_spa(app)
|
||||
|
||||
|
||||
def start_server(host: str = "127.0.0.1", port: int = 9119, open_browser: bool = True):
|
||||
"""Start the web UI server."""
|
||||
import uvicorn
|
||||
|
||||
if open_browser:
|
||||
import threading
|
||||
import webbrowser
|
||||
|
||||
def _open():
|
||||
import time as _t
|
||||
_t.sleep(1.0)
|
||||
webbrowser.open(f"http://{host}:{port}")
|
||||
|
||||
threading.Thread(target=_open, daemon=True).start()
|
||||
|
||||
print(f" Hermes Web UI → http://{host}:{port}")
|
||||
uvicorn.run(app, host=host, port=port, log_level="warning")
|
||||
@@ -22,6 +22,8 @@ Public API (signatures preserved from the original 2,400-line version):
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
@@ -364,6 +366,32 @@ def get_tool_definitions(
|
||||
_AGENT_LOOP_TOOLS = {"todo", "memory", "session_search", "delegate_task"}
|
||||
_READ_SEARCH_TOOLS = {"read_file", "search_files"}
|
||||
|
||||
# Auto-reload .env: check file mtime at most every 5 seconds so new API keys
|
||||
# take effect without manual /reload or session restart.
|
||||
_env_last_check: float = 0.0
|
||||
_env_last_mtime: float = 0.0
|
||||
_ENV_CHECK_INTERVAL = 5.0
|
||||
|
||||
|
||||
def _maybe_reload_env() -> None:
|
||||
"""Stat ~/.hermes/.env and reload into os.environ if it changed."""
|
||||
global _env_last_check, _env_last_mtime
|
||||
now = time.monotonic()
|
||||
if now - _env_last_check < _ENV_CHECK_INTERVAL:
|
||||
return
|
||||
_env_last_check = now
|
||||
try:
|
||||
env_path = os.path.join(os.path.expanduser("~"), ".hermes", ".env")
|
||||
mtime = os.path.getmtime(env_path)
|
||||
if mtime != _env_last_mtime:
|
||||
_env_last_mtime = mtime
|
||||
from hermes_cli.config import reload_env
|
||||
reload_env()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def handle_function_call(
|
||||
function_name: str,
|
||||
@@ -390,6 +418,8 @@ def handle_function_call(
|
||||
Returns:
|
||||
Function result as a JSON string.
|
||||
"""
|
||||
_maybe_reload_env()
|
||||
|
||||
# Notify the read-loop tracker when a non-read/search tool runs,
|
||||
# so the *consecutive* counter resets (reads after other work are fine).
|
||||
if function_name not in _READ_SEARCH_TOOLS:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hermes-agent"
|
||||
version = "0.6.0"
|
||||
version = "0.5.0"
|
||||
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -67,6 +67,7 @@ rl = [
|
||||
"wandb>=0.15.0,<1",
|
||||
]
|
||||
yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git ; python_version >= '3.12'"]
|
||||
web = ["fastapi>=0.115.0", "uvicorn>=0.34.0"]
|
||||
all = [
|
||||
"hermes-agent[modal]",
|
||||
"hermes-agent[daytona]",
|
||||
@@ -85,6 +86,7 @@ all = [
|
||||
"hermes-agent[acp]",
|
||||
"hermes-agent[voice]",
|
||||
"hermes-agent[dingtalk]",
|
||||
"hermes-agent[web]",
|
||||
"hermes-agent[feishu]",
|
||||
]
|
||||
|
||||
@@ -96,6 +98,9 @@ hermes-acp = "acp_adapter.entry:main"
|
||||
[tool.setuptools]
|
||||
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "rl_cli", "utils"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
hermes_cli = ["web_dist/**/*"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "honcho_integration", "acp_adapter"]
|
||||
|
||||
|
||||
25
run_agent.py
25
run_agent.py
@@ -2907,19 +2907,6 @@ class AIAgent:
|
||||
})
|
||||
return converted or None
|
||||
|
||||
@staticmethod
|
||||
def _deterministic_call_id(fn_name: str, arguments: str, index: int = 0) -> str:
|
||||
"""Generate a deterministic call_id from tool call content.
|
||||
|
||||
Used as a fallback when the API doesn't provide a call_id.
|
||||
Deterministic IDs prevent cache invalidation — random UUIDs would
|
||||
make every API call's prefix unique, breaking OpenAI's prompt cache.
|
||||
"""
|
||||
import hashlib
|
||||
seed = f"{fn_name}:{arguments}:{index}"
|
||||
digest = hashlib.sha256(seed.encode("utf-8", errors="replace")).hexdigest()[:12]
|
||||
return f"call_{digest}"
|
||||
|
||||
@staticmethod
|
||||
def _split_responses_tool_id(raw_id: Any) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Split a stored tool id into (call_id, response_item_id)."""
|
||||
@@ -3026,8 +3013,7 @@ class AIAgent:
|
||||
):
|
||||
call_id = f"call_{embedded_response_item_id[len('fc_'):]}"
|
||||
else:
|
||||
_raw_args = str(fn.get("arguments", "{}"))
|
||||
call_id = self._deterministic_call_id(fn_name, _raw_args, len(items))
|
||||
call_id = f"call_{uuid.uuid4().hex[:12]}"
|
||||
call_id = call_id.strip()
|
||||
|
||||
arguments = fn.get("arguments", "{}")
|
||||
@@ -3391,7 +3377,7 @@ class AIAgent:
|
||||
embedded_call_id, _ = self._split_responses_tool_id(raw_item_id)
|
||||
call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id.strip() else embedded_call_id
|
||||
if not isinstance(call_id, str) or not call_id.strip():
|
||||
call_id = self._deterministic_call_id(fn_name, arguments, len(tool_calls))
|
||||
call_id = f"call_{uuid.uuid4().hex[:12]}"
|
||||
call_id = call_id.strip()
|
||||
response_item_id = raw_item_id if isinstance(raw_item_id, str) else None
|
||||
response_item_id = self._derive_responses_function_call_id(call_id, response_item_id)
|
||||
@@ -3412,7 +3398,7 @@ class AIAgent:
|
||||
embedded_call_id, _ = self._split_responses_tool_id(raw_item_id)
|
||||
call_id = raw_call_id if isinstance(raw_call_id, str) and raw_call_id.strip() else embedded_call_id
|
||||
if not isinstance(call_id, str) or not call_id.strip():
|
||||
call_id = self._deterministic_call_id(fn_name, arguments, len(tool_calls))
|
||||
call_id = f"call_{uuid.uuid4().hex[:12]}"
|
||||
call_id = call_id.strip()
|
||||
response_item_id = raw_item_id if isinstance(raw_item_id, str) else None
|
||||
response_item_id = self._derive_responses_function_call_id(call_id, response_item_id)
|
||||
@@ -4947,10 +4933,7 @@ class AIAgent:
|
||||
if isinstance(raw_id, str) and raw_id.strip():
|
||||
call_id = raw_id.strip()
|
||||
else:
|
||||
_fn = getattr(tool_call, "function", None)
|
||||
_fn_name = getattr(_fn, "name", "") if _fn else ""
|
||||
_fn_args = getattr(_fn, "arguments", "{}") if _fn else "{}"
|
||||
call_id = self._deterministic_call_id(_fn_name, _fn_args, len(tool_calls))
|
||||
call_id = f"call_{uuid.uuid4().hex[:12]}"
|
||||
call_id = call_id.strip()
|
||||
|
||||
response_item_id = getattr(tool_call, "response_item_id", None)
|
||||
|
||||
@@ -920,6 +920,15 @@ install_node_deps() {
|
||||
}
|
||||
log_success "WhatsApp bridge dependencies installed"
|
||||
fi
|
||||
|
||||
# Build web UI frontend
|
||||
if [ -f "$INSTALL_DIR/web/package.json" ]; then
|
||||
log_info "Building web UI..."
|
||||
cd "$INSTALL_DIR/web"
|
||||
npm install --silent 2>/dev/null && npm run build 2>/dev/null && \
|
||||
log_success "Web UI built" || \
|
||||
log_warn "Web UI build failed (hermes web will not be available)"
|
||||
fi
|
||||
}
|
||||
|
||||
run_setup_wizard() {
|
||||
|
||||
@@ -201,52 +201,3 @@ class TestSecretCapturePayloadRedaction:
|
||||
text = '{"raw_secret": "ghp_abc123def456ghi789jkl"}'
|
||||
result = redact_sensitive_text(text)
|
||||
assert "abc123def456" not in result
|
||||
|
||||
|
||||
class TestElevenLabsTavilyExaKeys:
|
||||
"""Regression tests for ElevenLabs (sk_), Tavily (tvly-), and Exa (exa_) keys."""
|
||||
|
||||
def test_elevenlabs_key_redacted(self):
|
||||
text = "ELEVENLABS_API_KEY=sk_abc123def456ghi789jklmnopqrstu"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "abc123def456ghi" not in result
|
||||
|
||||
def test_elevenlabs_key_in_log_line(self):
|
||||
text = "Connecting to ElevenLabs with key sk_abc123def456ghi789jklmnopqrstu"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "abc123def456ghi" not in result
|
||||
|
||||
def test_tavily_key_redacted(self):
|
||||
text = "TAVILY_API_KEY=tvly-ABCdef123456789GHIJKL0000"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "ABCdef123456789" not in result
|
||||
|
||||
def test_tavily_key_in_log_line(self):
|
||||
text = "Initialising Tavily client with tvly-ABCdef123456789GHIJKL0000"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "ABCdef123456789" not in result
|
||||
|
||||
def test_exa_key_redacted(self):
|
||||
text = "EXA_API_KEY=exa_XYZ789abcdef000000000000000"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "XYZ789abcdef" not in result
|
||||
|
||||
def test_exa_key_in_log_line(self):
|
||||
text = "Using Exa client with key exa_XYZ789abcdef000000000000000"
|
||||
result = redact_sensitive_text(text)
|
||||
assert "XYZ789abcdef" not in result
|
||||
|
||||
def test_all_three_in_env_dump(self):
|
||||
env_dump = (
|
||||
"HOME=/home/user\n"
|
||||
"ELEVENLABS_API_KEY=sk_abc123def456ghi789jklmnopqrstu\n"
|
||||
"TAVILY_API_KEY=tvly-ABCdef123456789GHIJKL0000\n"
|
||||
"EXA_API_KEY=exa_XYZ789abcdef000000000000000\n"
|
||||
"SHELL=/bin/bash\n"
|
||||
)
|
||||
result = redact_sensitive_text(env_dump)
|
||||
assert "abc123def456ghi" not in result
|
||||
assert "ABCdef123456789" not in result
|
||||
assert "XYZ789abcdef" not in result
|
||||
assert "HOME=/home/user" in result
|
||||
assert "SHELL=/bin/bash" in result
|
||||
|
||||
@@ -126,20 +126,9 @@ class TestAppMentionHandler:
|
||||
"user": "testbot",
|
||||
})
|
||||
|
||||
# Mock AsyncWebClient so multi-workspace auth_test is awaitable
|
||||
mock_web_client = AsyncMock()
|
||||
mock_web_client.auth_test = AsyncMock(return_value={
|
||||
"user_id": "U_BOT",
|
||||
"user": "testbot",
|
||||
"team_id": "T_FAKE",
|
||||
"team": "FakeTeam",
|
||||
})
|
||||
|
||||
with patch.object(_slack_mod, "AsyncApp", return_value=mock_app), \
|
||||
patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), \
|
||||
patch.object(_slack_mod, "AsyncSocketModeHandler", return_value=MagicMock()), \
|
||||
patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), \
|
||||
patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \
|
||||
patch("asyncio.create_task"):
|
||||
asyncio.run(adapter.connect())
|
||||
|
||||
|
||||
@@ -60,43 +60,34 @@ class TestToolsSlashList:
|
||||
|
||||
class TestToolsSlashDisableWithReset:
|
||||
|
||||
def test_disable_applies_directly_and_resets_session(self):
|
||||
"""Disable applies immediately (no confirmation prompt) and resets session."""
|
||||
def test_disable_confirms_then_resets_session(self):
|
||||
cli_obj = _make_cli(["web", "memory"])
|
||||
with patch("hermes_cli.tools_config.load_config",
|
||||
return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \
|
||||
patch("hermes_cli.tools_config.save_config"), \
|
||||
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \
|
||||
patch("hermes_cli.config.load_config", return_value={}), \
|
||||
patch.object(cli_obj, "new_session") as mock_reset:
|
||||
patch.object(cli_obj, "new_session") as mock_reset, \
|
||||
patch("builtins.input", return_value="y"):
|
||||
cli_obj._handle_tools_command("/tools disable web")
|
||||
mock_reset.assert_called_once()
|
||||
assert "web" not in cli_obj.enabled_toolsets
|
||||
|
||||
def test_disable_does_not_prompt_for_confirmation(self):
|
||||
"""Disable no longer uses input() — it applies directly."""
|
||||
def test_disable_cancelled_does_not_reset(self):
|
||||
cli_obj = _make_cli(["web", "memory"])
|
||||
with patch("hermes_cli.tools_config.load_config",
|
||||
return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \
|
||||
patch("hermes_cli.tools_config.save_config"), \
|
||||
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \
|
||||
patch("hermes_cli.config.load_config", return_value={}), \
|
||||
patch.object(cli_obj, "new_session"), \
|
||||
patch("builtins.input") as mock_input:
|
||||
with patch.object(cli_obj, "new_session") as mock_reset, \
|
||||
patch("builtins.input", return_value="n"):
|
||||
cli_obj._handle_tools_command("/tools disable web")
|
||||
mock_input.assert_not_called()
|
||||
mock_reset.assert_not_called()
|
||||
# Toolsets unchanged
|
||||
assert cli_obj.enabled_toolsets == {"web", "memory"}
|
||||
|
||||
def test_disable_always_resets_session(self):
|
||||
"""Even without a confirmation prompt, disable always resets the session."""
|
||||
def test_disable_eof_cancels(self):
|
||||
cli_obj = _make_cli(["web", "memory"])
|
||||
with patch("hermes_cli.tools_config.load_config",
|
||||
return_value={"platform_toolsets": {"cli": ["web", "memory"]}}), \
|
||||
patch("hermes_cli.tools_config.save_config"), \
|
||||
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory"}), \
|
||||
patch("hermes_cli.config.load_config", return_value={}), \
|
||||
patch.object(cli_obj, "new_session") as mock_reset:
|
||||
with patch.object(cli_obj, "new_session") as mock_reset, \
|
||||
patch("builtins.input", side_effect=EOFError):
|
||||
cli_obj._handle_tools_command("/tools disable web")
|
||||
mock_reset.assert_called_once()
|
||||
mock_reset.assert_not_called()
|
||||
|
||||
def test_disable_missing_name_prints_usage(self, capsys):
|
||||
cli_obj = _make_cli()
|
||||
@@ -110,15 +101,15 @@ class TestToolsSlashDisableWithReset:
|
||||
|
||||
class TestToolsSlashEnableWithReset:
|
||||
|
||||
def test_enable_applies_directly_and_resets_session(self):
|
||||
"""Enable applies immediately (no confirmation prompt) and resets session."""
|
||||
def test_enable_confirms_then_resets_session(self):
|
||||
cli_obj = _make_cli(["memory"])
|
||||
with patch("hermes_cli.tools_config.load_config",
|
||||
return_value={"platform_toolsets": {"cli": ["memory"]}}), \
|
||||
patch("hermes_cli.tools_config.save_config"), \
|
||||
patch("hermes_cli.tools_config._get_platform_tools", return_value={"memory", "web"}), \
|
||||
patch("hermes_cli.config.load_config", return_value={}), \
|
||||
patch.object(cli_obj, "new_session") as mock_reset:
|
||||
patch.object(cli_obj, "new_session") as mock_reset, \
|
||||
patch("builtins.input", return_value="y"):
|
||||
cli_obj._handle_tools_command("/tools enable web")
|
||||
mock_reset.assert_called_once()
|
||||
assert "web" in cli_obj.enabled_toolsets
|
||||
|
||||
@@ -5,7 +5,6 @@ from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from tools.skills_hub import (
|
||||
GitHubAuth,
|
||||
@@ -649,29 +648,6 @@ class TestWellKnownSkillSource:
|
||||
assert bundle.files["SKILL.md"] == "# Code Review\n"
|
||||
assert bundle.files["references/checklist.md"] == "- [ ] security\n"
|
||||
|
||||
@patch("tools.skills_hub._write_index_cache")
|
||||
@patch("tools.skills_hub._read_index_cache", return_value=None)
|
||||
@patch("tools.skills_hub.httpx.get")
|
||||
def test_fetch_rejects_unsafe_file_paths_from_well_known_endpoint(self, mock_get, _mock_read_cache, _mock_write_cache):
|
||||
def fake_get(url, *args, **kwargs):
|
||||
if url.endswith("/index.json"):
|
||||
return MagicMock(status_code=200, json=lambda: {
|
||||
"skills": [{
|
||||
"name": "code-review",
|
||||
"description": "Review code",
|
||||
"files": ["SKILL.md", "../../../escape.txt"],
|
||||
}]
|
||||
})
|
||||
if url.endswith("/code-review/SKILL.md"):
|
||||
return MagicMock(status_code=200, text="# Code Review\n")
|
||||
raise AssertionError(url)
|
||||
|
||||
mock_get.side_effect = fake_get
|
||||
|
||||
bundle = self._source().fetch("well-known:https://example.com/.well-known/skills/code-review")
|
||||
|
||||
assert bundle is None
|
||||
|
||||
|
||||
class TestCheckForSkillUpdates:
|
||||
def test_bundle_content_hash_matches_installed_content_hash(self, tmp_path):
|
||||
@@ -1167,61 +1143,6 @@ class TestQuarantineBundleBinaryAssets:
|
||||
assert (q_path / "SKILL.md").read_text(encoding="utf-8").startswith("---")
|
||||
assert (q_path / "assets" / "neutts-cli" / "samples" / "jo.wav").read_bytes() == b"RIFF\x00\x01fakewav"
|
||||
|
||||
def test_quarantine_bundle_rejects_traversal_file_paths(self, tmp_path):
|
||||
import tools.skills_hub as hub
|
||||
|
||||
hub_dir = tmp_path / "skills" / ".hub"
|
||||
with patch.object(hub, "SKILLS_DIR", tmp_path / "skills"), \
|
||||
patch.object(hub, "HUB_DIR", hub_dir), \
|
||||
patch.object(hub, "LOCK_FILE", hub_dir / "lock.json"), \
|
||||
patch.object(hub, "QUARANTINE_DIR", hub_dir / "quarantine"), \
|
||||
patch.object(hub, "AUDIT_LOG", hub_dir / "audit.log"), \
|
||||
patch.object(hub, "TAPS_FILE", hub_dir / "taps.json"), \
|
||||
patch.object(hub, "INDEX_CACHE_DIR", hub_dir / "index-cache"):
|
||||
bundle = SkillBundle(
|
||||
name="demo",
|
||||
files={
|
||||
"SKILL.md": "---\nname: demo\n---\n",
|
||||
"../../../escape.txt": "owned",
|
||||
},
|
||||
source="well-known",
|
||||
identifier="well-known:https://example.com/.well-known/skills/demo",
|
||||
trust_level="community",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Unsafe bundle file path"):
|
||||
quarantine_bundle(bundle)
|
||||
|
||||
assert not (tmp_path / "skills" / "escape.txt").exists()
|
||||
|
||||
def test_quarantine_bundle_rejects_absolute_file_paths(self, tmp_path):
|
||||
import tools.skills_hub as hub
|
||||
|
||||
hub_dir = tmp_path / "skills" / ".hub"
|
||||
absolute_target = tmp_path / "outside.txt"
|
||||
with patch.object(hub, "SKILLS_DIR", tmp_path / "skills"), \
|
||||
patch.object(hub, "HUB_DIR", hub_dir), \
|
||||
patch.object(hub, "LOCK_FILE", hub_dir / "lock.json"), \
|
||||
patch.object(hub, "QUARANTINE_DIR", hub_dir / "quarantine"), \
|
||||
patch.object(hub, "AUDIT_LOG", hub_dir / "audit.log"), \
|
||||
patch.object(hub, "TAPS_FILE", hub_dir / "taps.json"), \
|
||||
patch.object(hub, "INDEX_CACHE_DIR", hub_dir / "index-cache"):
|
||||
bundle = SkillBundle(
|
||||
name="demo",
|
||||
files={
|
||||
"SKILL.md": "---\nname: demo\n---\n",
|
||||
str(absolute_target): "owned",
|
||||
},
|
||||
source="well-known",
|
||||
identifier="well-known:https://example.com/.well-known/skills/demo",
|
||||
trust_level="community",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Unsafe bundle file path"):
|
||||
quarantine_bundle(bundle)
|
||||
|
||||
assert not absolute_target.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHubSource._download_directory — tree API + fallback (#2940)
|
||||
|
||||
@@ -259,12 +259,6 @@ def test_check_website_access_uses_dynamic_hermes_home(monkeypatch, tmp_path):
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
# Invalidate the module-level cache so the new HERMES_HOME is picked up.
|
||||
# A prior test may have cached a default policy (enabled=False) under the
|
||||
# old HERMES_HOME set by the autouse _isolate_hermes_home fixture.
|
||||
from tools.website_policy import invalidate_cache
|
||||
invalidate_cache()
|
||||
|
||||
blocked = check_website_access("https://dynamic.example/path")
|
||||
|
||||
assert blocked is not None
|
||||
|
||||
@@ -24,7 +24,7 @@ import time
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
@@ -85,43 +85,6 @@ class SkillBundle:
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _normalize_bundle_path(path_value: str, *, field_name: str, allow_nested: bool) -> str:
|
||||
"""Normalize and validate bundle-controlled paths before touching disk."""
|
||||
if not isinstance(path_value, str):
|
||||
raise ValueError(f"Unsafe {field_name}: expected a string")
|
||||
|
||||
raw = path_value.strip()
|
||||
if not raw:
|
||||
raise ValueError(f"Unsafe {field_name}: empty path")
|
||||
|
||||
normalized = raw.replace("\\", "/")
|
||||
path = PurePosixPath(normalized)
|
||||
parts = [part for part in path.parts if part not in ("", ".")]
|
||||
|
||||
if normalized.startswith("/") or path.is_absolute():
|
||||
raise ValueError(f"Unsafe {field_name}: {path_value}")
|
||||
if not parts or any(part == ".." for part in parts):
|
||||
raise ValueError(f"Unsafe {field_name}: {path_value}")
|
||||
if re.fullmatch(r"[A-Za-z]:", parts[0]):
|
||||
raise ValueError(f"Unsafe {field_name}: {path_value}")
|
||||
if not allow_nested and len(parts) != 1:
|
||||
raise ValueError(f"Unsafe {field_name}: {path_value}")
|
||||
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def _validate_skill_name(name: str) -> str:
|
||||
return _normalize_bundle_path(name, field_name="skill name", allow_nested=False)
|
||||
|
||||
|
||||
def _validate_category_name(category: str) -> str:
|
||||
return _normalize_bundle_path(category, field_name="category", allow_nested=False)
|
||||
|
||||
|
||||
def _validate_bundle_rel_path(rel_path: str) -> str:
|
||||
return _normalize_bundle_path(rel_path, field_name="bundle file path", allow_nested=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHub Authentication
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -738,12 +701,6 @@ class WellKnownSkillSource(SkillSource):
|
||||
if not parsed:
|
||||
return None
|
||||
|
||||
try:
|
||||
skill_name = _validate_skill_name(parsed["skill_name"])
|
||||
except ValueError:
|
||||
logger.warning("Well-known skill identifier contained unsafe skill name: %s", identifier)
|
||||
return None
|
||||
|
||||
entry = self._index_entry(parsed["index_url"], parsed["skill_name"])
|
||||
if not entry:
|
||||
return None
|
||||
@@ -756,28 +713,19 @@ class WellKnownSkillSource(SkillSource):
|
||||
for rel_path in files:
|
||||
if not isinstance(rel_path, str) or not rel_path:
|
||||
continue
|
||||
try:
|
||||
safe_rel_path = _validate_bundle_rel_path(rel_path)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Well-known skill %s advertised unsafe file path: %r",
|
||||
identifier,
|
||||
rel_path,
|
||||
)
|
||||
return None
|
||||
text = self._fetch_text(f"{parsed['skill_url']}/{safe_rel_path}")
|
||||
text = self._fetch_text(f"{parsed['skill_url']}/{rel_path}")
|
||||
if text is None:
|
||||
return None
|
||||
downloaded[safe_rel_path] = text
|
||||
downloaded[rel_path] = text
|
||||
|
||||
if "SKILL.md" not in downloaded:
|
||||
return None
|
||||
|
||||
return SkillBundle(
|
||||
name=skill_name,
|
||||
name=parsed["skill_name"],
|
||||
files=downloaded,
|
||||
source="well-known",
|
||||
identifier=self._wrap_identifier(parsed["base_url"], skill_name),
|
||||
identifier=self._wrap_identifier(parsed["base_url"], parsed["skill_name"]),
|
||||
trust_level="community",
|
||||
metadata={
|
||||
"index_url": parsed["index_url"],
|
||||
@@ -1804,10 +1752,9 @@ class ClawHubSource(SkillSource):
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
try:
|
||||
name = _validate_bundle_rel_path(info.filename)
|
||||
except ValueError:
|
||||
logger.debug("Skipping unsafe ZIP member path: %s", info.filename)
|
||||
# Sanitize path — strip leading slashes and ..
|
||||
name = info.filename.lstrip("/")
|
||||
if ".." in name or name.startswith("/"):
|
||||
continue
|
||||
# Only extract text-sized files (skip large binaries)
|
||||
if info.file_size > 500_000:
|
||||
@@ -2476,19 +2423,13 @@ def ensure_hub_dirs() -> None:
|
||||
def quarantine_bundle(bundle: SkillBundle) -> Path:
|
||||
"""Write a skill bundle to the quarantine directory for scanning."""
|
||||
ensure_hub_dirs()
|
||||
skill_name = _validate_skill_name(bundle.name)
|
||||
validated_files: List[Tuple[str, Union[str, bytes]]] = []
|
||||
for rel_path, file_content in bundle.files.items():
|
||||
safe_rel_path = _validate_bundle_rel_path(rel_path)
|
||||
validated_files.append((safe_rel_path, file_content))
|
||||
|
||||
dest = QUARANTINE_DIR / skill_name
|
||||
dest = QUARANTINE_DIR / bundle.name
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
dest.mkdir(parents=True)
|
||||
|
||||
for rel_path, file_content in validated_files:
|
||||
file_dest = dest.joinpath(*rel_path.split("/"))
|
||||
for rel_path, file_content in bundle.files.items():
|
||||
file_dest = dest / rel_path
|
||||
file_dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
if isinstance(file_content, bytes):
|
||||
file_dest.write_bytes(file_content)
|
||||
@@ -2506,17 +2447,10 @@ def install_from_quarantine(
|
||||
scan_result: ScanResult,
|
||||
) -> Path:
|
||||
"""Move a scanned skill from quarantine into the skills directory."""
|
||||
safe_skill_name = _validate_skill_name(skill_name)
|
||||
safe_category = _validate_category_name(category) if category else ""
|
||||
quarantine_resolved = quarantine_path.resolve()
|
||||
quarantine_root = QUARANTINE_DIR.resolve()
|
||||
if not quarantine_resolved.is_relative_to(quarantine_root):
|
||||
raise ValueError(f"Unsafe quarantine path: {quarantine_path}")
|
||||
|
||||
if safe_category:
|
||||
install_dir = SKILLS_DIR / safe_category / safe_skill_name
|
||||
if category:
|
||||
install_dir = SKILLS_DIR / category / skill_name
|
||||
else:
|
||||
install_dir = SKILLS_DIR / safe_skill_name
|
||||
install_dir = SKILLS_DIR / skill_name
|
||||
|
||||
if install_dir.exists():
|
||||
shutil.rmtree(install_dir)
|
||||
@@ -2527,7 +2461,7 @@ def install_from_quarantine(
|
||||
# Record in lock file
|
||||
lock = HubLockFile()
|
||||
lock.record_install(
|
||||
name=safe_skill_name,
|
||||
name=skill_name,
|
||||
source=bundle.source,
|
||||
identifier=bundle.identifier,
|
||||
trust_level=bundle.trust_level,
|
||||
@@ -2539,7 +2473,7 @@ def install_from_quarantine(
|
||||
)
|
||||
|
||||
append_audit_log(
|
||||
"INSTALL", safe_skill_name, bundle.source,
|
||||
"INSTALL", skill_name, bundle.source,
|
||||
bundle.trust_level, scan_result.verdict,
|
||||
content_hash(install_dir),
|
||||
)
|
||||
|
||||
48
web/README.md
Normal file
48
web/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Hermes Agent — Web UI
|
||||
|
||||
Browser-based dashboard for managing Hermes Agent configuration, API keys, and monitoring active sessions.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Vite** + **React 19** + **TypeScript**
|
||||
- **Tailwind CSS v4** with custom dark theme
|
||||
- **shadcn/ui**-style components (hand-rolled, no CLI dependency)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Start the backend API server
|
||||
cd ../
|
||||
python -m hermes_cli.main web --no-open
|
||||
|
||||
# In another terminal, start the Vite dev server (with HMR + API proxy)
|
||||
cd web/
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The Vite dev server proxies `/api` requests to `http://127.0.0.1:9119` (the FastAPI backend).
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
This outputs to `../hermes_cli/web_dist/`, which the FastAPI server serves as a static SPA. The built assets are included in the Python package via `pyproject.toml` package-data.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ui/ # Reusable UI primitives (Card, Badge, Button, Input, etc.)
|
||||
├── lib/
|
||||
│ ├── api.ts # API client — typed fetch wrappers for all backend endpoints
|
||||
│ └── utils.ts # cn() helper for Tailwind class merging
|
||||
├── pages/
|
||||
│ ├── StatusPage # Agent status, active/recent sessions
|
||||
│ ├── ConfigPage # Dynamic config editor (reads schema from backend)
|
||||
│ └── EnvPage # API key management with save/clear
|
||||
├── App.tsx # Main layout and navigation
|
||||
├── main.tsx # React entry point
|
||||
└── index.css # Tailwind imports and theme variables
|
||||
```
|
||||
23
web/eslint.config.js
Normal file
23
web/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hermes Agent</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3906
web/package-lock.json
generated
Normal file
3906
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
web/package.json
Normal file
37
web/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
BIN
web/public/favicon.ico
Normal file
BIN
web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
51
web/src/App.tsx
Normal file
51
web/src/App.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useState } from "react";
|
||||
import { Activity, KeyRound, Settings } from "lucide-react";
|
||||
import StatusPage from "@/pages/StatusPage";
|
||||
import ConfigPage from "@/pages/ConfigPage";
|
||||
import EnvPage from "@/pages/EnvPage";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ id: "status", label: "Status", icon: Activity },
|
||||
{ id: "config", label: "Config", icon: Settings },
|
||||
{ id: "env", label: "API Keys", icon: KeyRound },
|
||||
] as const;
|
||||
|
||||
type PageId = (typeof NAV_ITEMS)[number]["id"];
|
||||
|
||||
export default function App() {
|
||||
const [page, setPage] = useState<PageId>("status");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background text-foreground">
|
||||
<header className="sticky top-0 z-40 border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="mx-auto flex h-14 max-w-5xl items-center gap-6 px-6">
|
||||
<span className="text-lg font-bold tracking-tight">Hermes Agent</span>
|
||||
|
||||
<nav className="flex items-center gap-1">
|
||||
{NAV_ITEMS.map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => setPage(id)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors cursor-pointer ${
|
||||
page === id
|
||||
? "bg-secondary text-secondary-foreground"
|
||||
: "text-muted-foreground hover:bg-secondary/50 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-6 py-8">
|
||||
{page === "status" && <StatusPage />}
|
||||
{page === "config" && <ConfigPage />}
|
||||
{page === "env" && <EnvPage />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
web/src/components/AutoField.tsx
Normal file
127
web/src/components/AutoField.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
export function AutoField({
|
||||
schemaKey,
|
||||
schema,
|
||||
value,
|
||||
onChange,
|
||||
}: AutoFieldProps) {
|
||||
const label = schemaKey.split(".").pop() ?? schemaKey;
|
||||
const description = String(schema.description ?? "");
|
||||
|
||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||
const obj = value as Record<string, unknown>;
|
||||
return (
|
||||
<div className="grid gap-3 rounded-lg border border-border p-3">
|
||||
<Label className="text-xs font-medium">{label}</Label>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
{Object.entries(obj).map(([subKey, subVal]) => (
|
||||
<div key={subKey} className="grid gap-1">
|
||||
<Label className="text-xs text-muted-foreground">{subKey}</Label>
|
||||
<Input
|
||||
value={String(subVal ?? "")}
|
||||
onChange={(e) => onChange({ ...obj, [subKey]: e.target.value })}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.type === "boolean") {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
<Switch checked={!!value} onCheckedChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.type === "select") {
|
||||
const options = (schema.options as string[]) ?? [];
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
<Select value={String(value ?? "")} onChange={(e) => onChange(e.target.value)}>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.type === "number") {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
<Input
|
||||
type="number"
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.type === "text") {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
<textarea
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={String(value ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.type === "list") {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
<Input
|
||||
value={Array.isArray(value) ? value.join(", ") : String(value ?? "")}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
}
|
||||
placeholder="comma-separated values"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-sm">{label}</Label>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
<Input value={String(value ?? "")} onChange={(e) => onChange(e.target.value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AutoFieldProps {
|
||||
schemaKey: string;
|
||||
schema: Record<string, unknown>;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
}
|
||||
15
web/src/components/Toast.tsx
Normal file
15
web/src/components/Toast.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export function Toast({ toast }: { toast: { message: string; type: "success" | "error" } | null }) {
|
||||
if (!toast) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-4 right-4 z-50 rounded-lg px-4 py-2 text-sm font-medium shadow-lg ${
|
||||
toast.type === "success"
|
||||
? "bg-success/20 text-success border border-success/30"
|
||||
: "bg-destructive/20 text-destructive border border-destructive/30"
|
||||
}`}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
web/src/components/ui/badge.tsx
Normal file
29
web/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
destructive: "border-transparent bg-destructive text-destructive-foreground",
|
||||
outline: "text-foreground",
|
||||
success: "border-transparent bg-success/20 text-success",
|
||||
warning: "border-transparent bg-warning/20 text-warning",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export function Badge({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
38
web/src/components/ui/button.tsx
Normal file
38
web/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors cursor-pointer"
|
||||
+ " disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {
|
||||
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
|
||||
}
|
||||
29
web/src/components/ui/card.tsx
Normal file
29
web/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-card text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h3 className={cn("font-semibold leading-none tracking-tight", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn("p-6 pt-0", className)} {...props} />;
|
||||
}
|
||||
16
web/src/components/ui/input.tsx
Normal file
16
web/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors",
|
||||
"placeholder:text-muted-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
13
web/src/components/ui/label.tsx
Normal file
13
web/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Label({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
web/src/components/ui/select.tsx
Normal file
15
web/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
web/src/components/ui/separator.tsx
Normal file
19
web/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { orientation?: "horizontal" | "vertical" }) {
|
||||
return (
|
||||
<div
|
||||
role="separator"
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
37
web/src/components/ui/switch.tsx
Normal file
37
web/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
className,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onCheckedChange: (v: boolean) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
checked ? "bg-primary" : "bg-input",
|
||||
className,
|
||||
)}
|
||||
onClick={() => onCheckedChange(!checked)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform",
|
||||
checked ? "translate-x-4" : "translate-x-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
49
web/src/components/ui/tabs.tsx
Normal file
49
web/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Tabs({
|
||||
defaultValue,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
defaultValue: string;
|
||||
children: (active: string, setActive: (v: string) => void) => React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const [active, setActive] = useState(defaultValue);
|
||||
return <div className={cn("flex flex-col gap-4", className)}>{children(active, setActive)}</div>;
|
||||
}
|
||||
|
||||
export function TabsList({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-start gap-1 rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsTrigger({
|
||||
active,
|
||||
value,
|
||||
onClick,
|
||||
className,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { active: boolean; value: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
active ? "bg-background text-foreground shadow" : "hover:bg-background/50",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
web/src/hooks/useToast.ts
Normal file
15
web/src/hooks/useToast.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export function useToast(duration = 3000) {
|
||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||
|
||||
const showToast = useCallback(
|
||||
(message: string, type: "success" | "error") => {
|
||||
setToast({ message, type });
|
||||
setTimeout(() => setToast(null), duration);
|
||||
},
|
||||
[duration],
|
||||
);
|
||||
|
||||
return { toast, showToast };
|
||||
}
|
||||
39
web/src/index.css
Normal file
39
web/src/index.css
Normal file
@@ -0,0 +1,39 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-background: oklch(0.145 0 0);
|
||||
--color-foreground: oklch(0.95 0 0);
|
||||
--color-card: oklch(0.17 0 0);
|
||||
--color-card-foreground: oklch(0.95 0 0);
|
||||
--color-primary: oklch(0.7 0.15 250);
|
||||
--color-primary-foreground: oklch(0.98 0 0);
|
||||
--color-secondary: oklch(0.22 0 0);
|
||||
--color-secondary-foreground: oklch(0.9 0 0);
|
||||
--color-muted: oklch(0.2 0 0);
|
||||
--color-muted-foreground: oklch(0.6 0 0);
|
||||
--color-accent: oklch(0.25 0 0);
|
||||
--color-accent-foreground: oklch(0.95 0 0);
|
||||
--color-destructive: oklch(0.6 0.2 25);
|
||||
--color-destructive-foreground: oklch(0.98 0 0);
|
||||
--color-success: oklch(0.7 0.18 155);
|
||||
--color-warning: oklch(0.75 0.15 75);
|
||||
--color-border: oklch(0.25 0 0);
|
||||
--color-input: oklch(0.25 0 0);
|
||||
--color-ring: oklch(0.7 0.15 250);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 0.85em;
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--color-secondary);
|
||||
}
|
||||
88
web/src/lib/api.ts
Normal file
88
web/src/lib/api.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
const BASE = "";
|
||||
|
||||
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${url}`, init);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
|
||||
getSessions: () => fetchJSON<SessionInfo[]>("/api/sessions"),
|
||||
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
|
||||
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
|
||||
getSchema: () => fetchJSON<Record<string, unknown>>("/api/config/schema"),
|
||||
saveConfig: (config: Record<string, unknown>) =>
|
||||
fetchJSON<{ ok: boolean }>("/api/config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ config }),
|
||||
}),
|
||||
getEnvVars: () => fetchJSON<Record<string, EnvVarInfo>>("/api/env"),
|
||||
setEnvVar: (key: string, value: string) =>
|
||||
fetchJSON<{ ok: boolean }>("/api/env", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key, value }),
|
||||
}),
|
||||
deleteEnvVar: (key: string) =>
|
||||
fetchJSON<{ ok: boolean }>("/api/env", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key }),
|
||||
}),
|
||||
};
|
||||
|
||||
export interface PlatformStatus {
|
||||
error_code?: string;
|
||||
error_message?: string;
|
||||
state: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface StatusResponse {
|
||||
active_sessions: number;
|
||||
config_path: string;
|
||||
config_version: number;
|
||||
env_path: string;
|
||||
gateway_exit_reason: string | null;
|
||||
gateway_pid: number | null;
|
||||
gateway_platforms: Record<string, PlatformStatus>;
|
||||
gateway_running: boolean;
|
||||
gateway_state: string | null;
|
||||
gateway_updated_at: string | null;
|
||||
hermes_home: string;
|
||||
latest_config_version: number;
|
||||
release_date: string;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string;
|
||||
source: string;
|
||||
model: string;
|
||||
title: string | null;
|
||||
started_at: number;
|
||||
ended_at: number | null;
|
||||
last_active: number;
|
||||
is_active: boolean;
|
||||
message_count: number;
|
||||
tool_call_count: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
export interface EnvVarInfo {
|
||||
is_set: boolean;
|
||||
redacted_value: string | null;
|
||||
description: string;
|
||||
url: string | null;
|
||||
category: string;
|
||||
is_password: boolean;
|
||||
tools: string[];
|
||||
advanced: boolean;
|
||||
}
|
||||
23
web/src/lib/nested.ts
Normal file
23
web/src/lib/nested.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
const parts = path.split(".");
|
||||
let cur: unknown = obj;
|
||||
for (const p of parts) {
|
||||
if (cur == null || typeof cur !== "object") return undefined;
|
||||
cur = (cur as Record<string, unknown>)[p];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
export function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
|
||||
const clone = structuredClone(obj);
|
||||
const parts = path.split(".");
|
||||
let cur: Record<string, unknown> = clone;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (cur[parts[i]] == null || typeof cur[parts[i]] !== "object") {
|
||||
cur[parts[i]] = {};
|
||||
}
|
||||
cur = cur[parts[i]] as Record<string, unknown>;
|
||||
}
|
||||
cur[parts[parts.length - 1]] = value;
|
||||
return clone;
|
||||
}
|
||||
6
web/src/lib/utils.ts
Normal file
6
web/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
10
web/src/main.tsx
Normal file
10
web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
149
web/src/pages/ConfigPage.tsx
Normal file
149
web/src/pages/ConfigPage.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Download, RotateCcw, Save, Upload } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { getNestedValue, setNestedValue } from "@/lib/nested";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { AutoField } from "@/components/AutoField";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [schema, setSchema] = useState<Record<string, Record<string, unknown>> | null>(null);
|
||||
const [defaults, setDefaults] = useState<Record<string, unknown> | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const { toast, showToast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getConfig().then(setConfig).catch(() => {});
|
||||
api.getSchema().then((s) => setSchema(s as Record<string, Record<string, unknown>>)).catch(() => {});
|
||||
api.getDefaults().then(setDefaults).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.saveConfig(config);
|
||||
showToast("Configuration saved", "success");
|
||||
} catch (e) {
|
||||
showToast(`Failed to save: ${e}`, "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (defaults) setConfig(structuredClone(defaults));
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
if (!config) return;
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "hermes-config.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const imported = JSON.parse(reader.result as string);
|
||||
setConfig(imported);
|
||||
showToast("Config imported — review and save", "success");
|
||||
} catch {
|
||||
showToast("Invalid JSON file", "error");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
if (!config || !schema) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const categories = [...new Set(Object.values(schema).map((s) => String(s.category ?? "general")))];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Toast toast={toast} />
|
||||
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Edit <code>~/.hermes/config.yaml</code>
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleExport}>
|
||||
<Download className="h-3 w-3" />
|
||||
Export
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload className="h-3 w-3" />
|
||||
Import
|
||||
</Button>
|
||||
|
||||
<input ref={fileInputRef} type="file" accept=".json,.yaml,.yml" className="hidden" onChange={handleImport} />
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
<Button size="sm" onClick={handleSave} disabled={saving}>
|
||||
<Save className="h-3 w-3" />
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue={categories[0]}>
|
||||
{(active, setActive) => (
|
||||
<>
|
||||
<TabsList className="flex-wrap">
|
||||
{categories.map((cat) => (
|
||||
<TabsTrigger key={cat} value={cat} active={active === cat} onClick={() => setActive(cat)}>
|
||||
{cat.charAt(0).toUpperCase() + cat.slice(1)}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base capitalize">{active}</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-6">
|
||||
{Object.entries(schema)
|
||||
.filter(([, s]) => String(s.category ?? "general") === active)
|
||||
.map(([key, s]) => (
|
||||
<AutoField
|
||||
key={key}
|
||||
schemaKey={key}
|
||||
schema={s}
|
||||
value={getNestedValue(config, key)}
|
||||
onChange={(v) => setConfig(setNestedValue(config, key, v))}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
240
web/src/pages/EnvPage.tsx
Normal file
240
web/src/pages/EnvPage.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
KeyRound,
|
||||
MessageSquare,
|
||||
Save,
|
||||
Settings,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { EnvVarInfo } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const CATEGORY_META: Record<string, { label: string; icon: typeof KeyRound }> = {
|
||||
provider: { label: "LLM Providers", icon: Zap },
|
||||
tool: { label: "Tool API Keys", icon: KeyRound },
|
||||
messaging: { label: "Messaging Platforms", icon: MessageSquare },
|
||||
setting: { label: "Agent Settings", icon: Settings },
|
||||
};
|
||||
|
||||
export default function EnvPage() {
|
||||
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null);
|
||||
const [edits, setEdits] = useState<Record<string, string>>({});
|
||||
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const { toast, showToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
api.getEnvVars().then(setVars).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSave = async (key: string) => {
|
||||
const value = edits[key];
|
||||
if (!value) return;
|
||||
setSaving(key);
|
||||
try {
|
||||
await api.setEnvVar(key, value);
|
||||
setVars((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
[key]: { ...prev[key], is_set: true, redacted_value: value.slice(0, 4) + "..." + value.slice(-4) },
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
setEdits((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
showToast(`${key} saved — active sessions will pick this up automatically`, "success");
|
||||
} catch (e) {
|
||||
showToast(`Failed to save ${key}: ${e}`, "error");
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = async (key: string) => {
|
||||
setSaving(key);
|
||||
try {
|
||||
await api.deleteEnvVar(key);
|
||||
setVars((prev) =>
|
||||
prev
|
||||
? { ...prev, [key]: { ...prev[key], is_set: false, redacted_value: null } }
|
||||
: prev,
|
||||
);
|
||||
setEdits((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
showToast(`${key} removed`, "success");
|
||||
} catch (e) {
|
||||
showToast(`Failed to remove ${key}: ${e}`, "error");
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!vars) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const categories = Object.keys(CATEGORY_META);
|
||||
const grouped = categories.map((cat) => ({
|
||||
...CATEGORY_META[cat],
|
||||
category: cat,
|
||||
entries: Object.entries(vars).filter(
|
||||
([, info]) => info.category === cat && (showAdvanced || !info.advanced),
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Toast toast={toast} />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage API keys and secrets stored in <code>~/.hermes/.env</code>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
Changes are saved to disk immediately. Active sessions pick up new keys automatically within a few seconds.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
{showAdvanced ? "Hide Advanced" : "Show Advanced"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{grouped.map(({ label, icon: Icon, entries, category }) => {
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<Card key={category}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{label}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{entries.filter(([, i]) => i.is_set).length} of {entries.length} configured
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-4">
|
||||
{entries.map(([key, info]) => (
|
||||
<div key={key} className="grid gap-2 rounded-lg border border-border p-4">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="font-mono text-xs">{key}</Label>
|
||||
<Badge variant={info.is_set ? "success" : "outline"}>
|
||||
{info.is_set ? "Set" : "Not set"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{info.url && (
|
||||
<a
|
||||
href={info.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
Get key <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">{info.description}</p>
|
||||
|
||||
{info.tools.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{info.tools.map((tool) => (
|
||||
<Badge key={tool} variant="secondary" className="text-[10px]">
|
||||
{tool}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showValues[key] ? "text" : "password"}
|
||||
value={
|
||||
edits[key] !== undefined
|
||||
? edits[key]
|
||||
: info.is_set
|
||||
? info.redacted_value ?? ""
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => setEdits({ ...edits, [key]: e.target.value })}
|
||||
onFocus={() => {
|
||||
if (edits[key] === undefined && info.is_set) {
|
||||
setEdits({ ...edits, [key]: "" });
|
||||
}
|
||||
}}
|
||||
placeholder={info.is_set ? "(click to replace)" : "Enter value..."}
|
||||
className="pr-9 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
onClick={() => setShowValues({ ...showValues, [key]: !showValues[key] })}
|
||||
>
|
||||
{showValues[key] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{edits[key] !== undefined && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSave(key)}
|
||||
disabled={saving === key || !edits[key]}
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
{saving === key ? "..." : "Save"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{info.is_set && edits[key] === undefined && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleClear(key)}
|
||||
disabled={saving === key}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{saving === key ? "..." : "Clear"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
web/src/pages/StatusPage.tsx
Normal file
295
web/src/pages/StatusPage.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
Cpu,
|
||||
Database,
|
||||
Radio,
|
||||
Shield,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { PlatformStatus, SessionInfo, StatusResponse } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const delta = Date.now() / 1000 - ts;
|
||||
if (delta < 60) return "just now";
|
||||
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
|
||||
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
|
||||
if (delta < 172800) return "yesterday";
|
||||
return `${Math.floor(delta / 86400)}d ago`;
|
||||
}
|
||||
|
||||
function isoTimeAgo(iso: string): string {
|
||||
const delta = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
if (delta < 0 || Number.isNaN(delta)) return "unknown";
|
||||
if (delta < 60) return "just now";
|
||||
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
|
||||
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
|
||||
return `${Math.floor(delta / 86400)}d ago`;
|
||||
}
|
||||
|
||||
const PLATFORM_STATE_BADGE: Record<string, { variant: "success" | "warning" | "destructive"; label: string }> = {
|
||||
connected: { variant: "success", label: "Connected" },
|
||||
disconnected: { variant: "warning", label: "Disconnected" },
|
||||
fatal: { variant: "destructive", label: "Error" },
|
||||
};
|
||||
|
||||
const GATEWAY_STATE_DISPLAY: Record<string, { badge: "success" | "warning" | "destructive" | "outline"; label: string }> = {
|
||||
running: { badge: "success", label: "Running" },
|
||||
starting: { badge: "warning", label: "Starting" },
|
||||
startup_failed: { badge: "destructive", label: "Failed" },
|
||||
stopped: { badge: "outline", label: "Stopped" },
|
||||
};
|
||||
|
||||
function gatewayValue(status: StatusResponse): string {
|
||||
if (status.gateway_running) return `PID ${status.gateway_pid}`;
|
||||
if (status.gateway_state === "startup_failed") return "Start failed";
|
||||
return "Not running";
|
||||
}
|
||||
|
||||
function gatewayBadge(status: StatusResponse) {
|
||||
const info = status.gateway_state ? GATEWAY_STATE_DISPLAY[status.gateway_state] : null;
|
||||
if (info) return info;
|
||||
return status.gateway_running
|
||||
? { badge: "success" as const, label: "Running" }
|
||||
: { badge: "outline" as const, label: "Off" };
|
||||
}
|
||||
|
||||
export default function StatusPage() {
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
api.getStatus().then(setStatus).catch(() => {});
|
||||
api.getSessions().then(setSessions).catch(() => {});
|
||||
};
|
||||
load();
|
||||
const interval = setInterval(load, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const configNeedsMigration = status.config_version < status.latest_config_version;
|
||||
const gwBadge = gatewayBadge(status);
|
||||
|
||||
const items = [
|
||||
{
|
||||
icon: Cpu,
|
||||
label: "Agent",
|
||||
value: `v${status.version}`,
|
||||
badgeText: "Live",
|
||||
badgeVariant: "success" as const,
|
||||
},
|
||||
{
|
||||
icon: Activity,
|
||||
label: "Active Sessions",
|
||||
value: status.active_sessions > 0 ? `${status.active_sessions} running` : "None",
|
||||
badgeText: status.active_sessions > 0 ? "Live" : "Off",
|
||||
badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as "success" | "outline",
|
||||
},
|
||||
{
|
||||
icon: Radio,
|
||||
label: "Gateway",
|
||||
value: gatewayValue(status),
|
||||
badgeText: gwBadge.label,
|
||||
badgeVariant: gwBadge.badge,
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
label: "Config Version",
|
||||
value: `v${status.config_version}`,
|
||||
badgeText: configNeedsMigration ? "Migrate" : "Current",
|
||||
badgeVariant: (configNeedsMigration ? "warning" : "success") as "warning" | "success",
|
||||
},
|
||||
];
|
||||
|
||||
const platforms = Object.entries(status.gateway_platforms ?? {});
|
||||
const activeSessions = sessions.filter((s) => s.is_active);
|
||||
const recentSessions = sessions.filter((s) => !s.is_active).slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{items.map(({ icon: Icon, label, value, badgeText, badgeVariant }) => (
|
||||
<Card key={label}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium">{label}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
|
||||
<Badge variant={badgeVariant} className="mt-2">
|
||||
{badgeVariant === "success" && (
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
{badgeText}
|
||||
</Badge>
|
||||
|
||||
{label === "Gateway" && !status.gateway_running && status.gateway_exit_reason && (
|
||||
<p className="mt-2 text-xs text-destructive">{status.gateway_exit_reason}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{platforms.length > 0 && (
|
||||
<PlatformsCard platforms={platforms} />
|
||||
)}
|
||||
|
||||
{activeSessions.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-success" />
|
||||
<CardTitle className="text-base">Active Sessions</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3">
|
||||
{activeSessions.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border p-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{s.title ?? "Untitled"}</span>
|
||||
|
||||
<Badge variant="success" className="text-[10px]">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
Live
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{s.model} · {s.message_count} msgs · {timeAgo(s.last_active)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{recentSessions.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Recent Sessions</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3">
|
||||
{recentSessions.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className="flex items-center justify-between rounded-lg border border-border p-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium text-sm">{s.title ?? "Untitled"}</span>
|
||||
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{s.model} · {s.message_count} msgs · {timeAgo(s.last_active)}
|
||||
</span>
|
||||
|
||||
{s.preview && (
|
||||
<span className="text-xs text-muted-foreground/70 truncate max-w-md">
|
||||
{s.preview}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
<Database className="mr-1 h-3 w-3" />
|
||||
{s.source}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlatformsCard({ platforms }: PlatformsCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Connected Platforms</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3">
|
||||
{platforms.map(([name, info]) => {
|
||||
const display = PLATFORM_STATE_BADGE[info.state] ?? {
|
||||
variant: "outline" as const,
|
||||
label: info.state,
|
||||
};
|
||||
const IconComponent = info.state === "connected" ? Wifi : info.state === "fatal" ? AlertTriangle : WifiOff;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="flex items-center justify-between rounded-lg border border-border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<IconComponent className={`h-4 w-4 ${
|
||||
info.state === "connected"
|
||||
? "text-success"
|
||||
: info.state === "fatal"
|
||||
? "text-destructive"
|
||||
: "text-warning"
|
||||
}`} />
|
||||
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium capitalize">{name}</span>
|
||||
|
||||
{info.error_message && (
|
||||
<span className="text-xs text-destructive">{info.error_message}</span>
|
||||
)}
|
||||
|
||||
{info.updated_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Last update: {isoTimeAgo(info.updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge variant={display.variant}>
|
||||
{display.variant === "success" && (
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
{display.label}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlatformsCardProps {
|
||||
platforms: [string, PlatformStatus][];
|
||||
}
|
||||
34
web/tsconfig.app.json
Normal file
34
web/tsconfig.app.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
web/tsconfig.json
Normal file
7
web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
web/tsconfig.node.json
Normal file
26
web/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
22
web/vite.config.ts
Normal file
22
web/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "../hermes_cli/web_dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:9119",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user