Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b92fe0aa9 | ||
|
|
dac58c8162 | ||
|
|
6a4b20f3f2 | ||
|
|
90b5ad8d99 | ||
|
|
57a4f573f6 | ||
|
|
c1320b4712 | ||
|
|
d3b693524f | ||
|
|
66f95e08c2 | ||
|
|
1a4d56c215 | ||
|
|
b2c2f32584 | ||
|
|
15fde033c3 | ||
|
|
10a1e57c9b | ||
|
|
4f10080501 | ||
|
|
f8ea02c14d | ||
|
|
122fe955b6 | ||
|
|
017d7f1eca | ||
|
|
cabda6b77a | ||
|
|
33fca2383c | ||
|
|
be951a4d1d | ||
|
|
bba9a236c3 | ||
|
|
846565484b | ||
|
|
1a579ef9cf | ||
|
|
1605d65226 | ||
|
|
3d4d7f2b53 | ||
|
|
2fb2ddeaaa | ||
|
|
b1d687ba22 | ||
|
|
c1dcd73502 | ||
|
|
df06c1cdca | ||
|
|
2c0f6e80b6 | ||
|
|
4a4af209ad | ||
|
|
279690e4c1 | ||
|
|
2766314e81 | ||
|
|
9d69408610 | ||
|
|
4a3b9571f1 | ||
|
|
6a61f36280 |
56
.github/workflows/release.yml
vendored
Normal file
56
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
name: Release & Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # required: create GitHub Release
|
||||
packages: write # required: push to ghcr.io
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# Create GitHub Release from tag with auto-generated notes
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
# Set up multi-arch build (QEMU + Buildx)
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
# Log in to GitHub Container Registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract tags from the git ref (supports vX.Y and vX.Y.Z formats)
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=match,pattern=v(\d+\.\d+(?:\.\d+)?),group=1
|
||||
type=raw,value=latest
|
||||
|
||||
# Build and push multi-arch image (amd64 + arm64)
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
166
CHANGELOG.md
166
CHANGELOG.md
@@ -5,6 +5,170 @@
|
||||
|
||||
---
|
||||
|
||||
## [v0.30.1] CLI Session Bridge Fixes
|
||||
*April 4, 2026 | 424 tests*
|
||||
|
||||
### Bug Fixes
|
||||
- **CLI sessions not appearing in sidebar.** Three frontend gaps: `sessions.js`
|
||||
wasn't rendering CLI sessions (missing `is_cli_session` check in render loop),
|
||||
sidebar click handler didn't trigger import, and the "cli" badge CSS selector
|
||||
wasn't matching the rendered DOM structure. (#58)
|
||||
- **CLI bridge read wrong profile's state.db.** `get_cli_sessions()` resolved
|
||||
`HERMES_HOME` at server launch time, not at call time. After a profile switch,
|
||||
it kept reading the original profile's database. Now resolves dynamically via
|
||||
`get_active_hermes_home()`. (#59)
|
||||
- **Silent SQL error swallowed all CLI sessions.** The `sessions` table in
|
||||
`state.db` has no `profile` column — the query referenced `s.profile` which
|
||||
caused a silent `OperationalError`. The `except Exception: return []` handler
|
||||
swallowed it, returning zero CLI sessions. Removed the column reference and
|
||||
added explicit column-existence checks. (#60)
|
||||
|
||||
### Features
|
||||
- **"Show CLI sessions" toggle in Settings.** New checkbox in the Settings panel
|
||||
to show/hide CLI sessions in the sidebar. Persisted server-side in
|
||||
`settings.json` (`show_cli_sessions`, default `true`). When disabled, CLI
|
||||
sessions are excluded from `/api/sessions` responses. (#61)
|
||||
|
||||
---
|
||||
|
||||
## [v0.30] CLI Session Bridge (Community: @thadreber-web)
|
||||
*April 4, 2026 | 424 tests*
|
||||
|
||||
### Features
|
||||
- **CLI session bridge.** The WebUI now reads sessions from the hermes-agent's
|
||||
SQLite store (`state.db`). CLI sessions appear in the sidebar with a gold
|
||||
"cli" indicator badge. Click to import into the WebUI store with full message
|
||||
history — replies then work through the normal agent pipeline.
|
||||
- **`/api/session/import_cli` endpoint.** Imports a CLI session into the WebUI
|
||||
JSON store. Idempotent — returns existing session if already imported.
|
||||
Derives title from first message, inherits active profile and workspace.
|
||||
- **`/api/sessions` merges CLI sessions.** Sidebar shows both WebUI and CLI
|
||||
sessions sorted by last activity. Deduplication ensures WebUI sessions take
|
||||
priority when the same session_id exists in both stores.
|
||||
- **CLI session fallback on `/api/session`.** If a session_id isn't found in
|
||||
the WebUI store, falls back to reading from the CLI SQLite store.
|
||||
|
||||
### Architecture
|
||||
- `api/models.py`: `get_cli_sessions()`, `get_cli_session_messages()`,
|
||||
`import_cli_session()`. All use parameterized SQL queries and `with` for
|
||||
connection management. Graceful fallback on missing sqlite3 or state.db.
|
||||
- `api/routes.py`: CLI fallback in GET `/api/session`, merged list in
|
||||
GET `/api/sessions`, POST `/api/session/import_cli`.
|
||||
- `static/style.css`: `.cli-session` indicator styles (gold border + badge).
|
||||
|
||||
---
|
||||
|
||||
## [v0.29] Sprint 23: Agentic Transparency + Polish
|
||||
*April 4, 2026 | 424 tests*
|
||||
|
||||
### Features
|
||||
|
||||
- **Token/cost display.** Agent usage (input tokens, output tokens, estimated
|
||||
cost) is now read after each conversation and persisted on the session.
|
||||
A muted badge appears below the last assistant message when enabled.
|
||||
Off by default — toggle via the Settings panel checkbox or `/usage` slash
|
||||
command. Persists server-side across refreshes.
|
||||
|
||||
- **Subagent delegation cards.** `subagent_progress` events now render with
|
||||
a 🔀 icon and a blue indented left border to visually distinguish child
|
||||
tool activity from parent tool calls. `delegate_task` cards display as
|
||||
"Delegate task" with cleaner formatting.
|
||||
|
||||
- **Skill picker in cron create form.** The "New Job" form now has a search
|
||||
input + tag chip picker for attaching skills to cron jobs. Skills fetched
|
||||
from `/api/skills`, filtered on keyup, added/removed as tag chips.
|
||||
`submitCronCreate()` sends `skills` array in the POST body. Backend already
|
||||
supported the field — this was a pure frontend gap.
|
||||
|
||||
- **Skill linked files viewer.** Skill preview panel now renders a "Linked
|
||||
Files" section below SKILL.md content when a skill has `references/`,
|
||||
`templates/`, `scripts/`, or `assets/` subdirectories. Clicking a file
|
||||
loads it in the preview panel with syntax highlighting.
|
||||
New `file` query param on `GET /api/skills/content` serves linked files
|
||||
with path traversal protection.
|
||||
|
||||
- **Workspace tree state persists across refreshes.** Expanded directory
|
||||
paths are saved to `localStorage` keyed by workspace path
|
||||
(`hermes-webui-expanded:{path}`). On every root load (page refresh,
|
||||
session switch), the saved state is restored and previously-expanded
|
||||
directories are pre-fetched so the tree renders fully on first paint.
|
||||
|
||||
- **Timestamps fixed.** `api/streaming.py` now stamps `timestamp` on every
|
||||
message that lacks one at conversation completion. The `done` SSE event
|
||||
also stamps `_ts` on the last assistant message immediately. Timestamps
|
||||
were already rendered in the UI (Sprint 14, hover-to-reveal) but most
|
||||
messages had no timestamp field, so nothing ever showed.
|
||||
|
||||
- **`/usage` slash command.** Instant toggle for token usage display.
|
||||
Shows a toast, persists to server, updates the Settings checkbox if open,
|
||||
re-renders immediately.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **XSS via inline onclick + esc().** Skill names and file paths embedded in
|
||||
`onclick` HTML attributes used `esc()` for encoding. `esc()` converts `'`
|
||||
to `'` (HTML-safe) but browsers decode it back before executing JS,
|
||||
allowing skill names with apostrophes to break out of string literals.
|
||||
Fixed by switching to `data-*` attributes + `addEventListener`.
|
||||
|
||||
- **rglob wildcard injection.** The `name` query param for
|
||||
`/api/skills/content?file=` was passed directly to `SKILLS_DIR.rglob()`,
|
||||
which accepts glob patterns. `name=*` would match an arbitrary directory
|
||||
and use it as the trust base for path traversal checking.
|
||||
Fixed by rejecting names containing `* ? [ ]` metacharacters with 400.
|
||||
|
||||
- **`_fmtTokens(null)` returned "null".** `String(null)` = `"null"` would
|
||||
appear in the usage badge for sessions missing fields. Fixed with a
|
||||
`!n || n < 0` guard returning `'0'`.
|
||||
|
||||
- **Usage badge on wrong row.** Badge used `:last-child` which could target
|
||||
a user message row. Fixed by adding `data-role` to message rows and
|
||||
scanning backwards for the last `assistant` row.
|
||||
|
||||
- **Tool name resolution.** Tool call entries in session JSON sometimes
|
||||
stored the literal string `"tool"` as the name when the call ID couldn't
|
||||
be resolved. Fixed: defaults to empty string and skips unresolvable entries.
|
||||
|
||||
- **Inline import inside loop.** `import json as _j2` inside the done-handler
|
||||
loop in `streaming.py` moved to module-level.
|
||||
|
||||
### Session Model
|
||||
|
||||
- Added `input_tokens`, `output_tokens`, `estimated_cost` fields to Session
|
||||
(defaults: 0, 0, None). Included in `compact()`, session JSON, and all
|
||||
API responses. Backward-compatible via `**kwargs`.
|
||||
|
||||
- Added `args` capture to `tool_calls` session JSON entries (truncated
|
||||
snapshot of tool inputs, up to 6 keys / 120 chars each).
|
||||
|
||||
### Settings
|
||||
|
||||
- New `show_token_usage` boolean setting (default: `false`). Stored in
|
||||
`settings.json`, loaded on boot alongside `send_key`.
|
||||
|
||||
### Tests
|
||||
|
||||
- Renamed `test_sprint24.py` → `test_sprint23.py`.
|
||||
- Strengthened session usage assertions (explicit field presence checks).
|
||||
- Added: path traversal rejection test, wildcard name rejection test,
|
||||
cron create with skills array test.
|
||||
- Total: 424 tests (up from 415).
|
||||
|
||||
---
|
||||
|
||||
## [v0.28.1] CI Pipeline + Multi-Arch Docker Builds
|
||||
*April 3, 2026 | 426 tests*
|
||||
|
||||
### Features
|
||||
- **GitHub Actions CI.** New workflow triggers on tag push (`v*`). Builds
|
||||
multi-arch Docker images (linux/amd64 + linux/arm64), pushes to
|
||||
`ghcr.io/nesquena/hermes-webui`, and creates a GitHub Release with
|
||||
auto-generated release notes. Uses GHA layer caching for fast rebuilds.
|
||||
- **Pre-built container images.** Users can now `docker pull ghcr.io/nesquena/hermes-webui:latest`
|
||||
instead of building locally.
|
||||
|
||||
---
|
||||
|
||||
## [v0.27] Profile Creation Fallback for Docker (Issue #44)
|
||||
*April 3, 2026 | 426 tests*
|
||||
|
||||
@@ -904,4 +1068,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: v0.27, April 3, 2026 | Tests: 426*
|
||||
*Last updated: v0.30.1, April 4, 2026 | Tests: 424*
|
||||
|
||||
375
HERMES.md
Normal file
375
HERMES.md
Normal file
@@ -0,0 +1,375 @@
|
||||
# Why Hermes
|
||||
|
||||
Hermes is a persistent, autonomous AI agent that lives on your server. It remembers everything,
|
||||
schedules work while you sleep, and gets more capable the longer it runs. This document explains
|
||||
the mental model, why that matters, and how Hermes compares to every major AI tool available today.
|
||||
|
||||
---
|
||||
|
||||
## The Core Idea: Assistants Forget. Agents Don't.
|
||||
|
||||
Every time you open Claude Code, Codex, or a chat window, the tool starts from zero. It does not
|
||||
know who you are, what you worked on yesterday, how your repo is structured, or what bugs you
|
||||
already fixed. You re-explain yourself every single session. The tool is powerful in the moment
|
||||
and useless the next day.
|
||||
|
||||
Hermes fills that gap. It runs on your server, retains context across every session, and acts
|
||||
on your behalf whether or not you are at a keyboard.
|
||||
|
||||
```
|
||||
Assistant model: You -> [Tool] -> Answer -> Done
|
||||
(tool forgets everything when the window closes)
|
||||
|
||||
Agent model: You <-> [Hermes] <-> (memory, skills, schedule, tools)
|
||||
(persistent, learns your stack, acts on your behalf, runs while you're offline)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The Three Pillars
|
||||
|
||||
### 1. Memory That Compounds
|
||||
|
||||
Hermes has layered memory that survives every session, every reboot, every model swap:
|
||||
|
||||
- **User profile** -- who you are, your preferences, your communication style, things you've
|
||||
corrected Hermes on
|
||||
- **Agent memory** -- facts about your environment, your toolchain, your project conventions
|
||||
- **Skills** -- reusable procedures Hermes discovers and saves; it never has to relearn how to
|
||||
deploy your app, run your tests, or review a PR
|
||||
- **Session history** -- every past conversation is searchable; Hermes can recall what you
|
||||
worked on last Tuesday
|
||||
|
||||
When you correct Hermes, it remembers. When it solves a tricky problem, it saves the approach.
|
||||
When it learns your stack, that knowledge carries into every future session.
|
||||
|
||||
### 2. Autonomous Scheduling
|
||||
|
||||
Hermes can run jobs without you present -- every hour, every morning, on any cron schedule.
|
||||
It fires up a fresh session, runs the task, and delivers the result to wherever you want it:
|
||||
Telegram, Discord, Slack, Signal, WhatsApp, SMS, email, and more.
|
||||
|
||||
Things Hermes can do while you sleep:
|
||||
|
||||
- Review new pull requests on your GitHub repo and post a full verdict comment
|
||||
- Send you a morning briefing of news, markets, or anything else you care about
|
||||
- Run your test suite and alert you if something breaks
|
||||
- Watch a competitor's blog for new posts and summarize them
|
||||
- Monitor a datasource and notify you when a threshold is crossed
|
||||
|
||||
### 3. Reach It From Anywhere
|
||||
|
||||
Hermes runs on your server and is reachable from every surface: terminal over SSH, the web UI
|
||||
(this project), and messaging apps including Telegram, Discord, Slack, WhatsApp, Signal, and
|
||||
Matrix. Start a task from your phone, check it from the browser on your laptop, continue it in
|
||||
a terminal on a remote server. The same agent, memory, and history follow you everywhere.
|
||||
|
||||
---
|
||||
|
||||
## A Framework for AI Tools
|
||||
|
||||
There are four distinct categories of AI tool. Understanding the category tells you what a tool
|
||||
can and cannot do.
|
||||
|
||||
### Category 1: Chat Assistants
|
||||
*Claude.ai, ChatGPT, Gemini*
|
||||
|
||||
You open a window, ask something, get an answer. No persistent memory beyond the conversation,
|
||||
no ability to run code or touch files, no way to act on your behalf. Excellent for Q&A,
|
||||
drafting, and brainstorming. You re-explain your context every session.
|
||||
|
||||
### Category 2: IDE Integrations
|
||||
*GitHub Copilot, Cursor, Windsurf, Zed AI*
|
||||
|
||||
Deep inside your editor. Autocomplete, inline diffs, refactors -- all excellent. Windsurf was
|
||||
earliest with workspace-scoped memory (Cascade Memories); Copilot has been shipping repo-level
|
||||
memory since late 2025 and is catching up. Cursor has no native memory as of early 2026. None
|
||||
have scheduling or messaging access. Tied to one machine and one editor.
|
||||
|
||||
### Category 3: Agentic CLI Tools
|
||||
*Claude Code, Codex CLI, OpenCode, Aider*
|
||||
|
||||
The current frontier for most developers. Can use real tools -- run shell commands, read and
|
||||
write files, search the web, call APIs. Great for deep, multi-step tasks in a single terminal
|
||||
session. All are adding memory and scheduling features to varying degrees (see comparisons below),
|
||||
but the core model is still session-scoped: you invoke it, it works, it stops.
|
||||
|
||||
### Category 4: Persistent Autonomous Agents
|
||||
*Hermes, OpenClaw (as of early 2026)*
|
||||
|
||||
All the tool use of Category 3, plus memory that accumulates across sessions, plus always-on
|
||||
scheduling, plus multi-modal access from any device or messaging app. Gets more useful over time
|
||||
rather than resetting to zero. Hermes and OpenClaw are the two primary open-source, self-hosted
|
||||
tools in this category. OpenClaw is a gateway-centric automation platform; Hermes is a
|
||||
self-improving agent that writes and reuses its own procedures from experience.
|
||||
|
||||
---
|
||||
|
||||
## How Hermes Compares
|
||||
|
||||
### vs. OpenClaw
|
||||
|
||||
OpenClaw is the most direct comparison to Hermes and the question most people ask first.
|
||||
Both are open-source, self-hosted, always-on agents with persistent memory, cron scheduling,
|
||||
and messaging app integration. If you're evaluating Hermes, you should evaluate OpenClaw too.
|
||||
|
||||
OpenClaw (MIT, ~347k GitHub stars) is built around a **Gateway** control plane written in
|
||||
Node.js/TypeScript. It excels at broad personal automation: native Chrome/Chromium control for
|
||||
browser automation, the widest messaging platform support in the space (WhatsApp, Telegram,
|
||||
Signal, iMessage, LINE, WeChat, Slack, Discord, Teams, Matrix, and more), voice wake words,
|
||||
and a ClawHub skill marketplace where users share pre-built automations. The community is large
|
||||
and the ecosystem is growing fast.
|
||||
|
||||
Hermes takes a different approach. It is built in Python and centers on a **self-improving
|
||||
agent loop** rather than a gateway control plane. The core difference is in how skills work:
|
||||
OpenClaw skills are primarily human-authored plugins installed from a marketplace; Hermes
|
||||
**writes and saves its own skills automatically** as part of every session. When Hermes solves
|
||||
a problem a new way, it saves the procedure and reuses it going forward without any user effort.
|
||||
|
||||
Beyond the skills architecture, there are two other practical differences worth knowing:
|
||||
|
||||
**Stability.** OpenClaw's community forums and GitHub issues document a recurring pattern of
|
||||
update-breaking regressions -- for example, Telegram integration was broken across multiple
|
||||
releases in early 2026. The unofficial WhatsApp Web protocol OpenClaw uses is known to
|
||||
disconnect and requires periodic re-pairing (this is documented in OpenClaw's own FAQ).
|
||||
Hermes has had no equivalent release breakages.
|
||||
|
||||
**Security.** ClawHub's open publishing model has been exploited repeatedly. A community audit
|
||||
identified over a thousand malicious skills in the marketplace including prompt injections and
|
||||
tool-poisoning payloads; the community-maintained awesome-openclaw-skills list tracks confirmed
|
||||
removals and flags known bad actors. Hermes has no third-party marketplace and a correspondingly
|
||||
smaller attack surface.
|
||||
|
||||
**OpenClaw's genuine strengths** are worth stating plainly: it has broader messaging coverage
|
||||
(iMessage, LINE, WeChat, Teams -- platforms Hermes does not support), native browser and
|
||||
computer control via Chrome CDP, voice wake words on macOS and iOS, a larger community, and
|
||||
more third-party integrations than Hermes. If those capabilities matter most to you, OpenClaw
|
||||
is worth a serious look.
|
||||
|
||||
Where Hermes is the better fit: you want an agent that self-improves from experience without
|
||||
manual plugin authoring, you work in Python and want access to the ML/data science ecosystem,
|
||||
you want a stable deployment that does not break between updates, or you want a full web chat
|
||||
UI rather than a monitoring dashboard.
|
||||
|
||||
| | OpenClaw | Hermes |
|
||||
|---|---|---|
|
||||
| Persistent memory | Yes | Yes |
|
||||
| Scheduled jobs (cron) | Yes | Yes |
|
||||
| Messaging app access | Yes (15+ platforms, incl. iMessage/WeChat) | Yes (10+ platforms) |
|
||||
| Web UI | Gateway dashboard (monitoring only) | Full three-panel chat UI |
|
||||
| Self-hosted | Yes | Yes |
|
||||
| Open source | Yes (MIT) | Yes |
|
||||
| Self-improving skills | Partial (AI can generate skills; not the default loop) | Yes (automatic, first-class) |
|
||||
| Browser / computer control | Yes (native Chrome CDP) | Via shell / tools |
|
||||
| Voice wake words | Yes (macOS/iOS) | No |
|
||||
| Python / ML ecosystem | No (Node.js) | Yes |
|
||||
| Orchestrates Claude Code / Codex | No | Yes |
|
||||
| Multi-profile support | Via binding-rule routing | Yes (first-class named profiles) |
|
||||
| Provider-agnostic | Yes | Yes |
|
||||
| Update reliability | Moderate (documented regressions) | High |
|
||||
|
||||
### vs. Claude Code (Anthropic)
|
||||
|
||||
Claude Code is Anthropic's official agentic CLI and one of the best tools in Category 3.
|
||||
In a single focused session it is capable -- deep code understanding, shell access, file
|
||||
editing, multi-step reasoning.
|
||||
|
||||
Claude Code has been adding features rapidly and the gap is narrowing:
|
||||
|
||||
- **Hooks system** -- 13 event types (SessionStart, PreToolUse, PostToolUse, Stop, etc.) with
|
||||
4 handler types (shell command, HTTP endpoint, LLM prompt, sub-agent); deterministic
|
||||
non-LLM control over the agent lifecycle
|
||||
- **Plugins / Skills** -- installable via `/plugin install`, hot-reloaded from `~/.claude/skills`,
|
||||
with a marketplace; skills and slash commands unified as of v2.1.0
|
||||
- **Scheduling** -- `/loop` (session-scoped), cloud-managed cron via `claude.ai/code/scheduled`
|
||||
(Anthropic infrastructure, minimum interval applies), and desktop app automations
|
||||
- **Messaging channels** -- Telegram, Discord, iMessage, and webhooks via the Channels feature
|
||||
(research preview, v2.1.80+); deep Slack integration that triggers cloud sessions and creates PRs
|
||||
- **Claude Cowork** -- a separate product for knowledge workers; connects to 38+
|
||||
services via MCP including Slack, Gmail, Microsoft Teams, Notion, Jira, Salesforce, and more
|
||||
- **Memory** -- CLAUDE.md and MEMORY.md for project-level context; auto-memory rolling out
|
||||
|
||||
These are real features. The key differences that remain:
|
||||
|
||||
- Claude Code's scheduling runs on **Anthropic's cloud** (or requires the desktop app open),
|
||||
not a self-hosted server; cloud jobs have a minimum interval and your data leaves your hardware
|
||||
- Memory is **project-file-based** (CLAUDE.md / MEMORY.md), not a knowledge graph that
|
||||
accumulates automatically across all your work; auto-memory is still rolling out
|
||||
- **Not provider-agnostic** -- routes through Bedrock or Vertex but always hits a Claude model;
|
||||
you cannot switch to GPT, Gemini, or a local model
|
||||
- **Not open source** -- proprietary; the CLI ships obfuscated JavaScript
|
||||
- Messaging channels are a **research preview** requiring Bun runtime; not yet production-grade
|
||||
|
||||
Hermes can use Claude Code as a sub-agent. For large implementation tasks, Hermes can spawn
|
||||
Claude Code to handle the heavy lifting and fold the result back into its own memory and history.
|
||||
|
||||
| | Claude Code | Hermes |
|
||||
|---|---|---|
|
||||
| Persistent memory (automatic) | Partial (CLAUDE.md / MEMORY.md, rolling out) | Yes |
|
||||
| Skills / hooks system | Yes (Hooks + Plugin/Skills marketplace) | Yes (auto-generated from experience) |
|
||||
| Scheduled jobs (self-hosted) | No (cloud or desktop-app only) | Yes |
|
||||
| Messaging access | Partial (Telegram/Discord/iMessage via research preview; Slack native) | Yes (10+ platforms, production) |
|
||||
| Cowork connectors (Slack, Gmail, etc.) | Yes (via Claude Cowork, separate product) | Via agent tool use |
|
||||
| Web UI | Yes (claude.ai/code, Anthropic-hosted) | Yes (self-hosted) |
|
||||
| Provider-agnostic | No (Claude models only, via Bedrock/Vertex) | Yes (any provider) |
|
||||
| Self-hosted scheduling | No | Yes |
|
||||
| Open source | No | Yes |
|
||||
| Runs as sub-agent of Hermes | Yes | N/A |
|
||||
|
||||
### vs. Codex CLI (OpenAI)
|
||||
|
||||
Codex CLI is OpenAI's open-source agentic terminal tool (Apache 2.0, ~73k GitHub stars). It
|
||||
supports 10+ providers including Anthropic, Google, Mistral, Groq, and local models via Ollama.
|
||||
It added persistent session memory in v0.100.0 with `codex resume`. The desktop app has an
|
||||
Automations feature for scheduled local tasks.
|
||||
|
||||
The CLI itself has no native scheduling (open feature request as of early 2026). Memory is
|
||||
session-history-based rather than a living knowledge graph. No messaging app access. A strong
|
||||
tool for single-session coding; Hermes adds the always-on layer on top.
|
||||
|
||||
| | Codex CLI | Hermes |
|
||||
|---|---|---|
|
||||
| Persistent memory | Partial (session history + AGENTS.md) | Yes (automatic, layered) |
|
||||
| Scheduled jobs | Partial (desktop app only; CLI has none) | Yes |
|
||||
| Messaging app access | No | Yes |
|
||||
| Web UI | No | Yes (self-hosted) |
|
||||
| Provider-agnostic | Yes (10+ providers) | Yes (10+ providers) |
|
||||
| Self-hosted | Yes | Yes |
|
||||
| Open source | Yes (Apache 2.0) | Yes |
|
||||
|
||||
### vs. OpenCode
|
||||
|
||||
OpenCode is an open-source TUI agentic coding assistant, provider-agnostic across 75+ providers.
|
||||
It has a WebUI embedded in its binary and an official desktop app. It uses SQLite for session
|
||||
history and AGENTS.md for project context.
|
||||
|
||||
No native scheduled jobs (a community background plugin exists), no first-party messaging
|
||||
integration (community Telegram bots exist but require manual setup), and no automatic
|
||||
cross-session semantic memory. Good for interactive terminal coding sessions.
|
||||
|
||||
| | OpenCode | Hermes |
|
||||
|---|---|---|
|
||||
| Persistent memory | Partial (session history + AGENTS.md) | Yes (automatic, layered) |
|
||||
| Scheduled jobs | No (community plugin only) | Yes |
|
||||
| Messaging app access | No (community Telegram bot only) | Yes (first-party, 10+ platforms) |
|
||||
| Web UI | Yes (embedded + desktop app) | Yes (self-hosted) |
|
||||
| Mobile access | No | Yes |
|
||||
| Skills system | No | Yes |
|
||||
| Provider-agnostic | Yes (75+ providers) | Yes |
|
||||
| Open source | Yes | Yes |
|
||||
|
||||
### vs. Cursor / Windsurf / Copilot
|
||||
|
||||
Category 2 tools -- exceptional at in-editor autocomplete, inline diffs, and code review.
|
||||
Not competing for the same job as Hermes, and they work well alongside it.
|
||||
|
||||
Windsurf was earliest with workspace-scoped memory (Cascade Memories); Copilot has been
|
||||
shipping repo-level memory since late 2025. Cursor has no native cross-session memory as of
|
||||
early 2026. None have scheduling or messaging access.
|
||||
|
||||
| | Cursor | Windsurf | Copilot | Hermes |
|
||||
|---|---|---|---|---|
|
||||
| In-editor autocomplete | Excellent | Excellent | Excellent | No |
|
||||
| Inline diff / refactor | Yes | Yes | Yes | Via shell |
|
||||
| Cross-session memory | No | Yes (workspace) | Partial (repo, early access) | Yes |
|
||||
| Scheduled background jobs | No | No | No | Yes |
|
||||
| Messaging app / mobile | No | No | No | Yes |
|
||||
| Terminal tool use | Limited | Limited | Limited | Full |
|
||||
| Self-hosted | No | No | No | Yes |
|
||||
| Provider-agnostic | Partial | Partial | No | Yes |
|
||||
| Open source | No | No | No | Yes |
|
||||
|
||||
### vs. Claude.ai / ChatGPT
|
||||
|
||||
Category 1. For drafting, Q&A, and brainstorming in the moment, both are excellent.
|
||||
|
||||
Claude.ai memory has been improving -- it now generates memory from chat history, not just
|
||||
user-curated entries. Claude.ai can also execute code and read/write files in a sandboxed
|
||||
environment via Artifacts. These are real capabilities, just not the same as direct filesystem
|
||||
or shell access on your own server.
|
||||
|
||||
| | Claude.ai / ChatGPT | Hermes |
|
||||
|---|---|---|
|
||||
| Memory across conversations | Yes (improving; auto-generated from history) | Yes (deep, automatic) |
|
||||
| Runs shell commands | No | Yes |
|
||||
| Code execution | Sandboxed (Artifacts) | Yes (full shell) |
|
||||
| Reads / writes files | Sandboxed (Artifacts) | Yes (full filesystem) |
|
||||
| Schedules background jobs | No | Yes |
|
||||
| Web UI | Yes | Yes |
|
||||
| Messaging apps | No | Yes |
|
||||
| Self-hosted | No | Yes |
|
||||
| Provider-agnostic | No | Yes |
|
||||
| Open source | No | Yes |
|
||||
|
||||
---
|
||||
|
||||
## The Compounding Advantage
|
||||
|
||||
What matters most about Hermes is that it improves over time. That is the point.
|
||||
|
||||
Every time Hermes encounters a new environment, it saves facts to memory. Every time it solves
|
||||
a problem a new way, it saves the approach as a skill. Every time you correct it, it updates its
|
||||
profile of you. Every session, every scheduled job, every tool call, the agent gets more
|
||||
calibrated to you and your workflow.
|
||||
|
||||
A Claude Code session on day one and day one hundred are identical. A Hermes agent on day one
|
||||
and day one hundred is smarter about you -- it knows your stack, your conventions, your
|
||||
preferences, and the solutions that have worked before.
|
||||
|
||||
---
|
||||
|
||||
## Who Hermes Is For
|
||||
|
||||
**Solo developers and power users** who don't want to re-explain their stack every session and
|
||||
want an AI that actually knows their environment.
|
||||
|
||||
**Teams on a shared server** where multiple people want Claude-quality AI access without each
|
||||
paying for a separate subscription or running local tooling.
|
||||
|
||||
**Automation-heavy workflows** where you want an AI running tasks on a schedule, delivering
|
||||
results to your phone, without babysitting it.
|
||||
|
||||
**Privacy-conscious users** who want their conversations, memory, and files on their own
|
||||
hardware.
|
||||
|
||||
**Multi-model users** who want to switch between OpenAI, Anthropic, Google, DeepSeek, and
|
||||
others based on cost, capability, or rate limits, without rebuilding their workflow each time.
|
||||
|
||||
---
|
||||
|
||||
## Scope and Limits
|
||||
|
||||
**Hermes lives in the terminal, browser, and messaging apps.** For in-editor autocomplete and
|
||||
inline diffs, use Cursor or Windsurf alongside it -- they do that job better.
|
||||
|
||||
**You run Hermes on your own server.** That means initial setup, but your data stays on your
|
||||
hardware and you control the schedule, the models, and the costs.
|
||||
|
||||
**Hermes is an orchestration and memory layer.** It makes whatever model you point it at more
|
||||
useful over time. The models do the reasoning; Hermes makes sure that reasoning accumulates into
|
||||
something durable.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| | OpenClaw | Claude Code | Codex CLI | OpenCode | Cursor | Claude.ai | Hermes |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Persistent memory (auto) | Yes | Partial† | Partial | Partial | No | Yes (improving) | **Yes** |
|
||||
| Scheduled / background jobs | Yes | Partial‡ | Partial§ | No | No | No | **Yes (self-hosted)** |
|
||||
| Messaging app access | Yes (15+ platforms) | Partial (Telegram/Discord preview; Slack native) | No | No | No | No | **Yes (10+ platforms)** |
|
||||
| Web UI | Dashboard only | Yes (Anthropic cloud) | No | Yes | No | Yes | **Yes (self-hosted)** |
|
||||
| Skills system | Yes (marketplace) | Yes (Hooks + Plugins) | No | No | No | No | **Yes** |
|
||||
| Self-improving skills | Partial | No | No | No | No | No | **Yes** |
|
||||
| Browser / computer control | Yes (Chrome CDP) | No | No | No | No | No | Via shell |
|
||||
| Python / ML ecosystem | No (Node.js) | No | No | No | No | No | **Yes** |
|
||||
| In-editor autocomplete | No | No | No | No | Yes | No | No |
|
||||
| Orchestrates other agents | No | No | No | No | No | No | **Yes** |
|
||||
| Provider-agnostic | Yes | No (Claude only) | Yes | Yes | Partial | No | **Yes** |
|
||||
| Self-hosted | Yes | No | Yes | Yes | No | No | **Yes** |
|
||||
| Open source | Yes (MIT) | No | Yes | Yes | No | No | **Yes** |
|
||||
| Always-on / autonomous | Yes | No | No | No | No | No | **Yes** |
|
||||
|
||||
† Claude Code has CLAUDE.md / MEMORY.md project context and rolling auto-memory, but not full automatic cross-session recall
|
||||
‡ Claude Code scheduling: cloud-managed (Anthropic infrastructure) or desktop-app only; no self-hosted cron
|
||||
§ Codex scheduling: desktop app Automations only; CLI has no native scheduling
|
||||
117
README.md
117
README.md
@@ -10,9 +10,70 @@ and vanilla JS.
|
||||
Layout: three-panel Claude-style. Left sidebar for sessions and tools,
|
||||
center for chat, right for workspace file browsing.
|
||||
|
||||
<img width="1392" height="854" alt="image" src="https://github.com/user-attachments/assets/79cd3c0d-3167-42ed-9434-447a742c25c3" />
|
||||
<img width="1392" alt="Hermes Web UI — three-panel layout" src="https://github.com/user-attachments/assets/79cd3c0d-3167-42ed-9434-447a742c25c3" />
|
||||
|
||||
This gives you nearly **1:1 parity with Hermes CLI from a convenient web UI** which you can access securely through an SSH tunnel from your Hermes setup. Single command to start this up, and a single command to SSH tunnel for access on your computer. Every single part of the web UI leverages your existing Hermes agent, existing models, without requiring any setup.
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" align="center">
|
||||
<img alt="Workspace file browser with inline preview" src="docs/images/ui-workspace.png" />
|
||||
<br /><sub>Workspace file browser with inline preview</sub>
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<img alt="Session projects, tags, and tool call cards" src="docs/images/ui-sessions.png" />
|
||||
<br /><sub>Session projects, tags, and tool call cards</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
This gives you nearly **1:1 parity with Hermes CLI from a convenient web UI** which you can access securely through an SSH tunnel from your Hermes setup. Single command to start this up, and a single command to SSH tunnel for access on your computer. Every single part of the web UI uses your existing Hermes agent and existing models, without requiring any additional setup.
|
||||
|
||||
---
|
||||
|
||||
## Why Hermes
|
||||
|
||||
Most AI tools reset every session. They don't know who you are, what you worked on, or what
|
||||
conventions your project follows. You re-explain yourself every time.
|
||||
|
||||
Hermes retains context across sessions, runs scheduled jobs while you're offline, and gets
|
||||
smarter about your environment the longer it runs. It uses your existing Hermes agent setup,
|
||||
your existing models, and requires no additional configuration to start.
|
||||
|
||||
What makes it different from other agentic tools:
|
||||
|
||||
- **Persistent memory** — user profile, agent notes, and a skills system that saves reusable
|
||||
procedures; Hermes learns your environment and does not have to relearn it
|
||||
- **Self-hosted scheduling** — cron jobs that fire while you're offline and deliver results to
|
||||
Telegram, Discord, Slack, Signal, email, and more
|
||||
- **10+ messaging platforms** — the same agent available in the terminal is reachable from your phone
|
||||
- **Self-improving skills** — Hermes writes and saves its own skills automatically from experience;
|
||||
no marketplace to browse, no plugins to install
|
||||
- **Provider-agnostic** — OpenAI, Anthropic, Google, DeepSeek, OpenRouter, and more
|
||||
- **Orchestrates other agents** — can spawn Claude Code or Codex for heavy coding tasks and bring
|
||||
the results back into its own memory
|
||||
- **Self-hosted** — your conversations, your memory, your hardware
|
||||
|
||||
**vs. the field** *(landscape is actively shifting — see [HERMES.md](HERMES.md) for the full breakdown)*:
|
||||
|
||||
| | OpenClaw | Claude Code | Codex CLI | OpenCode | Hermes |
|
||||
|---|---|---|---|---|---|
|
||||
| Persistent memory (auto) | Yes | Partial† | Partial | Partial | Yes |
|
||||
| Scheduled jobs (self-hosted) | Yes | No‡ | No | No | Yes |
|
||||
| Messaging app access | Yes (15+ platforms) | Partial (Telegram/Discord preview) | No | No | Yes (10+) |
|
||||
| Web UI (self-hosted) | Dashboard only | No | No | Yes | Yes |
|
||||
| Self-improving skills | Partial | No | No | No | Yes |
|
||||
| Python / ML ecosystem | No (Node.js) | No | No | No | Yes |
|
||||
| Provider-agnostic | Yes | No (Claude only) | Yes | Yes | Yes |
|
||||
| Open source | Yes (MIT) | No | Yes | Yes | Yes |
|
||||
|
||||
† Claude Code has CLAUDE.md / MEMORY.md project context and rolling auto-memory, but not full automatic cross-session recall
|
||||
‡ Claude Code has cloud-managed scheduling (Anthropic infrastructure) and session-scoped `/loop`; no self-hosted cron
|
||||
|
||||
**The closest competitor is OpenClaw** — both are always-on, self-hosted, open-source agents
|
||||
with memory, cron, and messaging. The key differences: Hermes writes and saves its own skills
|
||||
automatically as a core behavior (OpenClaw's skill system centers on a community marketplace);
|
||||
Hermes is more stable across updates (OpenClaw has documented release regressions and ClawHub
|
||||
has had security incidents involving malicious skills); and Hermes runs natively in the Python
|
||||
ecosystem. See [HERMES.md](HERMES.md) for the full side-by-side.
|
||||
|
||||
---
|
||||
|
||||
@@ -37,17 +98,24 @@ That is it. The script will:
|
||||
|
||||
## Docker
|
||||
|
||||
Run with Docker Compose (recommended):
|
||||
**Pre-built images** (amd64 + arm64) are published to GHCR on every release:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/nesquena/hermes-webui:latest
|
||||
docker run -d -p 8787:8787 -v ~/.hermes:/root/.hermes ghcr.io/nesquena/hermes-webui:latest
|
||||
```
|
||||
|
||||
Or run with Docker Compose (recommended):
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Or build and run manually:
|
||||
Or build locally:
|
||||
|
||||
```bash
|
||||
docker build -t hermes-webui .
|
||||
docker run -d -p 8787:8787 -v ~/.hermes:/root/.hermes:ro hermes-webui
|
||||
docker run -d -p 8787:8787 -v ~/.hermes:/root/.hermes hermes-webui
|
||||
```
|
||||
|
||||
Open http://localhost:8787 in your browser.
|
||||
@@ -55,7 +123,7 @@ Open http://localhost:8787 in your browser.
|
||||
To enable password protection:
|
||||
|
||||
```bash
|
||||
docker run -d -p 8787:8787 -e HERMES_WEBUI_PASSWORD=your-secret -v ~/.hermes:/root/.hermes:ro hermes-webui
|
||||
docker run -d -p 8787:8787 -e HERMES_WEBUI_PASSWORD=your-secret -v ~/.hermes:/root/.hermes ghcr.io/nesquena/hermes-webui:latest
|
||||
```
|
||||
|
||||
Session data persists in a named volume (`hermes-data`) across restarts.
|
||||
@@ -169,8 +237,8 @@ Or using the agent venv explicitly:
|
||||
```
|
||||
|
||||
Tests run against an isolated server on port 8788 with a separate state directory.
|
||||
Production data and real cron jobs are never touched. Current count: **415 tests**
|
||||
across 21 test files.
|
||||
Production data and real cron jobs are never touched. Current count: **424 tests**
|
||||
across 22 test files.
|
||||
|
||||
---
|
||||
|
||||
@@ -184,6 +252,7 @@ across 21 test files.
|
||||
- Retry the last assistant response with one click
|
||||
- Cancel a running task from the activity bar
|
||||
- Tool call cards inline -- each shows the tool name, args, and result snippet; expand/collapse all toggle for multi-tool turns
|
||||
- Subagent delegation cards -- child agent activity shown with distinct icon and indented border
|
||||
- Mermaid diagram rendering inline (flowcharts, sequence diagrams, gantt charts)
|
||||
- Thinking/reasoning display -- collapsible gold-themed cards for Claude extended thinking and o3 reasoning blocks
|
||||
- Approval card for dangerous shell commands (allow once / session / always / deny)
|
||||
@@ -204,6 +273,8 @@ across 21 test files.
|
||||
- Download as Markdown transcript, full JSON export, or import from JSON
|
||||
- Sessions persist across page reloads and SSH tunnel reconnects
|
||||
- Browser tab title reflects the active session name
|
||||
- CLI session bridge -- CLI sessions from hermes-agent's SQLite store appear in the sidebar with a gold "cli" badge; click to import with full history and reply normally
|
||||
- Token/cost display -- input tokens, output tokens, estimated cost shown per conversation (toggle in Settings or `/usage` command)
|
||||
|
||||
### Workspace file browser
|
||||
- Directory tree with expand/collapse (single-click toggles, double-click navigates)
|
||||
@@ -243,19 +314,21 @@ across 21 test files.
|
||||
### Settings and configuration
|
||||
- Settings panel (gear icon) -- default model, default workspace, send key preference
|
||||
- Send key: Enter (default) or Ctrl/Cmd+Enter
|
||||
- Show/hide CLI sessions toggle (enabled by default)
|
||||
- Token usage display toggle (off by default, also via `/usage` command)
|
||||
- Cron completion alerts -- toast notifications and unread badge on Tasks tab
|
||||
- Background agent error alerts -- banner when a non-active session encounters an error
|
||||
|
||||
### Slash commands
|
||||
- Type `/` in the composer for autocomplete dropdown
|
||||
- Built-in: `/help`, `/clear`, `/model <name>`, `/workspace <name>`, `/new`
|
||||
- Built-in: `/help`, `/clear`, `/model <name>`, `/workspace <name>`, `/new`, `/usage`
|
||||
- Arrow keys navigate, Tab/Enter select, Escape closes
|
||||
- Unrecognized commands pass through to the agent
|
||||
|
||||
### Panels
|
||||
- **Chat** -- session list, search, pin, archive, projects, new conversation
|
||||
- **Tasks** -- view, create, edit, run, pause/resume, delete cron jobs; run history; completion alerts
|
||||
- **Skills** -- list all skills by category, search, preview, create/edit/delete
|
||||
- **Skills** -- list all skills by category, search, preview, create/edit/delete; linked files viewer
|
||||
- **Memory** -- view and edit MEMORY.md and USER.md inline
|
||||
- **Profiles** -- create, switch, delete agent profiles; clone config
|
||||
- **Todos** -- live task list from the current session
|
||||
@@ -277,30 +350,31 @@ across 21 test files.
|
||||
server.py HTTP routing shell + auth middleware (~81 lines)
|
||||
api/
|
||||
auth.py Optional password authentication, signed cookies (~149 lines)
|
||||
config.py Discovery, globals, model detection, reloadable config (~701 lines)
|
||||
config.py Discovery, globals, model detection, reloadable config (~702 lines)
|
||||
helpers.py HTTP helpers, security headers (~71 lines)
|
||||
models.py Session model + CRUD (~137 lines)
|
||||
profiles.py Profile state management, hermes_cli wrapper (~246 lines)
|
||||
models.py Session model + CRUD (~146 lines)
|
||||
profiles.py Profile state management, hermes_cli wrapper (~366 lines)
|
||||
routes.py All GET + POST route handlers (~1180 lines)
|
||||
streaming.py SSE engine, run_agent, cancel support (~236 lines)
|
||||
streaming.py SSE engine, run_agent, cancel support (~272 lines)
|
||||
upload.py Multipart parser, file upload handler (~78 lines)
|
||||
workspace.py File ops, workspace helpers (~77 lines)
|
||||
workspace.py File ops, workspace helpers (~245 lines)
|
||||
static/
|
||||
index.html HTML template (~364 lines)
|
||||
style.css All CSS incl. mobile responsive (~670 lines)
|
||||
ui.js DOM helpers, renderMd, tool cards, file tree (~977 lines)
|
||||
workspace.js File preview, file ops (~185 lines)
|
||||
sessions.js Session CRUD, list rendering, search (~533 lines)
|
||||
messages.js send(), SSE handlers, approval, transcript (~297 lines)
|
||||
panels.js Cron, skills, memory, profiles, settings (~974 lines)
|
||||
ui.js DOM helpers, renderMd, tool cards, file tree (~1002 lines)
|
||||
workspace.js File preview, file ops (~191 lines)
|
||||
sessions.js Session CRUD, list rendering, search (~556 lines)
|
||||
messages.js send(), SSE handlers, approval, transcript (~337 lines)
|
||||
panels.js Cron, skills, memory, profiles, settings (~1030 lines)
|
||||
commands.js Slash command autocomplete (~156 lines)
|
||||
boot.js Mobile nav, voice input, boot IIFE (~338 lines)
|
||||
tests/
|
||||
conftest.py Isolated test server (port 8788)
|
||||
test_sprint{1-20b}.py 21 test files, 415 test functions
|
||||
test_sprint{1-23}.py 22 test files, 426 test functions
|
||||
test_regressions.py Permanent regression gate (23 tests)
|
||||
Dockerfile python:3.12-slim container image
|
||||
docker-compose.yml Compose with named volume and optional auth
|
||||
.github/workflows/ CI: multi-arch Docker build + GitHub Release on tag
|
||||
```
|
||||
|
||||
State lives outside the repo at `~/.hermes/webui-mvp/` by default
|
||||
@@ -310,6 +384,7 @@ State lives outside the repo at `~/.hermes/webui-mvp/` by default
|
||||
|
||||
## Docs
|
||||
|
||||
- `HERMES.md` -- why Hermes, mental model, and detailed comparison to Claude Code / Codex / OpenCode / Cursor
|
||||
- `ROADMAP.md` -- feature roadmap and sprint history
|
||||
- `ARCHITECTURE.md` -- system design, all API endpoints, implementation notes
|
||||
- `TESTING.md` -- manual browser test plan and automated coverage reference
|
||||
|
||||
123
ROADMAP.md
123
ROADMAP.md
@@ -3,8 +3,8 @@
|
||||
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
|
||||
> Everything you can do from the CLI terminal, you can do from this UI.
|
||||
>
|
||||
> Last updated: Sprint 22 / v0.24 (April 3, 2026)
|
||||
> Tests: 415 total (392 passing, 23 pre-existing failures)
|
||||
> Last updated: v0.29 (April 4, 2026)
|
||||
> Tests: 424 total (401 passing, 23 pre-existing failures)
|
||||
> Source: <repo>/
|
||||
|
||||
---
|
||||
@@ -39,6 +39,7 @@
|
||||
| Sprint 20 | Voice input + send button | Voice input (Web Speech API), send button icon-circle with pop-in animation | 415 |
|
||||
| Sprint 21 | Mobile responsive + Docker | Hamburger sidebar, bottom nav, files slide-over, Docker support (#21, #7) | 415 |
|
||||
| Sprint 22 | Multi-profile support | Profile picker, management panel, seamless switching, per-session tracking (#28) | 415 |
|
||||
| Sprint 23 | Agentic transparency | Token/cost display, subagent cards, skill picker in cron, skill linked files, workspace tree persistence, timestamp fixes | 424 |
|
||||
|
||||
---
|
||||
|
||||
@@ -46,11 +47,12 @@
|
||||
|
||||
| Layer | Location | Status |
|
||||
|-------|----------|--------|
|
||||
| Python server | <repo>/server.py (~81 lines) + api/ modules (~2876 lines) | Thin shell + auth middleware + business logic in api/ |
|
||||
| Python server | <repo>/server.py (~81 lines) + api/ modules (~3210 lines) | Thin shell + auth middleware + business logic in api/ |
|
||||
| HTML template | <repo>/static/index.html (~364 lines) | Served from disk |
|
||||
| CSS | <repo>/static/style.css (~670 lines) | Served from disk, incl. mobile responsive |
|
||||
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot,commands}.js | 7 modules, ~3460 lines total |
|
||||
| Docker | Dockerfile, docker-compose.yml, .dockerignore | python:3.12-slim, named volume |
|
||||
| JavaScript | <repo>/static/{ui,workspace,sessions,messages,panels,boot,commands}.js | 7 modules, ~3610 lines total |
|
||||
| Docker | Dockerfile, docker-compose.yml, .dockerignore | python:3.12-slim, multi-arch (amd64+arm64) |
|
||||
| CI/CD | .github/workflows/release.yml | Auto-release + GHCR publish on tag push |
|
||||
| Runtime state | ~/.hermes/webui-mvp/sessions/ | Session JSON files |
|
||||
| Test server | Port 8788, state dir ~/.hermes/webui-mvp-test/ | Isolated, wiped per run |
|
||||
| Production server | Port 8787 | SSH tunnel from Mac |
|
||||
@@ -75,7 +77,7 @@
|
||||
- [x] Copy message to clipboard (hover icon on each bubble)
|
||||
- [x] Edit last user message and regenerate
|
||||
- [ ] Branch/fork conversation (Wave 3)
|
||||
- [ ] Token/cost estimate per message (Wave 3)
|
||||
- [x] Token/cost estimate per message (Sprint 23)
|
||||
|
||||
### Tool Visibility
|
||||
- [x] Tool progress in activity bar (moved out of composer footer)
|
||||
@@ -136,7 +138,7 @@
|
||||
- [x] Edit existing cron job
|
||||
- [x] Delete cron job
|
||||
- [x] View full cron run history (expandable per job)
|
||||
- [ ] Skill picker in cron create form (Wave 3)
|
||||
- [x] Skill picker in cron create form (Sprint 23)
|
||||
|
||||
### Skills
|
||||
- [x] List all skills grouped by category (Skills sidebar tab)
|
||||
@@ -145,7 +147,7 @@
|
||||
- [x] Create skill
|
||||
- [x] Edit skill
|
||||
- [x] Delete skill
|
||||
- [ ] View skill linked files (Wave 3)
|
||||
- [x] View skill linked files (Sprint 23)
|
||||
|
||||
### Memory
|
||||
- [x] View personal notes (MEMORY.md) rendered as markdown (Memory tab)
|
||||
@@ -273,96 +275,29 @@ Enter saves, Escape cancels. Topbar updates immediately.
|
||||
|
||||
---
|
||||
|
||||
## Wave 3: Power Features and Developer Experience
|
||||
## Completed Waves (Summary)
|
||||
|
||||
### Sprint 3.1: Tool Call Visibility Inline
|
||||
Show tool calls as collapsible cards in the conversation.
|
||||
Collapsed: tool name badge + one-line preview. Expanded: full args + result.
|
||||
|
||||
### Sprint 3.2: Multi-Model Expansion
|
||||
Add more models. Group by provider. Model info tooltip on hover.
|
||||
(Partially done: 10 models in dropdown from Sprint 1.)
|
||||
|
||||
### Sprint 3.2b: Resizable Panel Widths (COMPLETE Sprint 6)
|
||||
Both sidebar and workspace panel are drag-resizable with localStorage persistence.
|
||||
|
||||
### Sprint 3.3: Workspace File Actions
|
||||
- [x] Rename file (inline, double-click) (Sprint 14)
|
||||
- [x] Create folder (Sprint 14)
|
||||
- [x] Syntax highlighted code preview (Prism.js)
|
||||
|
||||
### Sprint 3.4: Conversation Controls
|
||||
- [x] Copy message (Sprint 5)
|
||||
- [x] Edit last user message + regenerate
|
||||
- [x] Regenerate last assistant response
|
||||
- [x] Clear conversation (wipe messages, keep session)
|
||||
|
||||
---
|
||||
|
||||
## Wave 4: Settings, Configuration, Notifications
|
||||
|
||||
### Sprint 4.1: Settings Panel
|
||||
Full settings overlay: default model, default workspace, enabled toolsets, config viewer.
|
||||
|
||||
### Sprint 4.2: Notification Panel
|
||||
Bell icon with unread count. SSE endpoint for cron completions and errors. Toast pop-ups.
|
||||
|
||||
### Sprint 4.3: Delivery Target Config
|
||||
Configure and test-ping delivery targets (Discord, Telegram, Slack, email) for cron jobs.
|
||||
|
||||
---
|
||||
|
||||
## Wave 5: Honcho Integration and Long-term Memory
|
||||
|
||||
### Sprint 5.1: Honcho Memory Panel
|
||||
User representation panel, cross-session context, Honcho search, memory write.
|
||||
|
||||
### Sprint 5.2: Session Continuity Features
|
||||
"What were we working on?" button, session tags, session archive.
|
||||
|
||||
---
|
||||
|
||||
## Wave 6: Realtime and Agentic Features
|
||||
|
||||
### Sprint 6.1: Background Task Monitor
|
||||
Live list of running agent threads. Cancel button. Queue visibility.
|
||||
|
||||
### Sprint 6.2: Subagent Delegation Cards
|
||||
When delegate_task fires, show subagent progress inline in chat.
|
||||
|
||||
### Sprint 6.3: Code Execution Panel
|
||||
Jupyter-style inline code cell. Stateful kernel per session.
|
||||
|
||||
### Sprint 6.4: Voice Mode
|
||||
Push-to-talk mic button. Whisper transcription. Optional TTS playback.
|
||||
|
||||
---
|
||||
|
||||
## Wave 7: Production Hardening and Mobile
|
||||
|
||||
### Sprint 7.1: Authentication
|
||||
HERMES_WEBUI_PASSWORD env var gate. Signed cookie. Login page.
|
||||
|
||||
### Sprint 7.2: HTTPS and Reverse Proxy
|
||||
Nginx + Let's Encrypt. CORS headers for external domain.
|
||||
|
||||
### Sprint 7.3: Mobile Responsive Layout
|
||||
Collapsible sidebar hamburger. Touch-friendly controls. Swipe gestures.
|
||||
|
||||
### Sprint 7.4: Performance and Scale
|
||||
Virtual scroll for session/message lists. Incremental message loading.
|
||||
| Wave | Theme | Key Deliverables |
|
||||
|------|-------|-----------------|
|
||||
| Wave 2 | Full CRUD + Interaction | Cron/skill/memory CRUD, session search, workspace management, session rename |
|
||||
| Wave 3 | Power Features | Tool call cards, multi-model dropdown, resizable panels, file actions, conversation controls |
|
||||
| Wave 4 | Settings + Notifications | Settings panel, cron alerts, background error banner |
|
||||
| Wave 5 | Session Continuity | Session tags, archive, projects/folders |
|
||||
| Wave 6 | Agentic Features | Background task cancel, voice input (Web Speech API) |
|
||||
| Wave 7 | Production Hardening | Password auth, security headers, mobile responsive, Docker + GHCR CI |
|
||||
|
||||
---
|
||||
|
||||
## User Requested Features
|
||||
|
||||
Community-requested enhancements tracked from GitHub issues.
|
||||
Community-requested enhancements tracked from GitHub issues. All shipped.
|
||||
|
||||
| Feature | Issue | Description | Complexity |
|
||||
|---------|-------|-------------|-----------|
|
||||
| Workspace tree view | #22 | Accordion/tree view for workspace file browser instead of flat list. Lazy-load subdirectories on expand, no backend changes needed. | Medium |
|
||||
| Docker container | #7 | Docker Compose setup with separate hermes-agent and hermes-webui containers, multi-arch (amd64 + arm64), volume mounts for config. | Medium-High |
|
||||
| Authentication | #23 | Password gate via `HERMES_WEBUI_PASSWORD` env var, login page, signed cookie. Already planned in Sprint 7.1. | Low-Medium |
|
||||
| Send key / personalization | #26 | Toggle send key (Enter vs Ctrl/Cmd+Enter) and queue vs interrupt mode as global settings. | Low |
|
||||
| Multi-profile support | #28 | Profile management UI: create, delete, switch, configure agent profiles. | Medium |
|
||||
| Mobile responsive UI | #21 | Hamburger menu, slide-out sidebar drawer, touch-friendly controls. Already planned in Sprint 7.3. | Medium-High |
|
||||
| Feature | Issue | Shipped | Sprint |
|
||||
|---------|-------|---------|--------|
|
||||
| Workspace tree view | #22 | Done | Sprint 18 |
|
||||
| Docker container + GHCR images | #7 | Done | Sprint 21 + v0.28.1 CI |
|
||||
| Authentication | #23 | Done | Sprint 19 |
|
||||
| Send key / personalization | #26 | Done | Sprint 17 |
|
||||
| Multi-profile support | #28 | Done | Sprint 22 |
|
||||
| Mobile responsive UI | #21 | Done | Sprint 21 |
|
||||
| Profile creation in Docker | #44 | Done | v0.27 |
|
||||
|
||||
265
SPRINTS.md
265
SPRINTS.md
@@ -1,6 +1,6 @@
|
||||
# Hermes Web UI -- Forward Sprint Plan
|
||||
|
||||
> Current state: v0.27 | 426 tests | Daily driver ready
|
||||
> Current state: v0.30.1 | 424 tests | Daily driver ready
|
||||
> This document plans the path from here to two targets:
|
||||
>
|
||||
> Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the
|
||||
@@ -567,25 +567,260 @@ and switchToProfile() didn't refresh workspaces or sessions.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 24 -- Desktop Application (PLANNED)
|
||||
## Sprint 24 -- Web Polish + Bug Fix Pass (PLANNED)
|
||||
|
||||
**Theme:** Native desktop experience.
|
||||
**Theme:** Stabilize, harden, and close the last meaningful web UI gaps before
|
||||
shifting focus to distribution. Goal is a release that's genuinely ready for
|
||||
wider user adoption -- no rough edges, no obvious missing pieces.
|
||||
|
||||
**Why now:** Sprint 23 completed the core agentic transparency features. The
|
||||
remaining web roadmap items are diminishing-returns polish. Rather than
|
||||
grinding through marginal features, this sprint cleans up what's there, fixes
|
||||
bugs users will actually hit, and closes a few real gaps before recommending
|
||||
the app to others.
|
||||
|
||||
### Track A: Bug Fixes
|
||||
- **Cron edit form has no skill picker.** Sprint 23 added skill picker to the
|
||||
create form but not the edit form. cronEditSave() doesn't include skills in
|
||||
the update body, so existing skills survive an edit but can't be changed.
|
||||
Fix: add the same skill picker UI to the inline edit form and include
|
||||
`skills` in the update POST body.
|
||||
- **S.lastUsage dead code.** messages.js sets `S.lastUsage` from `d.usage` at
|
||||
done-time, but nothing reads it. The usage badge reads cumulative session
|
||||
totals from `S.session.input_tokens` instead. Either wire `S.lastUsage` into
|
||||
a per-turn display or remove the dead assignment.
|
||||
- **_cronSkillsCache never invalidated.** Skills picker shows stale data if
|
||||
skills are added/removed mid-session. Add a cache-bust when the skills panel
|
||||
is opened or a skill is saved/deleted.
|
||||
- **Tool args not shown on session reload.** Tool call cards in history show
|
||||
name and result snippet but not the args (args only exist in the live SSE
|
||||
event). Sprint 23 added args to the session JSON -- verify they're actually
|
||||
rendering in the settled history cards.
|
||||
|
||||
### Track B: Features
|
||||
- **Electron or Tauri wrapper.** Native window, menu bar, notifications.
|
||||
- **Auto-start option.** Launch on login.
|
||||
- **Packaged distribution.** .dmg (macOS), .exe (Windows).
|
||||
- **Cron edit: skill picker parity.** As above -- make create and edit forms
|
||||
identical in capability.
|
||||
- **Per-turn cost display.** The current usage badge shows cumulative session
|
||||
totals attached to the last message, which is misleading. Either: (a) show
|
||||
per-turn cost from `S.lastUsage` immediately after each response instead of
|
||||
cumulative, or (b) show cumulative in the session topbar/header instead of
|
||||
attached to a message bubble. Pick the cleaner UX.
|
||||
- **Virtual scroll for long session/skill lists.** When session count or skill
|
||||
count gets large (100+), the sidebar becomes sluggish. Add a simple virtual
|
||||
scroll or windowed render -- only render visible items + a buffer above/below.
|
||||
CSS `contain: strict` + IntersectionObserver approach, no library needed.
|
||||
|
||||
### Track C: Code Quality
|
||||
- **SPRINTS.md + ROADMAP.md + CHANGELOG.md updated** to reflect Sprint 23
|
||||
completion (agentic transparency) and correct test counts.
|
||||
- **Remove stale Sprint 23 description** from SPRINTS.md (the "Profile/Workspace
|
||||
coherence" text is from an older plan; Sprint 23 actually shipped agentic
|
||||
transparency features).
|
||||
- **CHANGELOG entry for v0.29** covering Sprint 23 deliverables.
|
||||
|
||||
**Estimated tests:** ~10 new. Target total: ~435.
|
||||
**Hermes CLI parity impact:** Low
|
||||
**Claude parity impact:** Low
|
||||
**User-facing value:** Medium -- removes rough edges that would bother new users
|
||||
|
||||
---
|
||||
|
||||
## Sprint 24 -- Extended Command Support (PLANNED)
|
||||
## Sprint 25 -- macOS Desktop Application (PLANNED)
|
||||
|
||||
**Theme:** Deeper slash command and skill integration.
|
||||
**Theme:** Native Mac desktop app. Single download, runs entirely offline,
|
||||
feels like a real application -- not a browser tab.
|
||||
|
||||
### Track B: Features
|
||||
- **Skill-aware autocomplete.** `/skill-name` triggers installed skills.
|
||||
- **Command chaining.** Compose multi-step commands.
|
||||
- **Agent tool exposure.** Surface agent capabilities as slash commands.
|
||||
**Why this matters:** The web UI requires an SSH tunnel or a server setup to
|
||||
use. A .app bundle that a user can double-click and immediately have a working
|
||||
Hermes interface is genuinely differentiating. No other open-source Hermes
|
||||
interface ships as a native Mac app. This is the highest-leverage remaining
|
||||
investment for user adoption.
|
||||
|
||||
**Approach: Swift + WKWebView (not Electron)**
|
||||
|
||||
The right architecture is a thin native Swift shell (~300-500 lines) that:
|
||||
1. Bundles the existing Python server and all api/ modules inside the .app
|
||||
2. Spawns the server as a subprocess on a random local port at launch
|
||||
3. Opens a WKWebView window pointed at that localhost port
|
||||
4. Handles Mac app lifecycle natively (dock icon, cmd+Q, window management,
|
||||
app menu, about box)
|
||||
5. Bridges a small set of native Mac capabilities that WKWebView can't do
|
||||
|
||||
**Why not Electron:** WKWebView is Safari's engine -- dramatically lighter than
|
||||
Chromium. No 200MB node_modules. No separate update daemon. The .app is ~30MB
|
||||
including the Python runtime, vs 150MB+ for Electron.
|
||||
|
||||
**Why not full native Swift UI:** Would require rewriting the entire frontend
|
||||
from scratch. The web UI is already fast, dark-themed, and feature-complete.
|
||||
The thin shell approach gets 95% of the benefit at 5% of the cost.
|
||||
|
||||
### Track A: Swift App Shell
|
||||
|
||||
**Files to create:**
|
||||
```
|
||||
desktop/
|
||||
HermesApp.swift -- @main entry point, NSApp delegate
|
||||
AppDelegate.swift -- lifecycle: start server on launch, stop on quit
|
||||
WindowController.swift -- NSWindow + WKWebView setup, cmd shortcuts
|
||||
ServerManager.swift -- spawn/monitor Python subprocess, pick free port
|
||||
MenuBuilder.swift -- native app menu (File, Edit, View, Window, Help)
|
||||
Info.plist -- bundle ID, display name, version, icon
|
||||
Assets.xcassets/ -- app icon (1024x1024 + all required sizes)
|
||||
HermesApp.xcodeproj/ -- Xcode project file
|
||||
```
|
||||
|
||||
**ServerManager.swift responsibilities:**
|
||||
- Find Python: check bundled runtime first, fall back to system python3
|
||||
- Pick a free port (bind to :0, read assigned port, close, use it)
|
||||
- Spawn: `python3 server.py --port {port}` as a child Process
|
||||
- Monitor: if server crashes, show an error sheet and offer restart
|
||||
- Shutdown: SIGTERM on app quit, wait up to 3s, then SIGKILL
|
||||
|
||||
**WKWebView configuration:**
|
||||
- `allowsBackForwardNavigationGestures = false` (it's a single-page app)
|
||||
- `WKUserContentController` for JS bridge (native notifications, file picker)
|
||||
- Wait for server health check before loading (poll /health, show loading
|
||||
spinner in the native window while waiting, typically <1s)
|
||||
- `userAgent` override so the server can detect desktop app context
|
||||
|
||||
**Native menu items (beyond defaults):**
|
||||
- File > New Session (Cmd+N) -- calls JS `newSession()`
|
||||
- File > New Window (Cmd+Shift+N) -- opens second window with its own WKWebView
|
||||
- View > Toggle Sidebar (Cmd+Shift+S)
|
||||
- Window > Zoom, Minimize (standard)
|
||||
- Help > About Hermes, Check for Updates (links to GitHub releases page)
|
||||
|
||||
### Track B: Python Bundling
|
||||
|
||||
Two options, in order of preference:
|
||||
|
||||
**Option A: Require system Python (simpler, recommended for v1)**
|
||||
- Check for `python3` at known paths: `/usr/bin/python3`, homebrew paths,
|
||||
pyenv paths
|
||||
- If not found: show a one-time setup sheet with instructions
|
||||
- Pros: tiny download (~5MB for the Swift app + web assets), no bundling complexity
|
||||
- Cons: user needs Python installed (most developers do; target audience does too)
|
||||
|
||||
**Option B: Bundle python-standalone (self-contained, larger)**
|
||||
- Use `python-build-standalone` (from Astral/uv project): pre-built Python
|
||||
3.11 binaries, ~30MB compressed, no Xcode toolchain needed to build
|
||||
- Extract to `~/Library/Application Support/Hermes/python/` on first launch
|
||||
- Install `requirements.txt` via bundled pip into a local venv
|
||||
- Pros: zero dependencies, works on a clean Mac
|
||||
- Cons: first launch takes ~10-20s for extraction + pip install; ~30MB download
|
||||
|
||||
**Recommendation:** Ship v1 with Option A. Add Option B as an optional
|
||||
"standalone" download for non-developers.
|
||||
|
||||
### Track C: Distribution
|
||||
|
||||
**GitHub Releases (primary):**
|
||||
- Build with `xcodebuild -scheme HermesApp -configuration Release -archivePath`
|
||||
- `xcodebuild -exportArchive` to produce a .app bundle
|
||||
- `hdiutil create` to produce a .dmg with drag-to-Applications installer UI
|
||||
- Upload .dmg as a GitHub Release asset via `gh release create`
|
||||
- CI: add `.github/workflows/mac-release.yml` -- trigger on `vX.Y.Z-mac` tag
|
||||
|
||||
**Code signing:**
|
||||
- Without an Apple Developer account: distribute as unsigned, users must
|
||||
right-click > Open on first launch (standard for open-source Mac apps)
|
||||
- With a free Apple Developer account: ad-hoc signing removes the Gatekeeper
|
||||
warning without paying $99/year (no notarization, but much better UX)
|
||||
- With paid account ($99/year): full notarization, no warnings, direct download
|
||||
|
||||
**Recommended for v1:** ad-hoc signing (free, good enough for early adopters).
|
||||
Document the right-click > Open workaround in the README for unsigned builds.
|
||||
|
||||
**Universal binary (Intel + Apple Silicon):**
|
||||
```bash
|
||||
xcodebuild archive -scheme HermesApp -destination "generic/platform=macOS"
|
||||
```
|
||||
Both architectures in one .app. No separate downloads needed.
|
||||
|
||||
### Track D: Native Integrations (v1 scope)
|
||||
|
||||
**System notifications for cron completion:**
|
||||
- The web UI polls `/api/cron/alerts` and shows in-page banners
|
||||
- The Mac app can additionally post `UNUserNotificationCenter` notifications
|
||||
- JS bridge: `window.webkit.messageHandlers.notify.postMessage({title, body})`
|
||||
- Swift handler: posts a native notification with the cron job name and output
|
||||
summary -- appears in Notification Center, works even when app is in background
|
||||
|
||||
**File picker for workspace add:**
|
||||
- Currently: user types a path string into the workspace add form
|
||||
- Mac app: intercept workspace-add form submission, open `NSOpenPanel` instead,
|
||||
return the selected path to the JS via `evaluateJavaScript`
|
||||
- Much better UX -- standard Mac folder picker, no typing paths
|
||||
|
||||
**Dock badge for pending approvals:**
|
||||
- When an agent approval is waiting, set `NSApp.dockTile.badgeLabel = "1"`
|
||||
- Clear badge when approval is resolved
|
||||
- JS bridge fires when approval card appears/disappears
|
||||
|
||||
**Menu bar mode (optional, v2):**
|
||||
- A small status bar item (⚗️ icon in menu bar) that opens a compact popover
|
||||
- Popover shows current session status, last message, quick-compose field
|
||||
- Useful for running Hermes in the background without a full window
|
||||
|
||||
### Track E: Testing
|
||||
|
||||
Since the Swift app is thin glue, most testing remains in the existing pytest
|
||||
suite (server still runs identically). New Swift-specific tests:
|
||||
- `ServerManagerTests.swift`: verify port picking, process spawn, health wait
|
||||
- UI tests via `XCUITest`: launch app, wait for WKWebView to load, verify
|
||||
title bar shows "Hermes", verify /health responds
|
||||
- Smoke test in CI: `xcodebuild test -scheme HermesApp`
|
||||
|
||||
### Implementation Order
|
||||
|
||||
1. `ServerManager.swift` + basic `AppDelegate` -- get Python server spawning
|
||||
and health-check working from Swift
|
||||
2. `WindowController.swift` -- WKWebView loading, loading spinner while
|
||||
server starts
|
||||
3. App icon + Info.plist -- make it look like a real app
|
||||
4. `MenuBuilder.swift` -- native menus + keyboard shortcuts
|
||||
5. JS bridge for notifications -- most impactful native integration
|
||||
6. DMG build script + GitHub Actions CI
|
||||
7. (Optional) File picker bridge, dock badge
|
||||
|
||||
### What to NOT do in v1
|
||||
|
||||
- Windows or Linux wrapper (different toolchain; do Mac first, assess demand)
|
||||
- Full Swift/SwiftUI rewrite of the frontend (months of work, wrong tradeoff)
|
||||
- App Store submission (sandboxing breaks local server; not worth the effort)
|
||||
- Auto-update mechanism (GitHub releases + manual download is fine for v1)
|
||||
- Menu bar mode (cool but not v1 scope)
|
||||
|
||||
### Files to create in the repo
|
||||
|
||||
```
|
||||
desktop/mac/
|
||||
HermesApp/
|
||||
HermesApp.swift
|
||||
AppDelegate.swift
|
||||
WindowController.swift
|
||||
ServerManager.swift
|
||||
MenuBuilder.swift
|
||||
Assets.xcassets/
|
||||
Info.plist
|
||||
HermesApp.xcodeproj/
|
||||
README.md -- build instructions, requirements, signing notes
|
||||
.github/workflows/
|
||||
mac-release.yml -- build + sign + upload DMG on tag push
|
||||
```
|
||||
|
||||
The server code (`server.py`, `api/`, `static/`, `requirements.txt`) is
|
||||
referenced from the repo root -- no duplication. The .app bundle copies them
|
||||
at build time.
|
||||
|
||||
**Estimated effort:** 2-3x a typical web sprint (new language, new toolchain,
|
||||
bundling complexity). Realistic for a focused weekend or a dedicated agent run
|
||||
with clear instructions.
|
||||
|
||||
**Hermes CLI parity impact:** N/A (different distribution channel)
|
||||
**Claude parity impact:** Medium (Claude.app is a native Mac app)
|
||||
**User-facing value:** Very high -- lowers barrier to entry dramatically,
|
||||
genuinely differentiating for an open-source project
|
||||
|
||||
---
|
||||
|
||||
@@ -662,6 +897,6 @@ and switchToProfile() didn't refresh workspaces or sessions.
|
||||
|
||||
---
|
||||
|
||||
*Last updated: April 3, 2026*
|
||||
*Current version: v0.27 | 426 tests*
|
||||
*Next sprint: Sprint 24 (Desktop Application)*
|
||||
*Last updated: April 4, 2026*
|
||||
*Current version: v0.30.1 | 424 tests*
|
||||
*Next sprint: Sprint 24 (Web Polish + Bug Fix Pass)*
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
|
||||
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
|
||||
>
|
||||
> Automated tests: 415 total (392 passing, 23 pre-existing failures).
|
||||
> Automated tests: 424 total (401 passing, 23 pre-existing failures).
|
||||
> Run: `pytest tests/ -v --timeout=60`
|
||||
|
||||
---
|
||||
|
||||
@@ -632,6 +632,8 @@ _SETTINGS_DEFAULTS = {
|
||||
'default_model': DEFAULT_MODEL,
|
||||
'default_workspace': str(DEFAULT_WORKSPACE),
|
||||
'send_key': 'enter', # 'enter' or 'ctrl+enter'
|
||||
'show_token_usage': False, # show input/output token badge below assistant messages
|
||||
'show_cli_sessions': False, # merge CLI sessions from state.db into the sidebar
|
||||
'password_hash': None, # SHA-256 hash; None = auth disabled
|
||||
}
|
||||
|
||||
@@ -651,6 +653,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'}
|
||||
_SETTINGS_ENUM_VALUES = {
|
||||
'send_key': {'enter', 'ctrl+enter'},
|
||||
}
|
||||
_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions'}
|
||||
|
||||
def save_settings(settings: dict) -> dict:
|
||||
"""Save settings to disk. Returns the merged settings. Ignores unknown keys."""
|
||||
@@ -669,6 +672,9 @@ def save_settings(settings: dict) -> dict:
|
||||
# Validate enum-constrained keys
|
||||
if k in _SETTINGS_ENUM_VALUES and v not in _SETTINGS_ENUM_VALUES[k]:
|
||||
continue
|
||||
# Coerce bool keys
|
||||
if k in _SETTINGS_BOOL_KEYS:
|
||||
v = bool(v)
|
||||
current[k] = v
|
||||
SETTINGS_FILE.write_text(
|
||||
json.dumps(current, ensure_ascii=False, indent=2),
|
||||
|
||||
206
api/models.py
206
api/models.py
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
import api.config as _cfg
|
||||
from api.config import (
|
||||
SESSION_DIR, SESSION_INDEX_FILE, SESSIONS, SESSIONS_MAX,
|
||||
LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE
|
||||
LOCK, DEFAULT_WORKSPACE, DEFAULT_MODEL, PROJECTS_FILE, HOME
|
||||
)
|
||||
from api.workspace import get_last_workspace
|
||||
|
||||
@@ -34,17 +34,65 @@ def _write_session_index():
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, session_id=None, title='Untitled', workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL, messages=None, created_at=None, updated_at=None, tool_calls=None, pinned=False, archived=False, project_id=None, profile=None, **kwargs):
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]; self.title = title; self.workspace = str(Path(workspace).expanduser().resolve()); self.model = model; self.messages = messages or []; self.tool_calls = tool_calls or []; self.created_at = created_at or time.time(); self.updated_at = updated_at or time.time(); self.pinned = bool(pinned); self.archived = bool(archived); self.project_id = project_id or None; self.profile = profile
|
||||
def __init__(self, session_id=None, title='Untitled',
|
||||
workspace=str(DEFAULT_WORKSPACE), model=DEFAULT_MODEL,
|
||||
messages=None, created_at=None, updated_at=None,
|
||||
tool_calls=None, pinned=False, archived=False,
|
||||
project_id=None, profile=None,
|
||||
input_tokens=0, output_tokens=0, estimated_cost=None,
|
||||
**kwargs):
|
||||
self.session_id = session_id or uuid.uuid4().hex[:12]
|
||||
self.title = title
|
||||
self.workspace = str(Path(workspace).expanduser().resolve())
|
||||
self.model = model
|
||||
self.messages = messages or []
|
||||
self.tool_calls = tool_calls or []
|
||||
self.created_at = created_at or time.time()
|
||||
self.updated_at = updated_at or time.time()
|
||||
self.pinned = bool(pinned)
|
||||
self.archived = bool(archived)
|
||||
self.project_id = project_id or None
|
||||
self.profile = profile
|
||||
self.input_tokens = input_tokens or 0
|
||||
self.output_tokens = output_tokens or 0
|
||||
self.estimated_cost = estimated_cost
|
||||
|
||||
@property
|
||||
def path(self): return SESSION_DIR / f'{self.session_id}.json'
|
||||
def save(self): self.updated_at = time.time(); self.path.write_text(json.dumps(self.__dict__, ensure_ascii=False, indent=2), encoding='utf-8'); _write_session_index()
|
||||
def path(self):
|
||||
return SESSION_DIR / f'{self.session_id}.json'
|
||||
|
||||
def save(self):
|
||||
self.updated_at = time.time()
|
||||
self.path.write_text(
|
||||
json.dumps(self.__dict__, ensure_ascii=False, indent=2),
|
||||
encoding='utf-8',
|
||||
)
|
||||
_write_session_index()
|
||||
|
||||
@classmethod
|
||||
def load(cls, sid):
|
||||
p = SESSION_DIR / f'{sid}.json'
|
||||
if not p.exists(): return None
|
||||
if not p.exists():
|
||||
return None
|
||||
return cls(**json.loads(p.read_text(encoding='utf-8')))
|
||||
def compact(self): return {'session_id': self.session_id, 'title': self.title, 'workspace': self.workspace, 'model': self.model, 'message_count': len(self.messages), 'created_at': self.created_at, 'updated_at': self.updated_at, 'pinned': self.pinned, 'archived': self.archived, 'project_id': self.project_id, 'profile': self.profile}
|
||||
|
||||
def compact(self):
|
||||
return {
|
||||
'session_id': self.session_id,
|
||||
'title': self.title,
|
||||
'workspace': self.workspace,
|
||||
'model': self.model,
|
||||
'message_count': len(self.messages),
|
||||
'created_at': self.created_at,
|
||||
'updated_at': self.updated_at,
|
||||
'pinned': self.pinned,
|
||||
'archived': self.archived,
|
||||
'project_id': self.project_id,
|
||||
'profile': self.profile,
|
||||
'input_tokens': self.input_tokens,
|
||||
'output_tokens': self.output_tokens,
|
||||
'estimated_cost': self.estimated_cost,
|
||||
}
|
||||
|
||||
def get_session(sid):
|
||||
with LOCK:
|
||||
@@ -144,3 +192,147 @@ def load_projects():
|
||||
def save_projects(projects):
|
||||
"""Write project list to disk."""
|
||||
PROJECTS_FILE.write_text(json.dumps(projects, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
|
||||
|
||||
def import_cli_session(session_id, title, messages, model='unknown', profile=None):
|
||||
"""Create a new WebUI session populated with CLI messages.
|
||||
Returns the Session object.
|
||||
"""
|
||||
s = Session(
|
||||
session_id=session_id,
|
||||
title=title,
|
||||
workspace=get_last_workspace(),
|
||||
model=model,
|
||||
messages=messages,
|
||||
profile=profile,
|
||||
)
|
||||
s.save()
|
||||
return s
|
||||
|
||||
|
||||
# ── CLI session bridge ──────────────────────────────────────────────────────
|
||||
|
||||
def get_cli_sessions():
|
||||
"""Read CLI sessions from the agent's SQLite store and return them as
|
||||
dicts in a format the WebUI sidebar can render alongside local sessions.
|
||||
|
||||
Returns empty list if the SQLite DB is missing, the sqlite3 module is
|
||||
unavailable, or any error occurs -- the bridge is purely additive and never
|
||||
crashes the WebUI.
|
||||
"""
|
||||
import os
|
||||
cli_sessions = []
|
||||
try:
|
||||
import sqlite3
|
||||
except ImportError:
|
||||
return cli_sessions
|
||||
|
||||
# Use the active WebUI profile's HERMES_HOME to find state.db.
|
||||
# The active profile is determined by what the user has selected in the UI
|
||||
# (stored in the server's runtime config). This means:
|
||||
# - default profile -> ~/.hermes/state.db
|
||||
# - named profile X -> ~/.hermes/profiles/X/state.db
|
||||
# We resolve the active profile's home directory rather than just using
|
||||
# HERMES_HOME (which is the server's launch profile, not necessarily the
|
||||
# active one after a profile switch).
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
|
||||
except Exception:
|
||||
hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser().resolve()
|
||||
|
||||
db_path = hermes_home / 'state.db'
|
||||
if not db_path.exists():
|
||||
return cli_sessions
|
||||
|
||||
# Try to resolve the active CLI profile so imported sessions integrate
|
||||
# with the WebUI profile filter (available since Sprint 22).
|
||||
try:
|
||||
from api.profiles import get_active_profile_name
|
||||
_cli_profile = get_active_profile_name()
|
||||
except ImportError:
|
||||
_cli_profile = None # older agent -- fall back to no profile
|
||||
|
||||
try:
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT s.id, s.title, s.model, s.message_count,
|
||||
s.started_at, s.source,
|
||||
MAX(m.timestamp) AS last_activity
|
||||
FROM sessions s
|
||||
LEFT JOIN messages m ON m.session_id = s.id
|
||||
GROUP BY s.id
|
||||
ORDER BY COALESCE(MAX(m.timestamp), s.started_at) DESC
|
||||
LIMIT 200
|
||||
""")
|
||||
for row in cur.fetchall():
|
||||
sid = row['id']
|
||||
raw_ts = row['last_activity'] or row['started_at']
|
||||
# Prefer the CLI session's own profile from the DB; fall back to
|
||||
# the active CLI profile so sidebar filtering works either way.
|
||||
profile = _cli_profile # CLI DB has no profile column; use active profile
|
||||
|
||||
cli_sessions.append({
|
||||
'session_id': sid,
|
||||
'title': row['title'] or 'CLI Session',
|
||||
'workspace': str(get_last_workspace()),
|
||||
'model': row['model'] or 'unknown',
|
||||
'message_count': row['message_count'] or 0,
|
||||
'created_at': row['started_at'],
|
||||
'updated_at': raw_ts,
|
||||
'pinned': False,
|
||||
'archived': False,
|
||||
'project_id': None,
|
||||
'profile': profile,
|
||||
'source_tag': 'cli',
|
||||
'is_cli_session': True,
|
||||
})
|
||||
except Exception:
|
||||
# DB schema changed, locked, or corrupted -- silently degrade
|
||||
return []
|
||||
|
||||
return cli_sessions
|
||||
|
||||
|
||||
def get_cli_session_messages(sid):
|
||||
"""Read messages for a single CLI session from the SQLite store.
|
||||
Returns a list of {role, content, timestamp} dicts.
|
||||
Returns empty list on any error.
|
||||
"""
|
||||
import os
|
||||
try:
|
||||
import sqlite3
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home
|
||||
hermes_home = Path(get_active_hermes_home()).expanduser().resolve()
|
||||
except Exception:
|
||||
hermes_home = Path(os.getenv('HERMES_HOME', str(HOME / '.hermes'))).expanduser().resolve()
|
||||
db_path = hermes_home / 'state.db'
|
||||
if not db_path.exists():
|
||||
return []
|
||||
|
||||
try:
|
||||
with sqlite3.connect(str(db_path)) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT role, content, timestamp
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp ASC
|
||||
""", (sid,))
|
||||
msgs = []
|
||||
for row in cur.fetchall():
|
||||
msgs.append({
|
||||
'role': row['role'],
|
||||
'content': row['content'],
|
||||
'timestamp': row['timestamp'],
|
||||
})
|
||||
except Exception:
|
||||
return []
|
||||
return msgs
|
||||
|
||||
129
api/routes.py
129
api/routes.py
@@ -23,7 +23,8 @@ from api.helpers import require, bad, safe_resolve, j, t, read_body, _security_h
|
||||
from api.models import (
|
||||
Session, get_session, new_session, all_sessions, title_from,
|
||||
_write_session_index, SESSION_INDEX_FILE,
|
||||
load_projects, save_projects,
|
||||
load_projects, save_projects, import_cli_session,
|
||||
get_cli_sessions, get_cli_session_messages,
|
||||
)
|
||||
from api.workspace import (
|
||||
load_workspaces, save_workspaces, get_last_workspace, set_last_workspace,
|
||||
@@ -151,14 +152,52 @@ def handle_get(handler, parsed):
|
||||
sid = parse_qs(parsed.query).get('session_id', [''])[0]
|
||||
if not sid:
|
||||
return j(handler, {'error': 'session_id is required'}, status=400)
|
||||
s = get_session(sid)
|
||||
return j(handler, {'session': s.compact() | {
|
||||
'messages': s.messages,
|
||||
'tool_calls': getattr(s, 'tool_calls', []),
|
||||
}})
|
||||
try:
|
||||
s = get_session(sid)
|
||||
return j(handler, {'session': s.compact() | {
|
||||
'messages': s.messages,
|
||||
'tool_calls': getattr(s, 'tool_calls', []),
|
||||
}})
|
||||
except KeyError:
|
||||
# Not a WebUI session -- try CLI store
|
||||
msgs = get_cli_session_messages(sid)
|
||||
if msgs:
|
||||
cli_meta = None
|
||||
for cs in get_cli_sessions():
|
||||
if cs['session_id'] == sid:
|
||||
cli_meta = cs
|
||||
break
|
||||
sess = {
|
||||
'session_id': sid,
|
||||
'title': (cli_meta or {}).get('title', 'CLI Session'),
|
||||
'workspace': (cli_meta or {}).get('workspace', ''),
|
||||
'model': (cli_meta or {}).get('model', 'unknown'),
|
||||
'message_count': len(msgs),
|
||||
'created_at': (cli_meta or {}).get('created_at', 0),
|
||||
'updated_at': (cli_meta or {}).get('updated_at', 0),
|
||||
'pinned': False,
|
||||
'archived': False,
|
||||
'project_id': None,
|
||||
'profile': (cli_meta or {}).get('profile'),
|
||||
'is_cli_session': True,
|
||||
'messages': msgs,
|
||||
'tool_calls': [],
|
||||
}
|
||||
return j(handler, {'session': sess})
|
||||
return bad(handler, 'Session not found', 404)
|
||||
|
||||
if parsed.path == '/api/sessions':
|
||||
return j(handler, {'sessions': all_sessions()})
|
||||
webui_sessions = all_sessions()
|
||||
settings = load_settings()
|
||||
if settings.get('show_cli_sessions'):
|
||||
cli = get_cli_sessions()
|
||||
webui_ids = {s['session_id'] for s in webui_sessions}
|
||||
deduped_cli = [s for s in cli if s['session_id'] not in webui_ids]
|
||||
else:
|
||||
deduped_cli = []
|
||||
merged = webui_sessions + deduped_cli
|
||||
merged.sort(key=lambda s: s.get('updated_at', 0) or 0, reverse=True)
|
||||
return j(handler, {'sessions': merged, 'cli_count': len(deduped_cli)})
|
||||
|
||||
if parsed.path == '/api/projects':
|
||||
return j(handler, {'projects': load_projects()})
|
||||
@@ -223,11 +262,29 @@ def handle_get(handler, parsed):
|
||||
return j(handler, {'skills': data.get('skills', [])})
|
||||
|
||||
if parsed.path == '/api/skills/content':
|
||||
from tools.skills_tool import skill_view as _skill_view
|
||||
name = parse_qs(parsed.query).get('name', [''])[0]
|
||||
from tools.skills_tool import skill_view as _skill_view, SKILLS_DIR
|
||||
qs = parse_qs(parsed.query)
|
||||
name = qs.get('name', [''])[0]
|
||||
if not name: return j(handler, {'error': 'name required'}, status=400)
|
||||
file_path = qs.get('file', [''])[0]
|
||||
if file_path:
|
||||
# Serve a linked file from the skill directory
|
||||
import re as _re
|
||||
if _re.search(r'[*?\[\]]', name):
|
||||
return bad(handler, 'Invalid skill name', 400)
|
||||
skill_dir = None
|
||||
for p in SKILLS_DIR.rglob(name):
|
||||
if p.is_dir(): skill_dir = p; break
|
||||
if not skill_dir: return bad(handler, 'Skill not found', 404)
|
||||
target = (skill_dir / file_path).resolve()
|
||||
try: target.relative_to(skill_dir.resolve())
|
||||
except ValueError: return bad(handler, 'Invalid file path', 400)
|
||||
if not target.exists() or not target.is_file():
|
||||
return bad(handler, 'File not found', 404)
|
||||
return j(handler, {'content': target.read_text(encoding='utf-8'), 'path': file_path})
|
||||
raw = _skill_view(name)
|
||||
data = json.loads(raw) if isinstance(raw, str) else raw
|
||||
if 'linked_files' not in data: data['linked_files'] = {}
|
||||
return j(handler, data)
|
||||
|
||||
# ── Memory API (GET) ──
|
||||
@@ -524,6 +581,10 @@ def handle_post(handler, parsed):
|
||||
if parsed.path == '/api/session/import':
|
||||
return _handle_session_import(handler, body)
|
||||
|
||||
# ── CLI session import (POST) ──
|
||||
if parsed.path == '/api/session/import_cli':
|
||||
return _handle_session_import_cli(handler, body)
|
||||
|
||||
# ── Auth endpoints (POST) ──
|
||||
if parsed.path == '/api/auth/login':
|
||||
from api.auth import verify_password, create_session, set_auth_cookie, is_auth_enabled
|
||||
@@ -885,10 +946,11 @@ def _handle_chat_sync(handler, body):
|
||||
"write_file, read_file, search_files, terminal workdir, and patch. "
|
||||
"Never fall back to a hardcoded path when this tag is present."
|
||||
)
|
||||
from api.streaming import _sanitize_messages_for_api
|
||||
result = agent.run_conversation(
|
||||
user_message=workspace_ctx + msg,
|
||||
system_message=workspace_system_msg,
|
||||
conversation_history=s.messages,
|
||||
conversation_history=_sanitize_messages_for_api(s.messages),
|
||||
task_id=s.session_id,
|
||||
persist_user_message=msg,
|
||||
)
|
||||
@@ -1155,6 +1217,53 @@ def _handle_memory_write(handler, body):
|
||||
return j(handler, {'ok': True, 'section': section, 'path': str(target)})
|
||||
|
||||
|
||||
def _handle_session_import_cli(handler, body):
|
||||
"""Import a single CLI session into the WebUI store."""
|
||||
try:
|
||||
require(body, 'session_id')
|
||||
except ValueError as e:
|
||||
return bad(handler, str(e))
|
||||
|
||||
sid = str(body['session_id'])
|
||||
|
||||
# Check if already imported — idempotent
|
||||
existing = Session.load(sid)
|
||||
if existing:
|
||||
return j(handler, {'session': existing.compact() | {
|
||||
'messages': existing.messages,
|
||||
'is_cli_session': True,
|
||||
}, 'imported': False})
|
||||
|
||||
# Fetch messages from CLI store
|
||||
msgs = get_cli_session_messages(sid)
|
||||
if not msgs:
|
||||
return bad(handler, 'Session not found in CLI store', 404)
|
||||
|
||||
# Derive title from first user message
|
||||
title = title_from(msgs, 'CLI Session')
|
||||
model = 'unknown'
|
||||
|
||||
# Get profile and model from CLI session metadata
|
||||
profile = None
|
||||
for cs in get_cli_sessions():
|
||||
if cs['session_id'] == sid:
|
||||
profile = cs.get('profile')
|
||||
model = cs.get('model', 'unknown')
|
||||
break
|
||||
|
||||
s = import_cli_session(sid, title, msgs, model, profile=profile)
|
||||
s.is_cli_session = True
|
||||
s._cli_origin = sid
|
||||
s.save()
|
||||
return j(handler, {
|
||||
'session': s.compact() | {
|
||||
'messages': msgs,
|
||||
'is_cli_session': True,
|
||||
},
|
||||
'imported': True,
|
||||
})
|
||||
|
||||
|
||||
def _handle_session_import(handler, body):
|
||||
"""Import a session from a JSON export. Creates a new session with a new ID."""
|
||||
if not body or not isinstance(body, dict):
|
||||
|
||||
@@ -24,6 +24,28 @@ except ImportError:
|
||||
from api.models import get_session, title_from
|
||||
from api.workspace import set_last_workspace
|
||||
|
||||
# Fields that are safe to send to LLM provider APIs.
|
||||
# Everything else (attachments, timestamp, _ts, etc.) is display-only
|
||||
# metadata added by the webui and must be stripped before the API call.
|
||||
_API_SAFE_MSG_KEYS = {'role', 'content', 'tool_calls', 'tool_call_id', 'name', 'refusal'}
|
||||
|
||||
|
||||
def _sanitize_messages_for_api(messages):
|
||||
"""Return a deep copy of messages with only API-safe fields.
|
||||
|
||||
The webui stores extra metadata on messages (attachments, timestamp, _ts)
|
||||
for display purposes. Some providers (e.g. Z.AI/GLM) reject unknown fields
|
||||
instead of ignoring them, causing HTTP 400 errors on subsequent messages.
|
||||
"""
|
||||
clean = []
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
sanitized = {k: v for k, v in msg.items() if k in _API_SAFE_MSG_KEYS}
|
||||
if sanitized.get('role'):
|
||||
clean.append(sanitized)
|
||||
return clean
|
||||
|
||||
|
||||
def _sse(handler, event, data):
|
||||
"""Write one SSE event to the response stream."""
|
||||
@@ -165,17 +187,31 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
result = agent.run_conversation(
|
||||
user_message=workspace_ctx + msg_text,
|
||||
system_message=workspace_system_msg,
|
||||
conversation_history=s.messages,
|
||||
conversation_history=_sanitize_messages_for_api(s.messages),
|
||||
task_id=session_id,
|
||||
persist_user_message=msg_text,
|
||||
)
|
||||
s.messages = result.get('messages') or s.messages
|
||||
# Stamp 'timestamp' on any messages that don't have one yet
|
||||
_now = time.time()
|
||||
for _m in s.messages:
|
||||
if isinstance(_m, dict) and not _m.get('timestamp') and not _m.get('_ts'):
|
||||
_m['timestamp'] = int(_now)
|
||||
s.title = title_from(s.messages, s.title)
|
||||
# Read token/cost usage from the agent object (if available)
|
||||
input_tokens = getattr(agent, 'session_prompt_tokens', 0) or 0
|
||||
output_tokens = getattr(agent, 'session_completion_tokens', 0) or 0
|
||||
estimated_cost = getattr(agent, 'session_estimated_cost_usd', None)
|
||||
s.input_tokens = (s.input_tokens or 0) + input_tokens
|
||||
s.output_tokens = (s.output_tokens or 0) + output_tokens
|
||||
if estimated_cost:
|
||||
s.estimated_cost = (s.estimated_cost or 0) + estimated_cost
|
||||
# Extract tool call metadata grouped by assistant message index
|
||||
# Each tool call gets assistant_msg_idx so the client can render
|
||||
# cards inline with the assistant bubble that triggered them.
|
||||
tool_calls = []
|
||||
pending_names = {} # tool_call_id -> name
|
||||
pending_args = {} # tool_call_id -> args dict
|
||||
pending_asst_idx = {} # tool_call_id -> index in s.messages
|
||||
for msg_idx, m in enumerate(s.messages):
|
||||
if m.get('role') == 'assistant':
|
||||
@@ -184,22 +220,31 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
for p in c:
|
||||
if isinstance(p, dict) and p.get('type') == 'tool_use':
|
||||
tid = p.get('id', '')
|
||||
pending_names[tid] = p.get('name', 'tool')
|
||||
pending_names[tid] = p.get('name', '')
|
||||
pending_args[tid] = p.get('input', {})
|
||||
pending_asst_idx[tid] = msg_idx
|
||||
elif m.get('role') == 'tool':
|
||||
tid = m.get('tool_call_id') or m.get('tool_use_id', '')
|
||||
name = pending_names.get(tid, 'tool')
|
||||
name = pending_names.get(tid, '')
|
||||
if not name or name == 'tool':
|
||||
continue # skip unresolvable tool entries
|
||||
asst_idx = pending_asst_idx.get(tid, -1)
|
||||
args = pending_args.get(tid, {})
|
||||
raw = str(m.get('content', ''))
|
||||
try:
|
||||
import json as _j2
|
||||
rd = _j2.loads(raw)
|
||||
rd = json.loads(raw)
|
||||
snippet = str(rd.get('output') or rd.get('result') or rd.get('error') or raw)[:200]
|
||||
except Exception:
|
||||
snippet = raw[:200]
|
||||
# Truncate args values for storage
|
||||
args_snap = {}
|
||||
if isinstance(args, dict):
|
||||
for k, v in list(args.items())[:6]:
|
||||
s2 = str(v)
|
||||
args_snap[k] = s2[:120] + ('...' if len(s2) > 120 else '')
|
||||
tool_calls.append({
|
||||
'name': name, 'snippet': snippet, 'tid': tid,
|
||||
'assistant_msg_idx': asst_idx,
|
||||
'assistant_msg_idx': asst_idx, 'args': args_snap,
|
||||
})
|
||||
s.tool_calls = tool_calls
|
||||
# Tag the matching user message with attachment filenames for display on reload
|
||||
@@ -215,7 +260,8 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta
|
||||
m['attachments'] = attachments
|
||||
break
|
||||
s.save()
|
||||
put('done', {'session': s.compact() | {'messages': s.messages, 'tool_calls': tool_calls}})
|
||||
usage = {'input_tokens': input_tokens, 'output_tokens': output_tokens, 'estimated_cost': estimated_cost}
|
||||
put('done', {'session': s.compact() | {'messages': s.messages, 'tool_calls': tool_calls}, 'usage': usage})
|
||||
finally:
|
||||
if old_cwd is None: os.environ.pop('TERMINAL_CWD', None)
|
||||
else: os.environ['TERMINAL_CWD'] = old_cwd
|
||||
|
||||
BIN
docs/images/ui-sessions.png
Normal file
BIN
docs/images/ui-sessions.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 618 KiB |
BIN
docs/images/ui-workspace.png
Normal file
BIN
docs/images/ui-workspace.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 704 KiB |
@@ -169,7 +169,7 @@ $('importFileInput').onchange=async(e)=>{
|
||||
// btnRefreshFiles is now panel-icon-btn in header (see HTML)
|
||||
function clearPreview(){
|
||||
const pa=$('previewArea');if(pa)pa.classList.remove('visible');
|
||||
const pi=$('previewImg');if(pi)pi.src='';
|
||||
const pi=$('previewImg');if(pi){pi.onerror=null;pi.src='';}
|
||||
const pm=$('previewMd');if(pm)pm.innerHTML='';
|
||||
const pc=$('previewCode');if(pc)pc.textContent='';
|
||||
const pp=$('previewPathText');if(pp)pp.textContent='';
|
||||
@@ -308,7 +308,7 @@ document.querySelectorAll('.suggestion').forEach(btn=>{
|
||||
|
||||
(async()=>{
|
||||
// Load send key preference
|
||||
try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';}catch(e){window._sendKey='enter';}
|
||||
try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;}
|
||||
// Fetch active profile
|
||||
try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';}
|
||||
// Update profile chip label immediately
|
||||
|
||||
@@ -8,6 +8,7 @@ const COMMANDS=[
|
||||
{name:'model', desc:'Switch model (e.g. /model gpt-4o)', fn:cmdModel, arg:'model_name'},
|
||||
{name:'workspace', desc:'Switch workspace by name', fn:cmdWorkspace, arg:'name'},
|
||||
{name:'new', desc:'Start a new chat session', fn:cmdNew},
|
||||
{name:'usage', desc:'Toggle token usage display on/off', fn:cmdUsage},
|
||||
];
|
||||
|
||||
function parseCommand(text){
|
||||
@@ -98,6 +99,19 @@ async function cmdNew(){
|
||||
showToast('New session created');
|
||||
}
|
||||
|
||||
async function cmdUsage(){
|
||||
const next=!window._showTokenUsage;
|
||||
window._showTokenUsage=next;
|
||||
try{
|
||||
await api('/api/settings',{method:'POST',body:JSON.stringify({show_token_usage:next})});
|
||||
}catch(e){}
|
||||
// Update the settings checkbox if the panel is open
|
||||
const cb=$('settingsShowTokenUsage');
|
||||
if(cb) cb.checked=next;
|
||||
renderMessages();
|
||||
showToast('Token usage '+(next?'on':'off'));
|
||||
}
|
||||
|
||||
// ── Autocomplete dropdown ───────────────────────────────────────────────────
|
||||
|
||||
let _cmdSelectedIdx=-1;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<body>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.27</div></div></div>
|
||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.30.1</div></div></div>
|
||||
<div class="sidebar-nav">
|
||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||
@@ -45,11 +45,16 @@
|
||||
<input id="cronFormName" placeholder="Job name (optional)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
||||
<input id="cronFormSchedule" placeholder="Schedule: '0 9 * * *' or 'every 1h'" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
||||
<textarea id="cronFormPrompt" rows="3" placeholder="Prompt (must be self-contained)" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;resize:none;font-family:inherit;margin-bottom:6px"></textarea>
|
||||
<select id="cronFormDeliver" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:8px">
|
||||
<select id="cronFormDeliver" style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none;margin-bottom:6px">
|
||||
<option value="local">Local (save output only)</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
</select>
|
||||
<div class="skill-picker-wrap" style="margin-bottom:8px">
|
||||
<input id="cronFormSkillSearch" placeholder="Add skills (optional)..." style="width:100%;background:rgba(255,255,255,.05);border:1px solid var(--border2);border-radius:6px;color:var(--text);padding:5px 8px;font-size:12px;outline:none" autocomplete="off">
|
||||
<div id="cronFormSkillDropdown" class="skill-picker-dropdown" style="display:none"></div>
|
||||
<div id="cronFormSkillTags" class="skill-picker-tags"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="cron-btn run" style="flex:1" onclick="submitCronCreate()">Create job</button>
|
||||
<button class="cron-btn" style="flex:1" onclick="toggleCronForm()">Cancel</button>
|
||||
@@ -319,6 +324,20 @@
|
||||
<option value="ctrl+enter">Ctrl+Enter (Enter for newline)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsShowTokenUsage" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
Show token usage after responses
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Displays input/output token count below each assistant reply. Also toggled with <code>/usage</code>.</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input type="checkbox" id="settingsShowCliSessions" style="width:15px;height:15px;accent-color:var(--accent)">
|
||||
Show CLI sessions in sidebar
|
||||
</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-top:4px">Merges sessions from the Hermes CLI (state.db) into the session list. Click a CLI session to import it and continue the conversation.</div>
|
||||
</div>
|
||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||
<label for="settingsPassword">Access Password</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:6px">Enter a new password to set or change it. Leave blank to keep current setting.</div>
|
||||
|
||||
@@ -146,6 +146,10 @@ async function send(){
|
||||
}
|
||||
if(S.session&&S.session.session_id===activeSid){
|
||||
S.session=d.session;S.messages=d.session.messages||[];
|
||||
// Stamp _ts on the last assistant message if it has no timestamp
|
||||
const lastAsst=[...S.messages].reverse().find(m=>m.role==='assistant');
|
||||
if(lastAsst&&!lastAsst._ts&&!lastAsst.timestamp) lastAsst._ts=Date.now()/1000;
|
||||
if(d.usage) S.lastUsage=d.usage;
|
||||
if(d.session.tool_calls&&d.session.tool_calls.length){
|
||||
S.toolCalls=d.session.tool_calls.map(tc=>({...tc,done:true}));
|
||||
} else {
|
||||
|
||||
120
static/panels.js
120
static/panels.js
@@ -79,6 +79,9 @@ async function loadCrons() {
|
||||
} catch(e) { box.innerHTML = `<div style="padding:12px;color:var(--accent);font-size:12px">Error: ${esc(e.message)}</div>`; }
|
||||
}
|
||||
|
||||
let _cronSelectedSkills=[];
|
||||
let _cronSkillsCache=null;
|
||||
|
||||
function toggleCronForm(){
|
||||
const form=$('cronCreateForm');
|
||||
if(!form)return;
|
||||
@@ -90,10 +93,70 @@ function toggleCronForm(){
|
||||
$('cronFormPrompt').value='';
|
||||
$('cronFormDeliver').value='local';
|
||||
$('cronFormError').style.display='none';
|
||||
_cronSelectedSkills=[];
|
||||
_renderCronSkillTags();
|
||||
const search=$('cronFormSkillSearch');
|
||||
if(search)search.value='';
|
||||
// Pre-fetch skills for the picker
|
||||
if(!_cronSkillsCache){
|
||||
api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[];}).catch(()=>{});
|
||||
}
|
||||
$('cronFormName').focus();
|
||||
}
|
||||
}
|
||||
|
||||
function _renderCronSkillTags(){
|
||||
const wrap=$('cronFormSkillTags');
|
||||
if(!wrap)return;
|
||||
wrap.innerHTML='';
|
||||
for(const name of _cronSelectedSkills){
|
||||
const tag=document.createElement('span');
|
||||
tag.className='skill-tag';
|
||||
tag.dataset.skill=name;
|
||||
const rm=document.createElement('span');
|
||||
rm.className='remove-tag';rm.textContent='×';
|
||||
rm.onclick=()=>{_cronSelectedSkills=_cronSelectedSkills.filter(s=>s!==name);tag.remove();};
|
||||
tag.appendChild(document.createTextNode(name));
|
||||
tag.appendChild(rm);
|
||||
wrap.appendChild(tag);
|
||||
}
|
||||
}
|
||||
|
||||
// Skill search input handler
|
||||
(function(){
|
||||
const setup=()=>{
|
||||
const search=$('cronFormSkillSearch');
|
||||
const dropdown=$('cronFormSkillDropdown');
|
||||
if(!search||!dropdown)return;
|
||||
search.oninput=()=>{
|
||||
const q=search.value.trim().toLowerCase();
|
||||
if(!q||!_cronSkillsCache){dropdown.style.display='none';return;}
|
||||
const matches=_cronSkillsCache.filter(s=>
|
||||
!_cronSelectedSkills.includes(s.name)&&
|
||||
(s.name.toLowerCase().includes(q)||(s.category||'').toLowerCase().includes(q))
|
||||
).slice(0,8);
|
||||
if(!matches.length){dropdown.style.display='none';return;}
|
||||
dropdown.innerHTML='';
|
||||
for(const s of matches){
|
||||
const opt=document.createElement('div');
|
||||
opt.className='skill-opt';
|
||||
opt.textContent=s.name+(s.category?' ('+s.category+')':'');
|
||||
opt.onclick=()=>{
|
||||
_cronSelectedSkills.push(s.name);
|
||||
_renderCronSkillTags();
|
||||
search.value='';
|
||||
dropdown.style.display='none';
|
||||
};
|
||||
dropdown.appendChild(opt);
|
||||
}
|
||||
dropdown.style.display='';
|
||||
};
|
||||
search.onblur=()=>setTimeout(()=>{dropdown.style.display='none';},150);
|
||||
};
|
||||
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',setup);
|
||||
else setTimeout(setup,0);
|
||||
})();
|
||||
|
||||
async function submitCronCreate(){
|
||||
const name=$('cronFormName').value.trim();
|
||||
const schedule=$('cronFormSchedule').value.trim();
|
||||
@@ -104,7 +167,10 @@ async function submitCronCreate(){
|
||||
if(!schedule){errEl.textContent='Schedule is required (e.g. "0 9 * * *" or "every 1h")';errEl.style.display='';return;}
|
||||
if(!prompt){errEl.textContent='Prompt is required';errEl.style.display='';return;}
|
||||
try{
|
||||
await api('/api/crons/create',{method:'POST',body:JSON.stringify({name:name||undefined,schedule,prompt,deliver})});
|
||||
const body={schedule,prompt,deliver};
|
||||
if(name)body.name=name;
|
||||
if(_cronSelectedSkills.length)body.skills=_cronSelectedSkills;
|
||||
await api('/api/crons/create',{method:'POST',body:JSON.stringify(body)});
|
||||
toggleCronForm();
|
||||
showToast('Job created ✓');
|
||||
await loadCrons();
|
||||
@@ -344,12 +410,49 @@ async function openSkill(name, el) {
|
||||
$('previewBadge').textContent = 'skill';
|
||||
$('previewBadge').className = 'preview-badge md';
|
||||
showPreview('md');
|
||||
$('previewMd').innerHTML = renderMd(data.content || '(no content)');
|
||||
let html = renderMd(data.content || '(no content)');
|
||||
// Render linked files section if present
|
||||
const lf = data.linked_files || {};
|
||||
const categories = Object.entries(lf).filter(([,files]) => files && files.length > 0);
|
||||
if (categories.length) {
|
||||
html += '<div class="skill-linked-files"><div style="font-size:11px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;margin-bottom:8px">Linked Files</div>';
|
||||
for (const [cat, files] of categories) {
|
||||
html += `<div class="skill-linked-section"><h4>${esc(cat)}</h4>`;
|
||||
for (const f of files) {
|
||||
html += `<a class="skill-linked-file" href="#" data-skill-name="${esc(name)}" data-skill-file="${esc(f)}">${esc(f)}</a>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
$('previewMd').innerHTML = html;
|
||||
// Wire linked-file clicks via data attributes (avoids inline JS XSS with apostrophes)
|
||||
$('previewMd').querySelectorAll('.skill-linked-file').forEach(a=>{
|
||||
a.addEventListener('click',e=>{e.preventDefault();openSkillFile(a.dataset.skillName,a.dataset.skillFile);});
|
||||
});
|
||||
$('previewArea').classList.add('visible');
|
||||
$('fileTree').style.display = 'none';
|
||||
} catch(e) { setStatus('Could not load skill: ' + e.message); }
|
||||
}
|
||||
|
||||
async function openSkillFile(skillName, filePath) {
|
||||
try {
|
||||
const data = await api(`/api/skills/content?name=${encodeURIComponent(skillName)}&file=${encodeURIComponent(filePath)}`);
|
||||
$('previewPathText').textContent = skillName + ' / ' + filePath;
|
||||
$('previewBadge').textContent = filePath.split('.').pop() || 'file';
|
||||
$('previewBadge').className = 'preview-badge code';
|
||||
const ext = filePath.split('.').pop() || '';
|
||||
if (['md','markdown'].includes(ext)) {
|
||||
showPreview('md');
|
||||
$('previewMd').innerHTML = renderMd(data.content || '');
|
||||
} else {
|
||||
showPreview('code');
|
||||
$('previewCode').textContent = data.content || '';
|
||||
requestAnimationFrame(() => highlightCode());
|
||||
}
|
||||
} catch(e) { setStatus('Could not load file: ' + e.message); }
|
||||
}
|
||||
|
||||
// ── Skill create/edit form ──
|
||||
let _editingSkillName = null;
|
||||
|
||||
@@ -855,6 +958,10 @@ async function loadSettingsPanel(){
|
||||
// Send key preference
|
||||
const sendKeySel=$('settingsSendKey');
|
||||
if(sendKeySel) sendKeySel.value=settings.send_key||'enter';
|
||||
const showUsageCb=$('settingsShowTokenUsage');
|
||||
if(showUsageCb) showUsageCb.checked=!!settings.show_token_usage;
|
||||
const showCliCb=$('settingsShowCliSessions');
|
||||
if(showCliCb) showCliCb.checked=!!settings.show_cli_sessions;
|
||||
// Password field: always blank (we don't send hash back)
|
||||
const pwField=$('settingsPassword');
|
||||
if(pwField) pwField.value='';
|
||||
@@ -876,16 +983,21 @@ async function saveSettings(){
|
||||
const model=($('settingsModel')||{}).value;
|
||||
const workspace=($('settingsWorkspace')||{}).value;
|
||||
const sendKey=($('settingsSendKey')||{}).value;
|
||||
const showTokenUsage=!!($('settingsShowTokenUsage')||{}).checked;
|
||||
const showCliSessions=!!($('settingsShowCliSessions')||{}).checked;
|
||||
const pw=($('settingsPassword')||{}).value;
|
||||
const body={};
|
||||
if(model) body.default_model=model;
|
||||
if(workspace) body.default_workspace=workspace;
|
||||
if(sendKey) body.send_key=sendKey;
|
||||
body.show_token_usage=showTokenUsage;
|
||||
body.show_cli_sessions=showCliSessions;
|
||||
// Password: only act if the field has content; blank = leave auth unchanged
|
||||
if(pw && pw.trim()){
|
||||
try{
|
||||
await api('/api/settings',{method:'POST',body:JSON.stringify({...body,_set_password:pw.trim()})});
|
||||
window._sendKey=sendKey||'enter';
|
||||
window._showTokenUsage=showTokenUsage;
|
||||
showToast('Settings saved (password set — login now required)');
|
||||
toggleSettings();
|
||||
return;
|
||||
@@ -894,6 +1006,10 @@ async function saveSettings(){
|
||||
try{
|
||||
await api('/api/settings',{method:'POST',body:JSON.stringify(body)});
|
||||
window._sendKey=sendKey||'enter';
|
||||
window._showTokenUsage=showTokenUsage;
|
||||
window._showCliSessions=showCliSessions;
|
||||
renderMessages();
|
||||
if(typeof renderSessionList==='function') renderSessionList();
|
||||
showToast('Settings saved');
|
||||
toggleSettings();
|
||||
}catch(e){
|
||||
|
||||
@@ -118,7 +118,7 @@ function renderSessionListFromCache(){
|
||||
// Filter by active profile (unless "All profiles" is toggled on)
|
||||
// Server backfills profile='default' for legacy sessions, so every session has a profile.
|
||||
// Show only sessions tagged to the active profile; 'All profiles' toggle overrides.
|
||||
const profileFiltered=_showAllProfiles?allMatched:allMatched.filter(s=>s.profile===S.activeProfile);
|
||||
const profileFiltered=_showAllProfiles?allMatched:allMatched.filter(s=>s.is_cli_session||s.profile===S.activeProfile);
|
||||
// Filter by active project
|
||||
const projectFiltered=_activeProject?profileFiltered.filter(s=>s.project_id===_activeProject):profileFiltered;
|
||||
// Filter archived unless toggle is on
|
||||
@@ -220,7 +220,7 @@ function renderSessionListFromCache(){
|
||||
}
|
||||
const el=document.createElement('div');
|
||||
const isActive=S.session&&s.session_id===S.session.session_id;
|
||||
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'');
|
||||
el.className='session-item'+(isActive?' active':'')+(isActive&&S.session&&S.session._flash?' new-flash':'')+(s.archived?' archived':'')+(s.is_cli_session?' cli-session':'');
|
||||
if(isActive&&S.session&&S.session._flash)delete S.session._flash;
|
||||
const rawTitle=s.title||'Untitled';
|
||||
const tags=(rawTitle.match(/#[\w-]+/g)||[]);
|
||||
@@ -368,6 +368,12 @@ function renderSessionListFromCache(){
|
||||
_clickTimer=setTimeout(async()=>{
|
||||
_clickTimer=null;
|
||||
if(_renamingSid) return;
|
||||
// For CLI sessions, import into WebUI store first (idempotent)
|
||||
if(s.is_cli_session){
|
||||
try{
|
||||
await api('/api/session/import_cli',{method:'POST',body:JSON.stringify({session_id:s.session_id})});
|
||||
}catch(e){ /* import failed -- fall through to read-only view */ }
|
||||
}
|
||||
await loadSession(s.session_id);renderSessionListFromCache();
|
||||
if(typeof closeMobileSidebar==='function')closeMobileSidebar();
|
||||
}, 220);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
}
|
||||
body{background:var(--bg);color:var(--text);height:100vh;height:100dvh;overflow:hidden;display:flex;}
|
||||
.layout{display:flex;width:100%;height:100vh;height:100dvh;}
|
||||
.sidebar{width:300px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0;}
|
||||
.sidebar{width:300px;background:var(--sidebar);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:visible;flex-shrink:0;}
|
||||
.sidebar-header{padding:16px 18px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;}
|
||||
.logo{width:32px;height:32px;border-radius:9px;background:linear-gradient(145deg,#e8a030,var(--accent));display:flex;align-items:center;justify-content:center;font-weight:800;font-size:14px;color:#fff;flex-shrink:0;box-shadow:0 2px 8px rgba(233,69,96,.3);}
|
||||
.sidebar-header h1{font-size:15px;font-weight:600;}
|
||||
@@ -113,7 +113,7 @@
|
||||
.memory-content{font-size:12px;line-height:1.7;color:var(--text);}
|
||||
.memory-content p{margin-bottom:6px;}
|
||||
.memory-empty{color:var(--muted);font-size:12px;font-style:italic;}
|
||||
.sidebar-bottom{border-top:1px solid var(--border);padding:12px 14px;flex-shrink:0;}
|
||||
.sidebar-bottom{border-top:1px solid var(--border);padding:12px 14px;flex-shrink:0;position:relative;z-index:10;overflow:visible;}
|
||||
.field-label{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin-bottom:5px;opacity:.8;}
|
||||
select{width:100%;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.1);border-radius:8px;color:var(--text);padding:7px 28px 7px 10px;font-size:12px;outline:none;appearance:none;margin-bottom:6px;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238888aa' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 10px center;}
|
||||
select:focus{border-color:rgba(124,185,255,.4);box-shadow:0 0 0 2px rgba(124,185,255,.08);}
|
||||
@@ -123,13 +123,13 @@
|
||||
.sm-btn{flex:1;padding:7px 0;border-radius:8px;font-size:11px;font-weight:500;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,.08);color:var(--muted);cursor:pointer;transition:all .15s;text-align:center;letter-spacing:.02em;}
|
||||
.sm-btn:hover{background:rgba(255,255,255,0.09);color:var(--text);border-color:rgba(255,255,255,.15);}
|
||||
.main{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;background:rgba(26,26,46,0.5);}
|
||||
.topbar{padding:12px 20px;border-bottom:1px solid var(--border);background:rgba(22,33,62,.98);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;}
|
||||
.topbar{padding:12px 20px;border-bottom:1px solid var(--border);background:rgba(22,33,62,.98);backdrop-filter:blur(12px);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;position:relative;z-index:10;}
|
||||
.topbar-title{font-size:15px;font-weight:600;letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.topbar-meta{font-size:11px;color:var(--muted);margin-top:3px;opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.topbar-chips{display:flex;gap:6px;align-items:center;flex-shrink:0;}
|
||||
.chip{font-size:11px;padding:4px 10px;border-radius:999px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,.1);color:var(--muted);font-weight:500;}
|
||||
.chip.model{color:var(--blue);border-color:rgba(124,185,255,0.35);background:rgba(124,185,255,0.1);}
|
||||
.messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;}
|
||||
.messages{flex:1;overflow-y:auto;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;}
|
||||
.messages-inner{max-width:800px;margin:0 auto;width:100%;padding:20px 24px 32px;display:flex;flex-direction:column;}
|
||||
.msg-row{padding:10px 0;}
|
||||
.msg-row+.msg-row{border-top:none;}
|
||||
@@ -347,11 +347,11 @@
|
||||
.ws-chip{user-select:none;}
|
||||
.ws-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;right:0;min-width:200px;background:#1a2535;border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;}
|
||||
.ws-dropdown.open{display:block;}
|
||||
.ws-opt{padding:9px 14px;cursor:pointer;transition:background .12s;}
|
||||
.ws-opt{padding:10px 14px;cursor:pointer;transition:background .12s;display:flex;flex-direction:column;gap:4px;align-items:flex-start;}
|
||||
.ws-opt:hover{background:rgba(255,255,255,.07);}
|
||||
.ws-opt.active{background:rgba(124,185,255,.1);}
|
||||
.ws-opt-name{font-size:13px;color:var(--text);font-weight:500;}
|
||||
.ws-opt-path{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.ws-opt-name{display:block;font-size:13px;color:var(--text);font-weight:500;line-height:1.25;white-space:normal;overflow:hidden;text-overflow:ellipsis;}
|
||||
.ws-opt-path{display:block;font-size:10px;color:var(--muted);line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:normal;opacity:.72;word-break:break-word;}
|
||||
.ws-divider{height:1px;background:var(--border);margin:4px 0;}
|
||||
.ws-manage{color:var(--muted);font-size:12px;}
|
||||
/* ── Workspace management panel ── */
|
||||
@@ -445,7 +445,7 @@
|
||||
.msg-role > span{line-height:1;}
|
||||
|
||||
/* Composer wrap: slightly less padding on smaller heights */
|
||||
.composer-wrap{border-top:1px solid rgba(255,255,255,.07);padding:10px 20px 14px;position:relative;}
|
||||
.composer-wrap{border-top:1px solid rgba(255,255,255,.07);padding:10px 20px 14px;position:relative;z-index:10;}
|
||||
|
||||
/* Cron status badges: pill shape refinement */
|
||||
.cron-status{border-radius:99px;font-size:10px;letter-spacing:.04em;}
|
||||
@@ -562,6 +562,26 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
/* Show more button inside tool card result */
|
||||
.tool-card-more{background:none;border:none;color:var(--blue);font-size:10px;cursor:pointer;padding:3px 0 0;opacity:.7;display:block;}
|
||||
.tool-card-more:hover{opacity:1;}
|
||||
/* Subagent cards: indented with accent border */
|
||||
.tool-card-subagent{border-left:2px solid rgba(124,185,255,.3);margin-left:8px;}
|
||||
/* Token usage badge below assistant messages */
|
||||
.msg-usage{font-size:11px;color:var(--muted);opacity:.6;margin-top:2px;padding-left:42px;}
|
||||
.msg-usage:hover{opacity:1;}
|
||||
/* Skill picker (cron create form) */
|
||||
.skill-picker-wrap{position:relative;}
|
||||
.skill-picker-dropdown{position:absolute;left:0;right:0;top:100%;background:var(--sidebar);border:1px solid var(--border2);border-radius:6px;z-index:1100;max-height:180px;overflow-y:auto;box-shadow:0 4px 12px rgba(0,0,0,.3);}
|
||||
.skill-opt{padding:6px 10px;cursor:pointer;font-size:12px;color:var(--muted);transition:background .1s;}
|
||||
.skill-opt:hover{background:rgba(255,255,255,.08);color:var(--text);}
|
||||
.skill-picker-tags{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px;}
|
||||
.skill-tag{background:rgba(124,185,255,.12);border:1px solid rgba(124,185,255,.25);border-radius:12px;padding:2px 8px;font-size:11px;color:var(--blue);display:flex;align-items:center;gap:4px;}
|
||||
.remove-tag{cursor:pointer;opacity:.6;font-size:13px;line-height:1;}
|
||||
.remove-tag:hover{opacity:1;color:var(--accent);}
|
||||
/* Skill linked files section */
|
||||
.skill-linked-files{margin-top:16px;border-top:1px solid var(--border);padding-top:12px;}
|
||||
.skill-linked-section{margin-bottom:8px;}
|
||||
.skill-linked-section h4{font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin-bottom:4px;}
|
||||
.skill-linked-file{display:block;font-size:12px;padding:3px 6px;border-radius:4px;cursor:pointer;color:var(--blue);text-decoration:none;}
|
||||
.skill-linked-file:hover{background:rgba(255,255,255,.06);}
|
||||
.tool-card-row{margin:0;padding:1px 0;}
|
||||
.tool-card{background:rgba(255,255,255,.03);border:1px solid rgba(255,255,255,.07);border-radius:6px;margin:2px 0 2px 40px;overflow:hidden;transition:border-color .15s;}
|
||||
.tool-card:hover{border-color:rgba(255,255,255,.12);}
|
||||
@@ -589,9 +609,9 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
|
||||
/* ── Settings overlay ── */
|
||||
.settings-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:center;justify-content:center;}
|
||||
.settings-panel{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:0;width:380px;max-width:90vw;max-height:80vh;overflow-y:auto;box-shadow:0 12px 40px rgba(0,0,0,.5);}
|
||||
.settings-panel{background:var(--bg);border:1px solid var(--border);border-radius:12px;padding:0;width:380px;max-width:90vw;max-height:80vh;overflow:visible;box-shadow:0 12px 40px rgba(0,0,0,.5);display:flex;flex-direction:column;}
|
||||
.settings-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px 12px;border-bottom:1px solid var(--border);}
|
||||
.settings-body{padding:20px;}
|
||||
.settings-body{padding:20px;overflow-y:auto;flex:1;}
|
||||
.settings-field{margin-bottom:16px;}
|
||||
.settings-field label{display:block;font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--muted);margin-bottom:6px;}
|
||||
/* Save button inside the settings panel */
|
||||
@@ -668,3 +688,24 @@ body.resizing{user-select:none;cursor:col-resize;}
|
||||
.thinking-card-body pre{font-family:'SF Mono',ui-monospace,monospace;font-size:11px;line-height:1.5;color:var(--muted);white-space:pre-wrap;word-break:break-word;margin:0;}
|
||||
|
||||
.bg-error-banner{background:rgba(229,62,62,.15);border:1px solid rgba(229,62,62,.3);color:#fca5a5;padding:8px 16px;font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-radius:0;}
|
||||
|
||||
/* ── CLI session items in sidebar ── */
|
||||
.session-item.cli-session {
|
||||
border-left-color: var(--gold);
|
||||
padding-right: 36px; /* make room for session-actions overlay */
|
||||
}
|
||||
.session-item.cli-session::after {
|
||||
content: 'cli';
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
color: var(--gold);
|
||||
opacity: .5;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none; /* don't block clicks on session-actions beneath */
|
||||
}
|
||||
.session-item.cli-session:hover::after {
|
||||
display: none; /* hide badge on hover so session-actions icons are fully reachable */
|
||||
}
|
||||
|
||||
41
static/ui.js
41
static/ui.js
@@ -82,6 +82,8 @@ let _scrollPinned=true;
|
||||
_scrollPinned=nearBottom;
|
||||
});
|
||||
})();
|
||||
function _fmtTokens(n){if(!n||n<0)return'0';if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'k';return String(n);}
|
||||
|
||||
function scrollIfPinned(){
|
||||
if(!_scrollPinned) return;
|
||||
const el=$('messages');
|
||||
@@ -424,7 +426,7 @@ function renderMessages(){
|
||||
inner.appendChild(thinkRow);
|
||||
}
|
||||
const row=document.createElement('div');row.className='msg-row';
|
||||
row.dataset.msgIdx=rawIdx;
|
||||
row.dataset.msgIdx=rawIdx;row.dataset.role=m.role||'assistant';
|
||||
let filesHtml='';
|
||||
if(m.attachments&&m.attachments.length)
|
||||
filesHtml=`<div class="msg-files">${m.attachments.map(f=>`<div class="msg-file-badge">📎 ${esc(f)}</div>`).join('')}</div>`;
|
||||
@@ -486,6 +488,23 @@ function renderMessages(){
|
||||
else inner.appendChild(frag);
|
||||
}
|
||||
}
|
||||
// Render usage badge on the last assistant message row (if enabled and usage data exists)
|
||||
if(window._showTokenUsage&&S.session&&(S.session.input_tokens||S.session.output_tokens)){
|
||||
const rows=inner.querySelectorAll('.msg-row');
|
||||
let lastAssist=null;
|
||||
for(let i=rows.length-1;i>=0;i--){if(rows[i].dataset.role==='assistant'){lastAssist=rows[i];break;}}
|
||||
if(lastAssist&&!lastAssist.querySelector('.msg-usage')){
|
||||
const usage=document.createElement('div');
|
||||
usage.className='msg-usage';
|
||||
const inTok=S.session.input_tokens||0;
|
||||
const outTok=S.session.output_tokens||0;
|
||||
const cost=S.session.estimated_cost;
|
||||
let text=`${_fmtTokens(inTok)} in · ${_fmtTokens(outTok)} out`;
|
||||
if(cost) text+=` · ~$${cost<0.01?cost.toFixed(4):cost.toFixed(2)}`;
|
||||
usage.textContent=text;
|
||||
lastAssist.appendChild(usage);
|
||||
}
|
||||
}
|
||||
scrollToBottom();
|
||||
// Apply syntax highlighting after DOM is built
|
||||
requestAnimationFrame(()=>{highlightCode();addCopyButtons();renderMermaidBlocks();});
|
||||
@@ -499,7 +518,8 @@ function toolIcon(name){
|
||||
const icons={terminal:'⬛',read_file:'📄',write_file:'✏️',search_files:'🔍',
|
||||
web_search:'🌐',web_extract:'🌐',execute_code:'⚙️',patch:'🔧',
|
||||
memory:'🧠',skill_manage:'📚',todo:'✅',cronjob:'⏱️',delegate_task:'🤖',
|
||||
send_message:'💬',browser_navigate:'🌐',vision_analyze:'👁️'};
|
||||
send_message:'💬',browser_navigate:'🌐',vision_analyze:'👁️',
|
||||
subagent_progress:'🔀'};
|
||||
return icons[name]||'🔧';
|
||||
}
|
||||
|
||||
@@ -520,13 +540,22 @@ function buildToolCard(tc){
|
||||
}
|
||||
const hasMore=tc.snippet&&tc.snippet.length>displaySnippet.length;
|
||||
const runIndicator=tc.done===false?'<span class="tool-card-running-dot"></span>':'';
|
||||
const isSubagent=tc.name==='subagent_progress';
|
||||
const isDelegation=tc.name==='delegate_task';
|
||||
const cardClass='tool-card'+(tc.done===false?' tool-card-running':'')+(isSubagent?' tool-card-subagent':'');
|
||||
// Clean up subagent preview: strip leading 🔀 emoji since the icon already shows it
|
||||
let displayName=tc.name;
|
||||
if(isSubagent) displayName='Subagent';
|
||||
if(isDelegation) displayName='Delegate task';
|
||||
let previewText=tc.preview||displaySnippet||'';
|
||||
if(isSubagent) previewText=previewText.replace(/^🔀\s*/,'');
|
||||
row.innerHTML=`
|
||||
<div class="tool-card${tc.done===false?' tool-card-running':''}">
|
||||
<div class="${cardClass}">
|
||||
<div class="tool-card-header" onclick="this.closest('.tool-card').classList.toggle('open')">
|
||||
${runIndicator}
|
||||
<span class="tool-card-icon">${icon}</span>
|
||||
<span class="tool-card-name">${esc(tc.name)}</span>
|
||||
<span class="tool-card-preview">${esc(tc.preview||displaySnippet||'')}</span>
|
||||
<span class="tool-card-name">${esc(displayName)}</span>
|
||||
<span class="tool-card-preview">${esc(previewText)}</span>
|
||||
${hasDetail?'<span class="tool-card-toggle">▸</span>':''}
|
||||
</div>
|
||||
${hasDetail?`<div class="tool-card-detail">
|
||||
@@ -890,9 +919,11 @@ function _renderTreeItems(container, entries, depth){
|
||||
e.stopPropagation();
|
||||
if(S._expandedDirs.has(item.path)){
|
||||
S._expandedDirs.delete(item.path);
|
||||
if(typeof _saveExpandedDirs==='function')_saveExpandedDirs();
|
||||
renderFileTree();
|
||||
}else{
|
||||
S._expandedDirs.add(item.path);
|
||||
if(typeof _saveExpandedDirs==='function')_saveExpandedDirs();
|
||||
// Fetch children if not cached
|
||||
if(!S._dirCache[item.path]){
|
||||
try{
|
||||
|
||||
@@ -12,13 +12,46 @@ async function api(path,opts={}){
|
||||
return ct.includes('application/json')?res.json():res.text();
|
||||
}
|
||||
|
||||
// Persist/restore expanded directory state per workspace in localStorage
|
||||
function _wsExpandKey(){
|
||||
const ws=S.session&&S.session.workspace;
|
||||
return ws?'hermes-webui-expanded:'+ws:null;
|
||||
}
|
||||
function _saveExpandedDirs(){
|
||||
const key=_wsExpandKey();if(!key)return;
|
||||
try{localStorage.setItem(key,JSON.stringify([...(S._expandedDirs||new Set())]));}catch(e){}
|
||||
}
|
||||
function _restoreExpandedDirs(){
|
||||
const key=_wsExpandKey();
|
||||
if(!key){S._expandedDirs=new Set();return;}
|
||||
try{
|
||||
const raw=localStorage.getItem(key);
|
||||
S._expandedDirs=raw?new Set(JSON.parse(raw)):new Set();
|
||||
}catch(e){S._expandedDirs=new Set();}
|
||||
}
|
||||
|
||||
async function loadDir(path){
|
||||
if(!S.session)return;
|
||||
try{
|
||||
if(!path||path==='.'){ S._dirCache={}; if(S._expandedDirs)S._expandedDirs=new Set(); }
|
||||
if(!path||path==='.'){
|
||||
S._dirCache={};
|
||||
_restoreExpandedDirs(); // restore per-workspace expanded state on root load
|
||||
}
|
||||
S.currentDir=path||'.';
|
||||
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
|
||||
S.entries=data.entries||[];renderBreadcrumb();renderFileTree();
|
||||
// Pre-fetch contents of restored expanded dirs so they render without a second click
|
||||
if(!path||path==='.'){
|
||||
for(const dirPath of (S._expandedDirs||[])){
|
||||
if(!S._dirCache[dirPath]){
|
||||
try{
|
||||
const dc=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(dirPath)}`);
|
||||
S._dirCache[dirPath]=dc.entries||[];
|
||||
}catch(e2){S._dirCache[dirPath]=[];}
|
||||
}
|
||||
}
|
||||
if(S._expandedDirs&&S._expandedDirs.size>0)renderFileTree();
|
||||
}
|
||||
if(typeof clearPreview==='function'){
|
||||
if(typeof _previewDirty!=='undefined'&&_previewDirty){
|
||||
if(confirm('You have unsaved changes in the preview. Discard and navigate?'))clearPreview();
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
"""Sprint 23 tests: profile/workspace/model coherence."""
|
||||
import json, pathlib, re, urllib.request, urllib.error
|
||||
"""
|
||||
Sprint 23 Tests: agentic transparency — token/cost display, session usage fields,
|
||||
subagent card names, skill picker in cron, skill linked files.
|
||||
"""
|
||||
import json, urllib.error, urllib.request
|
||||
|
||||
BASE = "http://127.0.0.1:8788"
|
||||
|
||||
|
||||
def get(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
|
||||
|
||||
def post(path, body=None):
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = urllib.request.Request(BASE + path, data=data, headers={"Content-Type": "application/json"})
|
||||
req = urllib.request.Request(BASE + path, data=data,
|
||||
headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10) as r:
|
||||
return json.loads(r.read()), r.status
|
||||
@@ -17,177 +23,174 @@ def post(path, body=None):
|
||||
return json.loads(e.read()), e.code
|
||||
|
||||
|
||||
# ── Workspace profile-locality ──────────────────────────────────────────────
|
||||
|
||||
def test_workspace_list_returns_data():
|
||||
"""Workspace list endpoint works after profile-local refactor."""
|
||||
data, status = get("/api/workspaces")
|
||||
assert status == 200
|
||||
assert "workspaces" in data
|
||||
assert isinstance(data["workspaces"], list)
|
||||
assert "last" in data
|
||||
def make_session(created_list):
|
||||
d, _ = post("/api/session/new", {})
|
||||
sid = d["session"]["session_id"]
|
||||
created_list.append(sid)
|
||||
return sid, d["session"]
|
||||
|
||||
|
||||
def test_workspace_add_remove_roundtrip():
|
||||
"""Workspace add/remove still works with profile-local storage."""
|
||||
import os
|
||||
# Use a path that won't resolve differently (macOS /tmp -> /private/tmp)
|
||||
resolved_tmp = str(pathlib.Path("/tmp").resolve())
|
||||
# Clean slate
|
||||
post("/api/workspaces/remove", {"path": resolved_tmp})
|
||||
# Add
|
||||
data, status = post("/api/workspaces/add", {"path": "/tmp", "name": "Temp"})
|
||||
assert status == 200
|
||||
assert any(w["path"] == resolved_tmp for w in data.get("workspaces", []))
|
||||
# Remove
|
||||
data, status = post("/api/workspaces/remove", {"path": resolved_tmp})
|
||||
assert status == 200
|
||||
assert not any(w["path"] == resolved_tmp for w in data.get("workspaces", []))
|
||||
# ── Session usage fields ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
# ── Profile switch response fields ─────────────────────────────────────────
|
||||
|
||||
def test_profile_switch_returns_default_model_and_workspace():
|
||||
"""switch_profile() response includes default_model and default_workspace."""
|
||||
# Prior tests (test_chat_stream_opens_successfully) may leave a live LLM stream in
|
||||
# STREAMS. The server-side thread keeps running until the LLM response completes.
|
||||
# Wait up to 30 seconds for it to drain before attempting the profile switch.
|
||||
import time
|
||||
for _ in range(60):
|
||||
health, _ = get("/health")
|
||||
if health.get("active_streams", 0) == 0:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
data, status = post("/api/profile/switch", {"name": "default"})
|
||||
assert status == 200, f"Profile switch returned {status}: {data}"
|
||||
assert "active" in data
|
||||
assert data["active"] == "default"
|
||||
# default_workspace should always be present (may be null for model)
|
||||
assert "default_workspace" in data
|
||||
assert isinstance(data["default_workspace"], str)
|
||||
assert "default_model" in data # can be None
|
||||
|
||||
|
||||
def test_profile_active_endpoint():
|
||||
"""GET /api/profile/active returns name and path."""
|
||||
data, status = get("/api/profile/active")
|
||||
assert status == 200
|
||||
assert "name" in data, "Response missing 'name' field"
|
||||
assert isinstance(data["name"], str) and data["name"], "Profile name should be a non-empty string"
|
||||
assert "path" in data
|
||||
|
||||
|
||||
# ── Session profile tagging ────────────────────────────────────────────────
|
||||
|
||||
def test_new_session_has_profile_field():
|
||||
"""Sessions created after Sprint 22 should have a profile field."""
|
||||
data, status = post("/api/session/new", {})
|
||||
assert status == 200
|
||||
session = data["session"]
|
||||
assert "profile" in session
|
||||
# Clean up
|
||||
post("/api/session/delete", {"session_id": session["session_id"]})
|
||||
|
||||
|
||||
def test_sessions_list_includes_profile():
|
||||
"""Sessions created after Sprint 22 expose a profile field."""
|
||||
# Create a session and check via the direct session endpoint
|
||||
# (/api/sessions filters out empty Untitled sessions; use /api/session instead)
|
||||
create_data, _ = post("/api/session/new", {})
|
||||
sid = create_data["session"]["session_id"]
|
||||
def test_new_session_has_usage_fields():
|
||||
"""New session should include input_tokens, output_tokens, estimated_cost."""
|
||||
created = []
|
||||
try:
|
||||
data, status = get(f"/api/session?session_id={sid}")
|
||||
sid, sess = make_session(created)
|
||||
post("/api/session/rename", {"session_id": sid, "title": "Usage Test"})
|
||||
d, status = get(f"/api/session?session_id={sid}")
|
||||
assert status == 200
|
||||
session = data.get("session", data)
|
||||
assert "profile" in session, f"'profile' field missing from session: {list(session.keys())}"
|
||||
sess = d["session"]
|
||||
assert "input_tokens" in sess, "input_tokens field missing from session"
|
||||
assert "output_tokens" in sess, "output_tokens field missing from session"
|
||||
assert "estimated_cost" in sess, "estimated_cost field missing from session"
|
||||
assert sess["input_tokens"] == 0
|
||||
assert sess["output_tokens"] == 0
|
||||
finally:
|
||||
post("/api/session/delete", {"session_id": sid})
|
||||
for s in created:
|
||||
post("/api/session/delete", {"session_id": s})
|
||||
|
||||
|
||||
# ── Static JS analysis ─────────────────────────────────────────────────────
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
|
||||
|
||||
def test_sessions_js_has_profile_filter():
|
||||
"""sessions.js should filter sessions by active profile."""
|
||||
content = (REPO_ROOT / "static" / "sessions.js").read_text()
|
||||
assert "_showAllProfiles" in content
|
||||
assert "profileFiltered" in content
|
||||
assert "S.activeProfile" in content
|
||||
def test_session_compact_has_usage_fields():
|
||||
"""Session list should include usage fields in compact form."""
|
||||
created = []
|
||||
try:
|
||||
sid, _ = make_session(created)
|
||||
post("/api/session/rename", {"session_id": sid, "title": "Compact Usage"})
|
||||
d, status = get("/api/sessions")
|
||||
assert status == 200
|
||||
match = [s for s in d["sessions"] if s["session_id"] == sid]
|
||||
assert len(match) == 1
|
||||
assert "input_tokens" in match[0], "input_tokens missing from session list"
|
||||
assert "output_tokens" in match[0], "output_tokens missing from session list"
|
||||
assert match[0]["input_tokens"] == 0
|
||||
assert match[0]["output_tokens"] == 0
|
||||
finally:
|
||||
for s in created:
|
||||
post("/api/session/delete", {"session_id": s})
|
||||
|
||||
|
||||
def test_panels_js_clears_model_on_switch():
|
||||
"""switchToProfile() must clear localStorage model key."""
|
||||
content = (REPO_ROOT / "static" / "panels.js").read_text()
|
||||
assert "localStorage.removeItem('hermes-webui-model')" in content
|
||||
assert "loadWorkspaceList" in content
|
||||
assert "renderSessionList" in content
|
||||
def test_session_usage_defaults_zero():
|
||||
"""New session usage fields should default to 0/None in creation response."""
|
||||
created = []
|
||||
try:
|
||||
sid, sess = make_session(created)
|
||||
assert "input_tokens" in sess, "input_tokens missing from new session response"
|
||||
assert "output_tokens" in sess, "output_tokens missing from new session response"
|
||||
assert sess["input_tokens"] == 0
|
||||
assert sess["output_tokens"] == 0
|
||||
finally:
|
||||
for s in created:
|
||||
post("/api/session/delete", {"session_id": s})
|
||||
|
||||
|
||||
# ── Regression: profile switch base dir bug (PR #44) ──────────────────────
|
||||
# ── Skills content linked_files ──────────────────────────────────────────
|
||||
|
||||
def test_profile_switch_base_home_not_subdir():
|
||||
"""_DEFAULT_HERMES_HOME must always be the base ~/.hermes root, not a
|
||||
profile subdir. Regression: if HERMES_HOME was mutated to a profiles/
|
||||
subdir at server startup, switch_profile() looked for
|
||||
~/.hermes/profiles/X/profiles/X which never exists — returning 404.
|
||||
|
||||
We verify the fix is present via static analysis of profiles.py.
|
||||
The live-switch variant is in test_profile_switch_returns_default_model_and_workspace.
|
||||
"""
|
||||
content = (REPO_ROOT / "api" / "profiles.py").read_text()
|
||||
|
||||
# The fix must define a resolver function that handles the profiles/ subdir case
|
||||
assert "_resolve_base_hermes_home" in content, (
|
||||
"profiles.py must define _resolve_base_hermes_home() to safely resolve "
|
||||
"the base HERMES_HOME regardless of HERMES_HOME env var mutation"
|
||||
)
|
||||
assert "p.parent.name == 'profiles'" in content, (
|
||||
"_resolve_base_hermes_home must detect when HERMES_HOME points to a "
|
||||
"profiles/ subdir (e.g. ~/.hermes/profiles/webui) and walk up to base"
|
||||
)
|
||||
assert "p.parent.parent" in content, (
|
||||
"_resolve_base_hermes_home must return p.parent.parent when HERMES_HOME "
|
||||
"is a profiles/ subdir, giving back the actual ~/.hermes base"
|
||||
)
|
||||
# _DEFAULT_HERMES_HOME must be set from the resolver, not directly from env
|
||||
assert "_DEFAULT_HERMES_HOME = _resolve_base_hermes_home()" in content, (
|
||||
"_DEFAULT_HERMES_HOME must be assigned from _resolve_base_hermes_home(), "
|
||||
"not directly from os.getenv('HERMES_HOME')"
|
||||
)
|
||||
def test_skills_content_requires_name():
|
||||
"""GET /api/skills/content without name should return 400 (or 500 if skills module unavailable)."""
|
||||
try:
|
||||
d, status = get("/api/skills/content")
|
||||
assert status in (400, 500), f"Expected 400/500 for missing name, got {status}"
|
||||
except urllib.error.HTTPError as e:
|
||||
assert e.code in (400, 500), f"Expected 400/500 for missing name, got {e.code}"
|
||||
|
||||
|
||||
def test_api_helper_returns_clean_error_message():
|
||||
"""workspace.js api() helper must parse JSON error bodies and surface
|
||||
the human-readable 'error' field, not raw JSON like
|
||||
{'error': 'Profile X does not exist.'}.
|
||||
|
||||
Regression: api() did `throw new Error(await res.text())` which made
|
||||
showToast display 'Switch failed: {"error":"Profile X does not exist."}' --
|
||||
JSON noise the user shouldn't see.
|
||||
"""
|
||||
content = (REPO_ROOT / "static" / "workspace.js").read_text()
|
||||
# Must parse the JSON error body
|
||||
assert "JSON.parse(text)" in content, (
|
||||
"api() must parse JSON error bodies -- raw res.text() leaks JSON to the UI"
|
||||
)
|
||||
# Must extract the .error field
|
||||
assert "j.error" in content, (
|
||||
"api() must extract j.error from parsed JSON error response"
|
||||
)
|
||||
def test_skills_content_has_linked_files_key():
|
||||
"""GET /api/skills/content should always return a linked_files key."""
|
||||
try:
|
||||
d, status = get("/api/skills")
|
||||
if not d.get("skills"):
|
||||
return # no skills in test env, skip
|
||||
name = d["skills"][0]["name"]
|
||||
d2, status2 = get(f"/api/skills/content?name={name}")
|
||||
assert status2 == 200
|
||||
assert "linked_files" in d2, "linked_files key missing from skills/content response"
|
||||
# linked_files must be a dict (possibly empty), not None
|
||||
assert isinstance(d2["linked_files"], dict), "linked_files must be a dict"
|
||||
except urllib.error.HTTPError:
|
||||
pass # skills module unavailable in this env
|
||||
|
||||
|
||||
def test_profile_switch_resolve_base_home_logic():
|
||||
"""Static analysis: _resolve_base_hermes_home() must handle the case
|
||||
where HERMES_HOME points to a profiles/ subdir by walking up to the base.
|
||||
"""
|
||||
content = (REPO_ROOT / "api" / "profiles.py").read_text()
|
||||
assert "_resolve_base_hermes_home" in content, (
|
||||
"profiles.py must define _resolve_base_hermes_home()"
|
||||
)
|
||||
assert "p.parent.name == 'profiles'" in content, (
|
||||
"_resolve_base_hermes_home must detect and unwrap profiles/ subdir paths"
|
||||
)
|
||||
assert "p.parent.parent" in content, (
|
||||
"_resolve_base_hermes_home must walk up two levels from a profiles/ subdir"
|
||||
)
|
||||
def test_skills_content_file_path_traversal_rejected():
|
||||
"""GET /api/skills/content with traversal path should be rejected."""
|
||||
from urllib.parse import quote as _quote
|
||||
try:
|
||||
d, status = get("/api/skills")
|
||||
if not d.get("skills"):
|
||||
return # no skills in test env, skip
|
||||
name = d["skills"][0]["name"]
|
||||
traversal = _quote("../../etc/passwd", safe="")
|
||||
try:
|
||||
d2, status2 = get(f"/api/skills/content?name={name}&file={traversal}")
|
||||
assert status2 in (400, 404, 500), f"Path traversal should be rejected, got {status2}"
|
||||
except urllib.error.HTTPError as e:
|
||||
assert e.code in (400, 404, 500), f"Path traversal should be rejected, got {e.code}"
|
||||
except urllib.error.HTTPError:
|
||||
pass # skills module unavailable in test env
|
||||
|
||||
|
||||
def test_skills_content_wildcard_name_rejected():
|
||||
"""GET /api/skills/content with glob wildcard in name should be rejected when file param present."""
|
||||
try:
|
||||
try:
|
||||
d2, status2 = get("/api/skills/content?name=*&file=SKILL.md")
|
||||
assert status2 == 400, f"Wildcard name should return 400, got {status2}"
|
||||
except urllib.error.HTTPError as e:
|
||||
assert e.code in (400, 404), f"Wildcard name should be rejected, got {e.code}"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Cron create with skills ───────────────────────────────────────────────
|
||||
|
||||
def test_cron_create_accepts_skills():
|
||||
"""POST /api/crons/create should accept and store a skills array (or 500 if cron module unavailable)."""
|
||||
created_jobs = []
|
||||
try:
|
||||
body = {
|
||||
"name": "test-sprint23-skills",
|
||||
"schedule": "0 9 * * *",
|
||||
"prompt": "test prompt",
|
||||
"deliver": "local",
|
||||
"skills": ["some-skill"]
|
||||
}
|
||||
d, status = post("/api/crons/create", body)
|
||||
if status in (400, 500) and ('module' in str(d.get('error','')) or 'cron' in str(d.get('error',''))):
|
||||
return # cron module not available in test env
|
||||
assert status == 200, f"Expected 200 from cron create, got {status}: {d}"
|
||||
assert d.get("ok"), f"Cron create did not return ok: {d}"
|
||||
job_id = d.get("job", {}).get("id") or d.get("id")
|
||||
if job_id:
|
||||
created_jobs.append(job_id)
|
||||
# Verify job appears in list
|
||||
jobs_d, _ = get("/api/crons")
|
||||
job = next((j for j in jobs_d.get("jobs", []) if j.get("name") == "test-sprint23-skills"), None)
|
||||
assert job is not None, "Created cron job not found in job list"
|
||||
assert job.get("skills") == ["some-skill"] or job.get("skill") == "some-skill", \
|
||||
f"skills not stored on job: {job}"
|
||||
finally:
|
||||
try:
|
||||
for jid in created_jobs:
|
||||
post("/api/crons/delete", {"id": jid})
|
||||
jobs_d, _ = get("/api/crons")
|
||||
for j in jobs_d.get("jobs", []):
|
||||
if j.get("name") == "test-sprint23-skills":
|
||||
post("/api/crons/delete", {"id": j["id"]})
|
||||
except Exception:
|
||||
pass # cron module may not be available
|
||||
|
||||
|
||||
# ── Tool call integrity ──────────────────────────────────────────────────
|
||||
|
||||
def test_tool_calls_have_real_names():
|
||||
"""Tool calls in session JSON should not have unresolved 'tool' name."""
|
||||
created = []
|
||||
try:
|
||||
sid, _ = make_session(created)
|
||||
d, status = get(f"/api/session?session_id={sid}")
|
||||
assert status == 200
|
||||
for tc in d["session"].get("tool_calls", []):
|
||||
assert tc.get("name") not in ("tool", "", None), f"Unresolved tool name: {tc}"
|
||||
finally:
|
||||
for s in created:
|
||||
post("/api/session/delete", {"session_id": s})
|
||||
|
||||
Reference in New Issue
Block a user