Technical Documentation: fathah/hermes-desktop
ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference
Overview (`README.md`)
This project is in active development. Features may change, and some things might break. If you run into a problem or have an idea, open an issue. Contributions are welcome!
Hermes One is a community maintained native desktop app for installing, configuring, and chatting with Hermes Agent — a self-improving AI assistant with tool use, multi-platform messaging, and a closed learning loop.
Instead of managing the CLI by hand, the app walks through install, provider setup, and day-to-day usage in one place. It uses the official Hermes install script, stores Hermes in ~/.hermes, and gives you a GUI for chat, sessions, profiles, memory, skills, tools, scheduling, messaging gateways, and more.
Sponsors
Click to collapse
| Greptile is an AI code reviewer. It reviews and tests pull requests with full context of the codebase. It catches bugs, flags regressions, and leaves inline review comments on every PR automatically. |
|---|
Install
Download the desktop app for your operating system from hermesone.org or GitHub Releases. npm install hermes-desktop is not a supported desktop app installation method.
To run the app in development, clone this repository and follow the Contributing Guide. Its npm install step installs dependencies inside the source checkout.
Windows
Windows users: The installer is not code-signed. Windows SmartScreen will warn on first launch — click "More info" → "Run anyway".
WSL users: If the installer stalls at
Switching to root user to install dependencies..., Playwright is waiting for a sudo password that has no TTY to read from. Grant passwordless sudo for the install, then revert when finished:bashecho "$USER ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/hermes-install # …re-run the installer; once it finishes: sudo rm /etc/sudoers.d/hermes-installTracked in #109.
Fedora (RPM)
sudo dnf install ./hermes-desktop-<version>.rpmFedora users: The
.rpmis not GPG-signed. If your system enforces signature checking, append--nogpgcheckto the install command. Auto-update is not supported for.rpmbuilds (limitation ofelectron-updater); reinstall the new.rpmto update.
Preview
| Chat | Profiles |
|---|---|
| Models | Providers |
| Tools | Discover |
| Schedules | Gateway |
| Persona | Kanban |
| Office | Settings |
Features
- Guided first-run install for Hermes Agent with progress tracking and dependency resolution
- Local or remote backend — run Hermes locally on
127.0.0.1:8642, or connect the desktop app to a remote Hermes API server with URL + API key - Multi-provider support — OpenRouter, Anthropic, OpenAI, Google (Gemini), xAI (Grok), Nous Portal, Qwen, MiniMax, Hugging Face, Groq, and local OpenAI-compatible endpoints (LM Studio, Atomic Chat, Ollama, vLLM, llama.cpp)
- Streaming chat UI with SSE streaming, tool progress indicators, markdown rendering, and syntax highlighting
- Token usage tracking — live prompt/completion token counts and cost display in the chat footer, plus a
/usageslash command - 22 slash commands —
/new,/clear,/fast,/web,/image,/browse,/code,/shell,/usage,/help,/tools,/skills,/model,/memory,/persona,/version,/compact,/compress,/undo,/retry,/debug,/status, and more - Session management — full-text search (SQLite FTS5), date-grouped history, resume and search across conversations
- Profile switching — create, delete, and switch between separate Hermes environments with isolated config
- 14 toolsets — web, browser, terminal, file, code execution, vision, image gen, TTS, skills, memory, session search, clarify, delegation, MoA, and task planning
- Memory system — view/edit memory entries, user profile memory, capacity tracking, and discoverable memory providers (Honcho, Hindsight, Mem0, RetainDB, Supermemory, ByteRover)
- Persona editor — edit and reset your agent's SOUL.md personality
- Saved models — CRUD management for model configurations across providers
- Scheduled tasks — cron job builder (minutes, hourly, daily, weekly, custom cron) with 15 delivery targets
- 16 messaging gateways — Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Mattermost, Email (IMAP/SMTP), SMS (Twilio/Vonage), iMessage (BlueBubbles), DingTalk, Feishu/Lark, WeCom, WeChat (iLink Bot), Webhooks, Home Assistant
- Hermes Office (Claw3d) — visual 3D interface with dev server and adapter management
- Backup, import & debug dump — full data backup/restore and system diagnostics from Settings
- Log viewer — view gateway and agent logs directly from the Settings screen
- Auto-updater — check for and install updates via electron-updater
- i18n ready — internationalization framework with English locale covering all screens, ready for community translations
- Test suite — SSE parser, IPC handlers, preload API surface, installer utilities, and constants validation with Vitest
How It Works
On first launch, the app:
- Asks whether you want to run Hermes locally or connect to a remote Hermes API server.
- Local mode: checks whether Hermes is already installed in
~/.hermes; if not, runs the official Hermes installer with dependency resolution (Git, uv, Python 3.11+). - Remote mode: prompts for the remote API URL and API key, validates the connection, and skips local install.
- Prompts for an API provider or local model endpoint.
- Saves provider config and API keys through Hermes config files.
- Launches the main workspace once setup is complete.
In local mode, chat requests go through http://127.0.0.1:8642 with SSE streaming. In remote mode, the app talks to your configured remote URL with the same streaming protocol. The desktop app parses the stream in real time, rendering tool progress, markdown content, and token usage as it arrives.
Screens
| Screen | Description |
|---|---|
| Chat | Streaming conversation UI with slash commands, tool progress, and token tracking |
| Sessions | Browse, search, and resume past conversations |
| Agents | Create, delete, and switch between Hermes profiles |
| Skills | Browse, install, and manage bundled and installed skills |
| Models | Manage saved model configurations per provider |
| Memory | View/edit memory entries, user profile, and configure memory providers |
| Soul | Edit the active profile's persona (SOUL.md) |
| Tools | Enable or disable individual toolsets |
| Schedules | Create and manage cron jobs with delivery targets |
| Gateway | Configure and control messaging platform integrations |
| Office | Claw3d visual interface setup and management |
| Settings | Provider config, credential pools, backup/import, log viewer, network settings, theme |
Supported Providers
Sponsors
| Provider | Notes |
|---|---|
| Atlas Cloud | OpenAI-compatible gateway — DeepSeek, Qwen, GLM, Kimi, MiniMax and more (atlascloud.ai) |
LLM Providers
| Provider | Notes |
|---|---|
| OpenRouter | 200+ models via single API (recommended) |
| Anthropic | Direct Claude access |
| OpenAI | Direct GPT access |
| Google (Gemini) | Google AI Studio |
| xAI (Grok) | Grok models |
| Nous Portal | Free tier available |
| Qwen | QwenAI models |
| MiniMax | Global and China endpoints |
| Hugging Face | 20+ open models via HF Inference |
| Groq | Fast inference (voice/STT) |
| Local/Custom | Any OpenAI-compatible endpoint |
Local presets are included for LM Studio, Atomic Chat, Ollama, vLLM, and llama.cpp.
Messaging Platforms
Telegram, Discord, Slack, WhatsApp, Signal, Matrix/Element, Mattermost, Email (IMAP/SMTP), SMS (Twilio & Vonage), iMessage (BlueBubbles), DingTalk, Feishu/Lark, WeCom, WeChat (iLink Bot), Webhooks, and Home Assistant.
Tool Integrations
Exa Search, Parallel API, Tavily, Firecrawl, FAL.ai (image generation), Honcho, Browserbase, Weights & Biases, and Tinker.
First-Time Setup
When the app opens for the first time, it will either detect an existing Hermes installation or offer to install it for you.
Supported setup paths in the UI:
OpenRouterAnthropicOpenAILocal LLMvia an OpenAI-compatible base URL
Local presets are included for:
- LM Studio
- Atomic Chat
- Ollama
- vLLM
- llama.cpp
Hermes files are managed in:
~/.hermes~/.hermes/.env~/.hermes/config.yaml~/.hermes/hermes-agent~/.hermes/profiles/— named profile directories~/.hermes/state.db— session history database~/.hermes/cron/jobs.json— scheduled tasks
Secrets provider
By default, API keys live in ~/.hermes/.env (the env provider). No
configuration is needed — this is byte-for-byte the historical behavior, and
nothing changes for you.
If you'd rather not keep keys in a plaintext .env, the opt-in command
provider resolves them by running a helper command you configure. Resolution
order everywhere is: process.env → .env → provider → unset.
Per-key helper (the requested key name arrives as $HERMES_SECRET_KEY):
# ~/.hermes/config.yaml
secrets:
provider: command
command: secret-tool lookup hermes "$HERMES_SECRET_KEY"Or a helper that dumps a dotenv blob (e.g. a vault that unseals into tmpfs):
secrets:
provider: command
command: "cat /run/user/1000/hermes-secrets.env"The helper's stdout may be either a single bare value (per-key helpers) orKEY=VALUE lines (dotenv dumps); both shapes are auto-detected.
Vault / secret manager integration (no TPM required)
The command provider is vault-agnostic — it runs whatever helper you
configure and reads its stdout. The helper is the only thing that needs to
talk to your secret store. If you don't have a TPM-sealed keyfile, any of
these work without code changes to Hermes:
- KeePassXC (password-only DB, no keyfile): point
secrets.commandat a
smallkpxc-export.shscript that doeskeepassxc-cli ls ~/secrets/hermes.kdbx <<<"$KPXC_PASSWORD"and dumps
the relevant group as dotenv. Prompt the user for the master password
once per session. - GnuPG with a passphrase-only key:
gpg --batch --passphrase-fd 0 --decrypt ~/.keys/api-keys.gpgworks directly as thecommandvalue.
Pass the passphrase via a file descriptor or env var, never argv. pass(the standard unix password manager):command: "pass show hermes/$HERMES_SECRET_KEY"for a per-key helper,
or a small wrapper script for a dotenv dump.secret-tool(libsecret/Gnome Keyring):command: "secret-tool lookup hermes $HERMES_SECRET_KEY"(already
shown above as the canonical per-key example).- Bitwarden CLI:
bw get item "$HERMES_SECRET_KEY" | jq -r .notes
(afterbw unlockin the session). - 1Password CLI:
op read "op://vault/$HERMES_SECRET_KEY/credential". - Plain env file with user-managed permissions:
command: "cat ~/.config/hermes/secrets.env"withchmod 600and
the file owned by your user. Not as secure as a vault, but better than
a world-readable.env.
The point: any helper that prints a value (per-key) or a dotenv blob
(list-mode) on stdout will work, and Hermes imposes a 3-second timeout
and 1 MiB output cap on the helper so a misbehaving one can't wedge the
app. The provider makes no assumptions about TPM, FIDO2, smart cards,
or platform keychains.
Security model:
- The command string is your own configuration — same trust level as
.env.
It runs via/bin/sh -c, so the command provider is POSIX-only
(Linux/macOS); Windows stays on the env provider. - The helper inherits the process environment plus
HERMES_SECRET_KEY; the
key name is passed as data, never interpolated into the shell string. - Hard 3-second timeout (resolution is synchronous on the main process — keep
helpers fast and non-interactive), 1 MiB output cap, and stderr is discarded. - Resolved values are never logged or written to disk; failures degrade to
"key unset", logging only exit code/signal. - The gateway-spawn broadcast uses a single
list()call, never a per-key
helper loop.
Source of truth: src/main/secrets/.
Notes
- The desktop app depends on the upstream Hermes Agent project for agent behavior and tool execution.
- The built-in installer runs the official Hermes install script with
--skip-setup, then completes provider configuration in the GUI. - Local model providers do not require an API key, but the compatible server must already be running.
- The npm commands in the Contributing Guide apply to a source checkout's dependencies and development workflow.
Contributing
Contributions are welcome! Check out the Contributing Guide to get started. If you're not sure where to begin, take a look at the open issues. Found a bug or have a feature request? File an issue.
Related Project
This repo is not affiliated to Nous Research. This is a community maintained project.
For the core agent, docs, and CLI workflows, see the main Hermes Agent repository:
Overview (`scripts/README.md`)
Dev scripts — CDP-based E2E harness
This directory hosts dev-only one-shot scripts that drive the running
dev Electron over the Chrome DevTools Protocol. Use it to reproduce
bugs, verify fixes, or probe runtime state — much faster and more
deterministic than screenshot-driven testing.
The harness is opt-in: nothing about it touches production builds
or normal npm run dev workflows.
Quickstart
Start dev electron with CDP enabled:
bashENABLE_CDP=1 npm run dev(Or any other free port:
ENABLE_CDP=1 CDP_PORT=9223 npm run dev.)In a separate shell, run a script:
bashnode scripts/e2e-attach.jsThe shared
attach()helper connects Playwright to the running
renderer overhttp://127.0.0.1:9222(or$CDP_PORT). You can
drive the UI with DOM-aware selectors, evaluate IPC calls in the
renderer, or read state from the running main process.
Full live visual regression suite
For reconciliation work, use the reusable full-suite driver instead of
assembling one-off snippets:
npm run test:live-visualIt attaches to the running Electron app over CDP and drives the visible
Chat, Sessions, Models, model picker, and attachment controls for
local, remote HTTP, and SSH. It covers:
- valid prompt + restored session
- bad -> good -> bad -> good in one session + restored session
- add/remove model persistence + chat selector availability
- pasted image display live + restored
- generated image display live + restored
- duplicate/missing-message checks for every restored transcript
Reports and screenshots are written to.sandbox/live-visual-regression/<run-id>/.
Useful flags:
node scripts/drive-live-regression-suite.js --modes=local,remote,ssh
node scripts/drive-live-regression-suite.js --skip-generated
node scripts/drive-live-regression-suite.js --paste-image=C:\path\to\image.png
node scripts/drive-live-regression-suite.js --remote-url=http://127.0.0.1:19080 --remote-token=<token>How the opt-in works
src/main/index.ts reads process.env.ENABLE_CDP at startup and, when
set to "1", appends --remote-debugging-port=<CDP_PORT|9222> to the
Chromium command line. Without the env var the switch is never added,
so production builds (and normal dev) never expose the port.
if (process.env.ENABLE_CDP === "1") {
app.commandLine.appendSwitch(
"remote-debugging-port",
process.env.CDP_PORT || "9222",
);
}Three properties this gives us:
- Off by default. A user running the shipped app sees no CDP
port. An attacker who sets the env var on a prod install still
hits the existing Electron security model (sandbox,
contextIsolation, preload allowlist) — they get whatever a regular
user would. - Per-developer. Whoever wants the harness flips one env var;
everyone else has zero footprint. - Multi-window safe.
CDP_PORTlets you run multiple dev
electron instances side-by-side (a clean profile + a real profile,
for instance) without port collisions.
Writing a repro script
The convention used by the existing scripts:
// scripts/repro-my-bug.js
const { attach } = require("./e2e-attach");
(async () => {
const { browser, page } = await attach();
// …drive the app via page.click / page.fill / page.evaluate…
// …observe DOM, IPC return values, on-disk state…
const verdict = /* boolean check */;
console.log(`[VERDICT] ${verdict ? "✅" : "🔴"} <what was tested>`);
await browser.close();
})().catch((e) => {
console.error("FAILED:", e.stack || e.message || e);
process.exit(1);
});Naming conventions:
| Prefix | Purpose | Lives long? |
|---|---|---|
repro-<short-name>.js |
Reproduce a specific bug. Pair with an issue number or commit. Print [VERDICT] 🔴 REPRODUCED (pre-fix) or [VERDICT] ✅ FIXED (post-fix). |
Until the fix is shipped + a regression test exists; then it can be deleted or kept as a manual reference. |
drive-<flow>.js |
Walk through a user flow end-to-end (e.g. OAuth sign-in, model switch + chat). | Keep alongside the feature so future contributors can re-run. |
probe-<aspect>.js |
Read-only inspection. No state mutation. Useful for understanding a bug before writing a repro. | Useful long-term as documentation. |
verify-<feature>.js |
Live verifier paired with a PR. Asserts [VERDICT A/B/C/D] lines for each contract the PR claims. |
Lives with the PR; can be repurposed as a manual smoke test. |
Things to remember
The harness is a Node CommonJS script, not part of the TS build.
Userequire(). The project's ESLint config ignoresscripts/e2e-attach.js,scripts/repro-*.js,scripts/probe-*.js,scripts/drive-*.js, andscripts/verify-*.jsso theno-require-importsrule doesn't fire here.page.evaluate(async () => window.hermesAPI.foo())is your friend.
The renderer'shermesAPIis exposed via contextBridge, so the
harness can call any IPC the UI can. This is often more reliable
than driving clicks, especially for tests of main-process state.Don't close the dev electron from the script —
browser.close()detaches Playwright but leaves the app running.
If you need the app gone, kill it separately.Restart
npm run devafter main-process changes.
electron-vite hot-reloads renderer files, but main-process changes
don't always restart the bundled main binary. When in doubt, kill
the electron processes and restart dev.Port 9222 can get stuck in a zombie LISTEN state on Windows
after a force-kill. Ifbind() returned an errorshows up in the
dev log, switch toCDP_PORT=9223(or any other free port).
A real example
The patterns above came out of triaging the v0.5.1 bug reports
("Session continuation requires API key authentication", session
proliferation, Edit Model dialog API-key bug, Nous Portal silent
misconfiguration). Each reproducible bug got a repro-*.js that
flipped from 🔴 pre-fix to ✅ post-fix in under a minute — vs the
multi-minute screenshot loop the same flow used to require.
If you write a useful repro, add it to this directory and link it
from the related PR / issue. The next contributor will thank you.
SSH TUNNEL VPS (`docs/SSH-TUNNEL-VPS.md`)
Connecting Hermes Desktop to a Remote Hermes VPS over SSH
This guide walks through configuring Hermes Desktop to use a Hermes Agent
running on a remote server (a VPS, a HyperV/KVM VM, a Raspberry Pi on your
LAN, etc.) so that every screen — Chat, Sessions, Skills, Memory, Soul,
Tools, Schedules, Gateway, Profiles, Models, Logs — works as if Hermes
were installed locally.
If you only need to chat against a remote Hermes and you don't care about
the management screens, the simpler "Remote" mode (HTTP URL + API key)
is enough. If you want full functionality parity, you need "SSH Tunnel"
mode, which is what this document covers.
Why SSH Tunnel mode (not plain Remote mode)
The desktop app has two remote modes, and they cover very different
surface areas:
| Screen / feature | Remote (HTTP + API key) | SSH Tunnel |
|---|---|---|
Chat (/v1/chat/completions) |
✅ | ✅ |
| Sessions list & search | ❌ reads local ~/.hermes |
✅ via SSH proxy |
| Skills (browse, install, uninstall) | ❌ reads local ~/.hermes |
✅ via SSH proxy |
| Memory (view/edit entries, user profile) | ❌ reads local ~/.hermes |
✅ via SSH proxy |
| Soul (persona editor) | ❌ reads local ~/.hermes |
✅ via SSH proxy |
| Tools (toolset enable/disable) | ❌ reads local ~/.hermes |
✅ via SSH proxy |
| Schedules (cron jobs) | ❌ reads local ~/.hermes |
✅ via SSH proxy |
| Gateway (status, start/stop, platform toggles) | ❌ reads local | ✅ via SSH proxy |
| Profile switching | ❌ reads local | ✅ via SSH proxy |
| Models (saved per-provider configs) | ❌ reads local | ✅ via SSH proxy |
| Logs (gateway, agent) | ❌ reads local | ✅ via SSH proxy |
Plain Remote mode only proxies the chat path. All other screens read
the local ~/.hermes directory, so if you have no Hermes install on the
desktop's host, those screens look empty even though your remote Hermes
has data. SSH Tunnel mode proxies every screen via sshExec against the
remote host's ~/.hermes, which is what you almost certainly want.
Prerequisites
On the desktop machine (where Hermes Desktop runs):
- An SSH key pair (e.g.
~/.ssh/id_ed25519/~/.ssh/id_ed25519.pub).
Generate one withssh-keygen -t ed25519if you don't have it. - The OpenSSH client on
PATH. macOS and Linux have it by default;
Windows 10/11 ship it as an optional feature ("OpenSSH Client").
On the remote machine (where Hermes Agent runs):
- OpenSSH server reachable from the desktop host (port 22 by default).
- A user account whose
~/.hermesdirectory contains your Hermes data
(more on this below). - Your desktop's public key authorized for that user
(~/.ssh/authorized_keys). - The Hermes API listening on
127.0.0.1:8642(the default — it does
not need to be exposed publicly; the SSH tunnel forwards it).
Which user account should the desktop SSH in as?
This is the most important decision and the most common source of "the
screens are empty" reports.
The desktop app's SSH proxy uses paths like ~/.hermes/... (which
resolves to $HOME/.hermes/ of the SSH user). It must log in as the
same user that runs Hermes Agent so that ~ points at the directory
containing your real data.
Case A — You installed Hermes manually as your own user
If you ran the Hermes installer interactively as e.g. andrea and your
data lives in /home/andrea/.hermes, SSH in as andrea. Nothing extra
to do.
Case B — Hermes runs as a dedicated service user (systemd)
This is common on production VPSes. Hermes is installed under/opt/hermes (or similar) and runs via a systemd unit like:
[Service]
User=hermes
Group=hermes
Environment=HOME=/opt/hermes
ExecStart=/opt/hermes/hermes-agent/.venv/bin/hermes gatewayIn this case the data lives at /opt/hermes/.hermes/ and you need to
SSH in as the hermes user. Two things to set up:
Make sure the
hermesuser has a real login shell. Hardened
installs sometimes set/usr/sbin/nologin. Switch it to bash:bashsudo chsh -s /bin/bash hermesAuthorize your desktop's public key for the
hermesuser. Run
this from an account with sudo (e.g. your normal login user):bashPUBKEY="ssh-ed25519 AAAA... your-desktop-host" # paste yours sudo install -d -o hermes -g hermes -m 700 /opt/hermes/.ssh sudo touch /opt/hermes/.ssh/authorized_keys sudo chown hermes:hermes /opt/hermes/.ssh/authorized_keys sudo chmod 600 /opt/hermes/.ssh/authorized_keys echo "$PUBKEY" | sudo tee -a /opt/hermes/.ssh/authorized_keysNote: systemd's
ProtectHome=read-onlyon the Hermes service unit
only restricts the Hermes process itself. Interactive SSH sessions
into thehermesuser are unaffected, so the desktop can still
write skills, memory edits, soul updates, etc.
Case C — Hermes runs as root
Don't. If it currently does, migrate it to a dedicated user before
exposing SSH to it.
Case D — Hermes runs in a Docker container (Coolify, Compose, NAS)
A containerized nousresearch/hermes-agent deployment is fully
supported, but the host has no hermes binary and no real ~/.hermes —
the CLI lives inside the container and the data lives in the volume
mounted at the container's /opt/data. Without setup, chat works (the
API port is reachable through the tunnel) while Sessions, Models, Logs,
and Doctor come up empty.
The desktop can set this up for you. In Settings → Connection → SSH
(or the first-run SSH screen), use Detect remote install. The
desktop lists running Hermes containers; pick one (required when several
run) and click Set up Docker access. This writes two files on the
SSH host, as the SSH user:
~/.config/hermes-desktop/remote-hermes— a launcher that runs the
Hermes CLI inside the selected container viadocker exec. This is
the standard per-user launcher hook the desktop already probes first
for every remote CLI call, so gateway controls, Doctor, skills, and
profile listing all route into the container automatically.~/.hermes→ symlink to the container's data volume (for example/data/hermes), so session lists, config, logs, and memory read the
real Hermes home.
Requirements: the SSH user must be able to run docker (root or a user
in the docker group), and the container must mount a host directory at/opt/data. Nothing inside the container is modified, and an existing
hand-written launcher or a real ~/.hermes directory is never
overwritten — the setup refuses and tells you what to move.
Step-by-step setup
1. Verify SSH works exactly as the desktop will call it
The desktop spawns ssh with these flags (see src/main/ssh-tunnel.ts):-N -L <localPort>:127.0.0.1:8642 -i <keyPath> -o BatchMode=yes -o StrictHostKeyChecking=accept-new. The critical one isBatchMode=yes — any password or passphrase prompt will fail closed
with no useful error message. From your desktop, run:
ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
-i ~/.ssh/id_ed25519 -p 22 [email protected] \
'curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8642/health'You should see 200. If you see Permission denied (publickey), the
key isn't authorized for that user — double-check/opt/hermes/.ssh/authorized_keys and its permissions (700 on the dir,
600 on the file, owned by the target user). If you see a passphrase
prompt, your key has a passphrase and SSH agent isn't loaded — either
remove the passphrase, or load it into the agent before launching the
desktop app.
2. Configure the desktop app
Open Settings → Connection and select SSH Tunnel. Fill in:
| Field | Value |
|---|---|
| SSH Host | hostname or IP of the remote (e.g. your.vps.example.com) |
| SSH Port | 22 (or your sshd port) |
| Username | the user whose ~/.hermes is the real one (hermes in Case B) |
| Private Key Path | absolute path, e.g. ~/.ssh/id_ed25519 on macOS/Linux or C:\Users\you\.ssh\id_ed25519 on Windows |
| Remote Hermes Port | 8642 (default) |
Click Test SSH Connection. Expected result: "SSH tunnel connected!".
Then Save and restart the app.
3. (Alternative) Edit `~/.hermes/desktop.json` directly
If you prefer to skip the UI, the same config is stored at~/.hermes/desktop.json (the desktop app's local config, on the
desktop machine — not on the VPS):
{
"connectionMode": "ssh",
"remoteUrl": "http://your.vps.example.com:8642",
"remoteApiKey": "",
"sshConfig": {
"host": "your.vps.example.com",
"port": 22,
"username": "hermes",
"keyPath": "/Users/you/.ssh/id_ed25519",
"remotePort": 8642,
"localPort": 18642
}
}remoteUrl / remoteApiKey are retained so you can switch back to
plain Remote mode by changing only connectionMode.
Verifying every screen works
After restart, walk through these screens — each should reflect data
from the remote ~/.hermes, not your local one:
- Chat — send a message. Tokens should stream.
- Sessions — should list past conversations from the VPS.
- Skills — should show installed skills from the VPS.
- Memory — should show memory entries from the VPS.
- Soul — should show your remote
SOUL.md. - Tools — should show toolset enable/disable state.
- Profiles — should list profiles defined on the VPS.
- Schedules — should show cron jobs from
~/.hermes/cron/jobs.json. - Gateway — should reflect the running gateway's state.
If any screen still looks empty, see Troubleshooting below.
Troubleshooting
"SSH tunnel is not active" or chat hangs
On Linux/macOS versions ≤ 0.4.3 there is a knownControlPersist lifecycle bug — the SSH process exits immediately,
making the desktop think the tunnel died even though port-forwarding is
alive. See #195 and #159. Upgrade to a build that
includes PR #204 or apply the fix from those issues.
"Permission denied (publickey)" from the desktop, but my key works in the terminal
Most common causes:
- You use a different key from your terminal (via
~/.ssh/confighost
alias orssh-agent) than the path you configured in the desktop. The
desktop only uses the explicit key file you give it (BatchMode=yes
disables agent fallback negotiation in some configurations). - The key has a passphrase and is unlocked only in the agent. Either
remove the passphrase or ensure the agent is loaded before launching
Hermes Desktop.
Screens are empty even after switching to SSH Tunnel mode
You're almost certainly SSH'ing in as the wrong user — ~/.hermes
resolves to that user's home, not where Hermes actually keeps its data.
Verify with:
ssh -i <key> <user>@<host> 'ls -la ~/.hermes && pwd'The directory should contain SOUL.md, config.yaml, auth.json,memories/, profiles/, etc. If you see No such file or directory,
you're in the wrong account — re-read the "Which user account"
section above.
If Hermes runs in a Docker container on the remote (Doctor reportshermes CLI not found on remote PATH…), no user has a real ~/.hermes
— use Detect remote install in the SSH settings and run the Docker
setup (see Case D above).
Settings → Hermes Agent shows blank Engine / Released / Python / OpenAI SDK
Production installs commonly ship /usr/local/bin/hermes as asudo -u hermes … wrapper, and the sudoers policy refuses to run the
wrapper as the hermes user itself ("Sorry, user hermes is not allowed
to execute …"). The result: sshGetHermesVersion returns empty and the
Settings card renders four blanks while everything else works.
Fixed in PR #205 by probing the venv binary directly. If your
build pre-dates that fix, you can verify locally with:
ssh <user>@<host> '/opt/hermes/hermes-agent/.venv/bin/hermes --version'A working version string means the fix will populate the card once your
build includes #205.
Kanban shows "Kanban requires a local Hermes install"
This screen is not yet wired for remote/SSH mode (the UI explicitly
says "Remote/SSH support is coming in a follow-up"). All other
management screens work in SSH tunnel mode; Kanban is the one
exception. Track upstream for the follow-up PR.
Office (Claw3D) offers to install Claw3D locally
The Office screen detects Claw3D on the desktop host, not on the VPS.
If you're already running hermes-office.service on the VPS, that
service is independent of this screen — visit it directly athttp://<vps>:3000. Tighter integration is tracked in
#196.
`Test SSH Connection` succeeds but chat fails with 401 or auth errors
Hermes API may require an API key locally even when bound to127.0.0.1. Configure it in the desktop app's Settings → API key (or
leave blank if the gateway is configured for no-auth on localhost). The
key, if used, is the one stored in your remote Hermes .env/auth.json,
not a value you generate on the desktop.
Windows-specific: keys not persisting across restarts
Tracked in #182. If you hit this, store the desktop's API key
and SSH key path in a password manager and re-paste after a Windows
restart until the upstream fix lands.
Security notes
- The SSH tunnel binds only to
127.0.0.1on the desktop side. The
remote Hermes port is not exposed to the public internet at any
point in this flow. BatchMode=yesmeans a stolen desktop without an unlocked SSH key
cannot impersonate you to the remote Hermes — there's no password to
steal and no key-loading prompt to manipulate.StrictHostKeyChecking=accept-newtrusts the host key on first
connection and pins it in~/.ssh/known_hostsafter that. If the
remote host key ever changes (e.g. server reinstall), SSH will fail
closed and you'll need to manually re-trust it. This is the desired
behavior — don't change it.- Authorize the desktop's pubkey only on the dedicated Hermes user, not
on root. The Hermes user is already what runs the agent; giving it
inbound SSH does not expand the blast radius.
Chat Reconciliation Plan (`docs/chat-reconciliation-plan.md`)
Chat Reconciliation Stabilization Plan
2026-06-06 Direction Correction: Dashboard/WebSocket Timeline
This document originally described a conservative stabilization path for the
existing Hermes One reconciliation layer. That path is useful as background, but
it is not sufficient as the long-term implementation direction.
Manual testing and comparison with Hermes Agent desktop showed that PR540/PR545
and the current Hermes One implementation still share the same risky shape:
- live UI rows are assembled from one stream;
- restored/final rows are assembled from
state.db; - completion then tries to merge two independently-shaped transcripts;
- edge cases depend on content matching, row splitting, and heuristics.
The corrected target is closer to Hermes Agent desktop:
- use a live dashboard/WebSocket event stream as the active-turn source of
truth; - build one ordered turn timeline from gateway events;
- project both live events and restored DB rows through the same normalizer;
- avoid whole-transcript text-key reconciliation for active turns;
- treat DB hydration as replacement/fill-in after a turn is settled, not as a
competing live transcript.
The old reconciliation work should not be expanded into another pile of special
cases. It should be treated as a compatibility layer while the dashboard
transport is introduced.
What Hermes Agent Desktop Does Differently
The latest Hermes Agent desktop app lives at:
apps/desktop in NousResearch/hermes-agent.
Relevant files:
apps/desktop/electron/main.cjsapps/desktop/src/app/gateway/hooks/use-gateway-boot.tsapps/desktop/src/app/session/hooks/use-message-stream.tsapps/desktop/src/app/session/hooks/use-prompt-actions.tsapps/desktop/src/app/session/hooks/use-session-actions.tsapps/desktop/src/lib/chat-messages.tsapps/desktop/src/lib/chat-runtime.ts
The desktop app starts a dashboard backend:
hermes dashboard --no-open --host 127.0.0.1 --port <port>It then connects the renderer to:
ws://127.0.0.1:<port>/api/ws?token=<dashboard-token>Submissions are JSON-RPC calls:
session.create
session.resume
prompt.submit
session.interruptStreaming arrives as ordered events:
message.start
reasoning.delta
reasoning.available
message.delta
tool.start
tool.progress
tool.generating
tool.complete
message.complete
error
clarify.request
approval.request
sudo.request
secret.request
subagent.*The app does not render one independent "streamed transcript" and then reconcile
that against an unrelated DB transcript. It keeps a per-runtime-session state:
interface ClientSessionState {
storedSessionId: string | null
messages: ChatMessage[]
busy: boolean
awaitingResponse: boolean
streamId: string | null
sawAssistantPayload: boolean
interrupted: boolean
needsInput: boolean
}Live events mutate the in-flight assistant message's ordered parts[]:
type ChatMessagePart =
| { type: "text"; text: string }
| { type: "reasoning"; text: string }
| { type: "tool-call"; toolCallId: string; toolName: string; args: unknown; result?: unknown }The key invariant is boundary flushing:
- before a tool starts, flush any queued assistant/reasoning text;
- before a tool completes, flush queued text;
- before final completion, flush queued text;
- when a final stored transcript is loaded, preserve local errors but otherwise
prefer the stable stored projection.
This is why Hermes Agent desktop can show:
thought
tool call/result
assistant text
thought
tool call/result
assistant textinstead of:
all tool calls
all tool results
all intermediate text appended at the endWhy DB Polling Did Not Solve The Bug
We tried a mid-stream state.db polling bridge in the separate worktree. Manual
testing showed no visible streaming improvement. A DB dump after a tool-heavy
turn showed the rows appeared only near finalization, with effectively the same
timestamps. That means polling cannot recover live reasoning/tool-result order
when the backend does not persist those rows until the turn is done.
Polling may remain useful as a fallback for older backends, but it is not the
primary design.
Why `/v1/runs` Is Not The Main Target
PR540/PR545 already used the /v1/runs/event transport path and still had
reconciliation bugs. It gives more structured events than /v1/chat/completions,
but it does not by itself remove the fragile merge architecture if the renderer
still reconciles synthetic rows against DB rows by content/key heuristics.
In this worktree, /v1/runs is now guarded behind:
HERMES_DESKTOP_ENABLE_RUNS_TRANSPORT=1Default development should not depend on it.
Corrected Phased Implementation Plan
Progress Note: 2026-06-07
Phase B is implemented in the separate worktree:
src/main/dashboard.tscan start/stop/probe a local dashboard backend with a
per-processHERMES_DASHBOARD_SESSION_TOKEN.- Preload IPC exposes
dashboardStatus,startDashboard, andstopDashboard. - A sandbox smoke test confirmed
/api/statusacceptsX-Hermes-Session-Tokenand that the dashboard command is available in the
copied Hermes Agent install.
Phase C is partially implemented behind an explicit renderer flag:
src/renderer/src/screens/Chat/dashboardGatewayClient.tsimplements a small
JSON-RPC WebSocket client and now matches upstream's notification envelope:{"method":"event","params":{type,payload,session_id}}.src/renderer/src/screens/Chat/dashboardEventAdapter.tsreduces ordered
dashboard events into Hermes One's existingChatMessage[]shape.src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.tscan create
or resume a dashboard runtime session and submit prompts throughprompt.submit.- The route is disabled by default and only used when
VITE_HERMES_DESKTOP_DASHBOARD_CHAT=1.
Current limitations before making this the normal sandbox path:
- attachment submission still falls back to the existing HTTP transport;
- approval/clarify/sudo/secret events are not yet surfaced;
- runtime session ID and stored session ID are separated internally, but the
surrounding Hermes One navigation/history model still needs a fuller stored
session handoff; - live manual testing with the flag enabled has not yet been completed.
Upstream Refresh: 2026-06-09
NousResearch/hermes-agent origin/main was inspected at commit57775e9e1 on 2026-06-09. Upstream has moved enough that several of our
compatibility assumptions should change.
Useful upstream changes:
- Dashboard chat is now always enabled in
hermes_cli/web_server.pyvia_DASHBOARD_EMBEDDED_CHAT_ENABLED = True. The oldembedded_chatargument
is gone from currentstart_server(...). - The old
hermes dashboard --no-open --tui ...invocation is accepted for
compatibility, but upstream Desktop now starts dashboard without--tui. tui_gateway.serverexposes aDESKTOP_BACKEND_CONTRACTvalue. Current
contract2means remote non-image file upload viafile.attachis present.- Current gateway RPCs include:
session.createsession.resumeprompt.submitimage.attachimage.attach_bytesfile.attachmodel.optionsmodel.save_key
- Current dashboard REST endpoints include:
GET /api/model/optionsGET /api/model/recommended-defaultGET /api/model/auxiliaryPOST /api/model/set
- Remote OAuth gateways are first-class upstream:
/api/statusreportsauth_required: true;- REST auth uses cookies;
- WebSocket auth uses a single-use ticket from
POST /api/auth/ws-ticket; - static
?token=is still used for legacy token-mode gateways.
- Upstream Desktop probes the actual
/api/wsWebSocket, not just/api/status, before saying a remote connection is usable. - Upstream Desktop syncs attachments before
prompt.submit:- local image path ->
image.attach; - remote image bytes ->
image.attach_bytes; - local file path ->
file.attach; - remote file bytes/data URL ->
file.attach.
- local image path ->
- Upstream Desktop retries
prompt.submitonce after asession not found
error by callingsession.resumeon the durable stored session id, then
submitting to the fresh live runtime id. - Upstream event routing now intentionally accepts unscoped foreground turn
events (message.*,reasoning.*,tool.*) as belonging to the active chat
and drops only unscopedsubagent.*events. - Upstream has direct regression coverage for edge cases close to ours:
- duplicate user messages in
state.dbon failure/fallback paths; - sleep/wake gateway restart recovery;
- remote gateway file attachments;
- background-window streaming stalls;
- focused-chat unscoped event routing.
- duplicate user messages in
Immediate Hermes One adjustments from this refresh:
- The compatibility addon must treat
_DASHBOARD_EMBEDDED_CHAT_ENABLED = True
as already compatible. It should patch only older engines that still exposeembedded_chat: bool = False. - Local/SSH dashboard startup can keep passing
--tuifor older engines, but it
should not depend on source patching after the bundled engine updates to the
current dashboard shape. - Remote/SSH model lists and model writes should prefer upstream
/api/model/options,model.options,model.save_key, and/api/model/setinstead of direct remotemodels.jsonediting whenever the
backend supports them. - Remote/SSH pasted attachments should move from Hermes One staging/fallback
behavior to the upstream attach RPCs, especiallyimage.attach_bytesandfile.attach. - Remote OAuth should be promoted from "unsupported warning" to the upstream
cookie +/api/auth/ws-ticketflow. - Connection tests should probe
/api/wswith the same credential mode the
renderer will use. - We should add a Hermes One test matching upstream's submit recovery:
prompt.submitreturnssession not found->session.resume-> retryprompt.submitagainst the recovered live id.
Validation performed after this refresh:
npm test -- --run tests/hermes-agent-compat.test.ts tests/dashboard-remote.test.ts
passed.npm run typecheckpassed.
Phase A: Freeze The Existing Reconciliation Layer
Goal:
- stop expanding the PR540/545-style reconciliation path;
- keep current app behavior usable while we build the replacement path;
- protect manual testing from accidentally using
/v1/runs.
Actions:
- keep the separate worktree and sandbox setup;
- use the sandboxed
Hermes Oneapp identity; - keep config isolation and port isolation;
- make
/v1/runsopt-in only; - leave current DB refresh/reconciliation tests in place as regression coverage;
- document known limitations of the HTTP stream path:
- no reliable live tool results;
- no reliable live reasoning for some providers;
- DB rows can appear only at finalization;
- content-key reconciliation can misplace local errors and split text.
Acceptance criteria:
- the app builds and tests pass;
- normal chat still works through the existing HTTP path;
/v1/runsis not selected unless explicitly enabled.
Phase B: Add Dashboard Capability Discovery
Goal:
- detect whether the installed Hermes Agent supports dashboard WebSocket mode;
- do this without replacing the chat path yet.
Main-process additions:
- add a
DashboardConnectiondescriptor:
interface DashboardConnection {
baseUrl: string
wsUrl: string
token: string
mode: "local" | "remote"
profile?: string
processPid?: number
}- add IPC:
dashboard:get-connection
dashboard:start
dashboard:stop-dev-only
dashboard:status- for local mode, start:
hermes dashboard --no-open --host 127.0.0.1 --port <sandbox-safe-port>- pass:
HERMES_DASHBOARD_SESSION_TOKEN=<random-token>
HERMES_HOME=<profile-or-sandbox-home>- for remote/SSH mode, probe:
/api/status
/api/ws ticket/token supportOpen questions to answer during this phase:
- Does the Hermes One installed Hermes Agent version always ship
hermes dashboard? - For existing user installs, do we need to update Hermes Agent before the
dashboard path is available? - Can remote mode reuse the dashboard WebSocket, or only local mode initially?
Acceptance criteria:
- the app can start/probe dashboard without disturbing the existing gateway;
- dashboard uses sandbox ports in this worktree;
- dashboard logs are separate from the user's normal gateway logs;
- failure falls back to current HTTP chat.
Phase C: Add A Renderer Gateway Client
Goal:
- establish a live JSON-RPC WebSocket in the renderer, matching Hermes Agent
desktop's model.
Renderer additions:
- add a small
JsonRpcGatewayClientor vendor/adapt the shared client if
dependency layout permits; - expose a typed event stream:
type GatewayEvent =
| { type: "message.start"; session_id?: string; payload?: unknown }
| { type: "message.delta"; session_id?: string; payload?: { text?: string; rendered?: string } }
| { type: "reasoning.delta"; session_id?: string; payload?: { text?: string } }
| { type: "reasoning.available"; session_id?: string; payload?: { text?: string } }
| { type: "tool.start"; session_id?: string; payload?: GatewayToolPayload }
| { type: "tool.progress"; session_id?: string; payload?: GatewayToolPayload }
| { type: "tool.complete"; session_id?: string; payload?: GatewayToolPayload }
| { type: "message.complete"; session_id?: string; payload?: { text?: string; rendered?: string; reasoning?: string; usage?: unknown } }
| { type: "error"; session_id?: string; payload?: { message?: string } }- implement request/response calls:
session.create
session.resume
prompt.submit
session.interrupt
session.usage
model.optionsAcceptance criteria:
- WebSocket reconnects cleanly;
- request timeouts are surfaced as UI errors;
- events are filtered by active runtime session;
- no chat rendering has switched yet.
Phase D: Introduce An Ordered Turn Timeline
Goal:
- replace row-by-row live appends with a single active-turn state machine.
Do not migrate all rendering to Hermes Agent desktop's parts[] in one jump
unless it proves simpler. Hermes One can use an intermediate timeline and then
project to existing ChatMessage rows.
Suggested internal model:
type TimelinePart =
| { type: "reasoning"; id: string; text: string; pending?: boolean }
| { type: "text"; id: string; text: string; pending?: boolean }
| { type: "tool"; id: string; callId: string; name: string; args: string; result?: string; status: "running" | "completed" | "failed" }
| { type: "error"; id: string; error: string }
interface ActiveTimelineTurn {
turnId: string
userMessageId: string
assistantMessageId: string
parts: TimelinePart[]
queuedText: string
queuedReasoning: string
interrupted: boolean
}Boundary rules copied from Hermes Agent desktop:
message.delta: append toqueuedText, flush on animation/timer;reasoning.delta: append toqueuedReasoning, flush on animation/timer;tool.start: flush queued text/reasoning, close reasoning segment, add tool;tool.progress: update the active tool by stable ID/name;tool.complete: flush queued text/reasoning, update matching tool with result;message.complete: flush everything, append final tail only, mark complete;error: flush partial content if useful, append local error, mark failed.
Projection to existing UI rows:
- reasoning part ->
ReasoningMessage; - tool part ->
ToolCallMessageplus optionalToolResultMessage; - text part -> agent
ChatBubbleMessage; - error part -> agent
ChatBubbleMessagewitherror.
Acceptance criteria:
- pure unit tests prove event order is preserved;
- sequential tools render call/result pairs in event order;
- prose between tools stays between those tools;
- reasoning before/after tools stays in place;
- local errors stay attached to the failed user turn.
Phase E: Route New Chats Through Dashboard Transport
Goal:
- make dashboard/WebSocket the default for local sandbox chat while preserving
HTTP fallback.
Submission flow:
- If no active runtime session:
- call
session.create; - store runtime session ID and stored session ID separately.
- call
- Optimistically insert the user message.
- Call:
prompt.submit { session_id, text }- Drive the active timeline entirely from WebSocket events.
- On
message.complete, optionally hydrate stored session once to align
final artifacts.
Fallback:
- if dashboard connection fails before submit, use existing HTTP path;
- if dashboard fails mid-turn, render a local error anchored to that turn.
Acceptance criteria:
- "what time is it?" streams visibly;
- bad provider key displays an anchored local error;
- switching to a good provider in the same visible chat does not move/drop the
failed turn; - no local error text is sent back to the model as assistant context.
Phase F: Restore Sessions Through The Same Projection
Goal:
- restored sessions and live sessions use the same visual representation.
Implementation:
- keep
getSessionMessages(sessionId)as the stored transcript fetch; - replace ad hoc DB row mapping with a normalizer equivalent to Hermes Agent
desktop'stoChatMessages(); - merge tool-call assistant rows and
role="tool"rows into one ordered
assistant turn projection; - preserve reasoning rows;
- preserve attachments/artifacts;
- preserve local-only errors only during the current app process unless an
overlay store is explicitly added later.
Acceptance criteria:
- restored session shows:
- user messages;
- assistant messages;
- reasoning/thoughts;
- tool calls;
- tool results;
- errors persisted by Hermes Agent;
- image/file artifacts and attachments;
- restored session no longer shows duplicated tool-call-only groups after a
final assistant response.
Phase G: Retire Whole-Transcript Text-Key Reconciliation
Goal:
- remove the bug-prone class of behavior.
Implementation:
- stop calling
reconcileStreamedWithDb()for dashboard-driven active turns; - use final DB hydration as:
- full replacement when the active turn has completed and no local error is
pending; - stable-ID/turn-ID preservation only for local errors and in-flight UI state;
- full replacement when the active turn has completed and no local error is
- keep old reconciliation only for HTTP fallback mode.
Acceptance criteria:
- no content-prefix matching is needed for normal dashboard turns;
- DB hydration cannot move a local failed turn after a later successful turn;
- split assistant text is handled by the stored-session normalizer, not by
post-hoc duplicate heuristics.
Phase H: Manual Test Matrix
Run these in the sandboxed Hermes One instance, with the sandbox home and non-conflicting ports:
- simple no-tool prompt with visible streaming;
- invalid API key/provider failure;
- invalid key followed by good provider in the same visible chat;
- DeepSeek reasoning-heavy prompt;
- GPT reasoning-heavy prompt;
- image generation skill with:
- reasoning before first tool;
- multiple sequential tool calls;
- tool results;
- intermediate assistant prose;
- final image artifact;
- restored session for each above case;
- app reload while a session is idle;
- app reload after failed provider turn;
- remote/SSH mode fallback.
Phase I: Optional Persistent Local Error Overlay
Dashboard/Hermes Agent may not persist provider setup failures that happen
before a turn reaches the agent. If we want those local-only errors to survive
app restart, add a desktop-owned overlay store:
<profile-home>/desktop/session-overlays.jsonShape:
interface SessionOverlayEvent {
sessionId: string
turnId: string
afterUserId?: string
afterUserText: string
error: string
createdAt: number
}This should be a later phase because it is product behavior, not required for
live reconciliation correctness.
Current Status In This Worktree
Already done:
- separate worktree for development;
- sandbox scripts/config/ports;
- sandbox app identity
Hermes One; - local error metadata and rendering;
- preliminary local-error preservation tests;
- selected PR545 display pieces for grouped tools/reasoning;
- DB polling experiment, shown not to solve live ordering;
/v1/runsexperiment, now opt-in only.
Next recommended phase:
- package the Hermes Agent dashboard compatibility changes as a managed desktop
addon/overlay, then wire local, remote HTTP, and SSH deployment/checks through
that same capability probe.
2026-06-09 Remaining Work After Dashboard Transport Stabilization
The dashboard/WebSocket path, remote HTTP path, and SSH-over-tunnel path are now
implemented in the sandboxed Hermes One instance and covered by unit/integration tests. Manual live
testing has also exercised:
- normal local and remote dashboard chat;
- bad provider/key failure followed by good-provider recovery;
- repeated failed/non-failed turns in one visible session;
- reasoning-heavy and tool-heavy turns;
- grouped tool calls and restored grouped tool calls;
- prompt-image attachments through dashboard transport;
- restored sessions with local desktop overlay rows;
- remote HTTP sessions and remote HTTP configured-model CRUD;
- SSH dashboard transport over a local tunnel.
The remaining work is now mostly operational hardening, not a change in the
chat-reconciliation architecture.
1. Final Regression Gate
Status: implemented as a repeatable gate.
Run the complete regression battery after every change in this final hardening
pass. The canonical manual and automated checklist now lives in:
docs/reconciliation-regression-playbook.mdAutomated command:
npm run typecheck
npm test -- --run tests/remote-sessions.test.ts tests/remote-metadata.test.ts tests/remote-models.test.ts tests/dashboard-chat-transport.test.ts tests/dashboard-event-adapter.test.ts tests/live-tool-events.test.ts tests/live-reasoning-events.test.ts tests/tool-activity-group-title.test.ts tests/reconcile-streamed-with-db.test.ts tests/session-history-mapping.test.ts tests/sessions-history-items.test.ts tests/sessions-decode-content.test.ts src/renderer/src/screens/Chat/mediaUtils.test.ts src/renderer/src/screens/Chat/hooks/useChatIPC.test.tsx tests/chat-messages.test.ts tests/session-continuation-store.test.ts tests/dashboard-remote.test.ts tests/dashboard-launch.test.ts tests/dashboard-gateway-client.test.ts tests/hermes-agent-compat.test.ts tests/run-stream.test.ts src/renderer/src/screens/Sessions/Sessions.test.tsxCurrent result on 2026-06-11:
- 21 focused test files passed;
- 231 focused tests passed;
- full suite passed: 102 test files, 1158 tests passed, 3 skipped;
- TypeScript node and web typechecks passed.
2. Hermes Agent Compatibility Addon/Overlay
Status: first implementation slice complete.
Hermes One currently depends on Hermes Agent dashboard capabilities that may not
be present, or may be present but not enabled correctly, in every installed or
remote Hermes Agent. We should not leave those fixes as manual source edits.
Package them as a desktop-managed compatibility addon/overlay with this shape:
- detect capabilities before patching:
/api/statusreachable;- authenticated
/api/wsaccepts a WebSocket upgrade; - dashboard HTML advertises embedded chat support;
- configured-model list/CRUD endpoints are available;
- session list and session message endpoints are available;
- deploy only the missing compatibility pieces;
- record the applied addon version on the target Hermes Agent home;
- support local Hermes Agent install, remote HTTP target when writable/deployable,
and SSH target; - never modify provider credentials, user sessions, skills, memory, or model
configuration while applying the addon; - expose a clear Settings/diagnostics result when the target cannot be patched
automatically.
For the embedded-chat compatibility issue found during live testing, the addon
should avoid depending on a one-off source edit. The target behavior is:
hermes dashboard --no-open --host <host> --port <port> --tuimust produce a dashboard where:
GET /api/status -> authenticated success
GET /api/ws upgrade -> 101 Switching ProtocolsIf the installed engine does not do that, the addon should either patch/wrap the
dashboard launch behavior or install a small compatibility module that makes the
dashboard default compatible with Hermes One.
Implemented first slice:
src/main/hermes-agent-compat.tsowns a versioned compatibility patch for the
reproduced embedded-dashboard-chat issue.- Local Hermes Agent installs are checked before dashboard launch and after
hermes update. - SSH Hermes Agent targets are checked before dashboard probing/startup and
afterhermes updateover SSH. - The patch is intentionally narrow: it changes only the
embedded_chat: bool = Falsedefault inhermes_cli/web_server.pytoTrue. - A diagnostic marker is written to:
- local:
<HERMES_HOME>/desktop-compat/dashboard-embedded-chat.json; - SSH:
~/.hermes/desktop-compat/dashboard-embedded-chat.json.
- local:
- Plain remote HTTP is still probe-only. Hermes One cannot safely patch it
without either SSH access or a future Hermes Agent deploy endpoint.
Current tests:
tests/hermes-agent-compat.test.tscovers compatible, patchable, and unknown
source shapes.- The dashboard compatibility tests and full chat/session regression battery
pass after the slice.
3. Active Transport Status Indicator
Status: already implemented.
The Settings UI has separate configuration choices for local/remote/SSH
dashboard behavior and reports the actual active path after probing. The probe
now checks the dashboard WebSocket, not only REST status, so a remote target with
healthy REST but disabled embedded chat is reported as unavailable instead of
silently pretending dashboard chat is usable.
Keep this item as a regression check, but no new design work is currently
needed.
4. Remote/SSH/Local Test Matrix
Status: agreed, continue as a gate.
Before merging, run the same user-visible cases across every transport that is
expected to work:
- local dashboard auto;
- local dashboard forced;
- local legacy fallback;
- remote HTTP dashboard auto;
- remote HTTP dashboard forced;
- remote HTTP legacy fallback;
- SSH dashboard auto;
- SSH dashboard forced;
- SSH legacy fallback.
For each transport, cover:
- simple no-tool prompt;
- bad provider/key failure;
- bad provider followed by good provider in the same session;
- image prompt attachment;
- tool-heavy image-generation turn where the image path is mentioned in text;
- restored session;
- continue restored session;
- model switch and configured-model list refresh.
Known upstream limitation from this pass:
- Gemini failures in the current lab were traced to Hermes Agent upstream
behavior, not the Hermes One dashboard transport.
5. Reapply Addon After Engine Updates
Status: not yet implemented.
Yes: any Hermes Agent compatibility patch/addon must be rechecked after every
engine update, for both local and remote targets.
The update flow should become:
- run the normal Hermes Agent update;
- restart/probe the updated engine;
- run the compatibility capability probe;
- if required, reapply the addon/overlay;
- restart/probe again;
- surface the final state in Settings and diagnostics.
This applies to:
- Hermes One's bundled/local Hermes Agent install;
- remote HTTP targets when Hermes One is allowed to deploy an addon;
- SSH targets through the tunnel/SSH deployment path.
If the target cannot be modified, Hermes One should keep working in legacy mode
and clearly report why dashboard mode is unavailable.
6. Final Review And Upstreaming
Status: pending.
Before this leaves the worktree:
- split the work into reviewable commits/PRs;
- separate Hermes One changes from Hermes Agent compatibility changes;
- document the compatibility contract Hermes One expects from Hermes Agent;
- upstream the Hermes Agent embedded-chat/dashboard fix if possible;
- keep the addon/overlay path until the minimum supported Hermes Agent version
contains the fix natively.
Purpose
Hermes One currently merges two views of chat state:
- the renderer's streamed in-memory transcript
- the Hermes Agent
state.dbtranscript loaded throughgetSessionMessages()
That merge is valuable because the database contains artifacts that may not arrive
over the OpenAI-compatible stream, especially reasoning rows, tool calls, tool
results, and attachment metadata. But the current whole-session reconciliation is
fragile for local-only failures. A provider/key error can create a local renderer
error bubble that has no DB equivalent; after a later successful turn, the global
DB merge can move that error to the wrong position, drop neighboring lines, or
send the synthetic error text back to the model as prior assistant content.
The implementation goal is to keep the useful DB artifact fill-in behavior while
making local failures explicitly modeled and anchored to their originating turn.
Design Principle
Use different sources of truth for different phases:
- Cold load / restored session:
state.dbis canonical. - Active streaming turn: renderer order is canonical.
- Successful turn completion: DB may fill missing persisted artifacts into the
current visible transcript. - Failed turn completion: local error state is canonical for that failed turn;
do not run a global DB reconciliation that can reorder it.
In short:
DB is canonical for restored sessions. During active chat, streamed UI is
canonical for ordering; DB only fills missing persisted artifacts for
successful turns.
Current Hermes One Code Paths
Message Shape
File: src/renderer/src/screens/Chat/types.ts
Current ChatBubbleMessage has:
id- optional
kind role: "user" | "agent"content- optional
attachments
It does not have:
errorpendinglocalOnlyturnId- an anchor to the user message that caused an assistant response/error
This means provider errors are represented as normal assistant text, which makes
them indistinguishable from model output during reconciliation and future request
history construction.
Sending History
File: src/renderer/src/screens/Chat/hooks/useChatActions.ts
sendToAgent() currently sends:
messagesRef.current.filter(hasContent).map((m) => ({
role: m.role,
content: m.content,
}));Because renderer-only errors are normal role: "agent" content bubbles, an error
like Error: provider returned 401 can become part of the next provider's LLM
context. That is not only noisy; it can change model behavior in later turns.
Streaming
File: src/renderer/src/screens/Chat/hooks/useChatIPC.ts
Current streaming handlers:
onChatChunk: appends assistant text to the latest agent bubble or creates a
new agent bubble.onChatReasoningChunk: creates/appends a reasoning row in the active turn,
before assistant content when needed.onChatToolEvent: callsupsertLiveToolEvent().onChatDone: fetches full DB transcript withgetSessionMessages()and runsreconcileStreamedWithDb(prev, dbMessages).onChatError: appends a new content bubble:
{
id: `error-${Date.now()}`,
role: "agent",
content: `Error: ${error}`,
}The error bubble has no relationship to the user turn that caused it.
DB Mapping
File: src/renderer/src/screens/Chat/sessionHistory.ts
dbItemsToChatMessages() maps DB rows to renderer messages:
user-> user bubbleassistant-> agent bubblereasoning-> reasoning rowtool_call-> tool call rowtool_result-> tool result row
This is still the right path for cold load and restored sessions.
Reconciliation
File: src/renderer/src/screens/Chat/sessionHistory.ts
reconcileStreamedWithDb() does a whole-session merge:
- Builds a map of streamed rows by reconciliation key.
- Walks DB rows in canonical DB order.
- Preserves streamed IDs when DB rows match streamed equivalents.
- Inserts DB-only rows, especially reasoning/tool rows.
- Preserves unmatched streamed prefix/suffix rows.
- Drops some concatenated assistant split artifacts.
This works for successful turns where all unmatched rows are either useful live
artifacts or benign duplicates. It is risky when unmatched rows are local errors,
because local errors may be appended as suffix rows after a later DB transcript.
Restored Session Flow
File: src/renderer/src/screens/Layout/Layout.tsx
handleResumeSession() loads DB rows and calls setMessages(dbItemsToChatMessages(items)).
This should remain DB-only for now. It restores persisted user/assistant rows,
reasoning, tool calls, tool results, and attachments.
It will not restore purely local errors after app restart unless we add a
desktop-side overlay store or Hermes Agent itself persists failed turns.
Upstream Hermes Agent Desktop Lessons
Source: github.com/NousResearch/hermes-agent/apps/desktop
The upstream desktop app avoids this exact class of bugs mostly by avoiding raw
SQLite reconciliation in the renderer. It is an Electron shell that talks to ahermes dashboard backend over gateway APIs and reuses the embedded TUI path.
The relevant ideas to copy are local transcript invariants, not the full
dashboard architecture.
Upstream Message Model
File: apps/desktop/src/lib/chat-messages.ts
Upstream ChatMessage includes:
idroleparts- optional
timestamp - optional
pending - optional
error - optional
branchGroupId - optional
hidden - optional
attachmentRefs
The important part for Hermes One is error as metadata, not as normal assistant
text.
Upstream Error Handling
File: apps/desktop/src/app/session/hooks/use-message-stream.ts
Provider/gateway failures are converted into assistant error state byfailAssistantMessage():
- reuse current stream assistant message if it exists
- otherwise create a local assistant message
- set
error - clear
pending - clear turn busy state
It does not append a normal assistant text bubble containing Error: ....
Upstream also detects completion-shaped errors with completionErrorText().
Errors such as provider/gateway failure text returned through message.complete
are converted to assistant errors.
Upstream Hydration Guard
File: apps/desktop/src/app/session/hooks/use-message-stream.ts
At completion, upstream only hydrates from stored session data when:
- there is no completion error
- there is no inline assistant error
- there is no unresolved user tail
- and the stream did not produce useful assistant payload or final text
Hermes One should copy this invariant: failed turns should not trigger a broad DB
refresh/merge that can erase or reorder the local failure.
Upstream Error Preservation
File: apps/desktop/src/lib/chat-messages.ts
preserveLocalAssistantErrors(nextMessages, currentMessages) preserves local
assistant errors when hydration omits failed turns:
- if a hydrated assistant reuses the same ID, carry over local
error - if a local assistant error has no hydrated equivalent, preserve it
- preserve the preceding local user when needed
- avoid duplicating the local user when the hydrated transcript already contains
equivalent tail user content
Hermes One needs an adapted version that understands content bubbles rather
than upstream's parts model.
Scope Decision
Do not migrate Hermes One to upstream's parts[] model in this fix. That would
touch rendering, transcript copying, history mapping, and live tool rendering all
at once.
Do not migrate Hermes One to the upstream dashboard/gateway architecture in this
fix. That is a larger product direction decision.
Instead:
- keep Hermes One's current
ChatMessageunion - add minimal metadata to support local errors and anchoring
- wrap or replace the risky whole-session merge at completion boundaries
- preserve the DB-based cold load behavior
Proposed Data Model Changes
Extend `ChatBubbleMessage`
File: src/renderer/src/screens/Chat/types.ts
Add optional fields:
export interface ChatBubbleMessage {
id: string;
kind?: "user" | "assistant";
role: "user" | "agent";
content: string;
attachments?: Attachment[];
/**
* Local or streamed assistant failure metadata. This must not be sent back
* to the model as assistant content.
*/
error?: string;
/**
* True while an optimistic assistant bubble is active. Useful for replacing
* the pending row with final text or error state.
*/
pending?: boolean;
/**
* True for renderer-only UI state with no canonical DB row, such as provider
* setup/key failures.
*/
localOnly?: boolean;
/**
* Renderer-local turn identity. Used only for preserving UI order and local
* failures during a live session.
*/
turnId?: string;
}Keep these optional to preserve compatibility with existing tests and DB-loaded
messages.
Active Turn State
Add a small renderer-local active turn structure:
interface ActiveTurn {
turnId: string;
userId: string;
startIndex: number;
status: "running" | "failed" | "completed";
}This can live in Chat.tsx as a useRef<ActiveTurn | null> and be passed touseChatActions() and useChatIPC().
This does not need to be persisted. It is only for live-turn anchoring.
New Helper Module
Create a helper module, for example:
src/renderer/src/screens/Chat/chatMessages.ts
Recommended helpers:
export function isBubbleMessage(m: ChatMessage): m is ChatBubbleMessage;
export function isHistoryArtifact(m: ChatMessage): boolean;
export function visibleBubbleText(m: ChatBubbleMessage): string;
export function normalizeMessageText(text: string): string;
export function shouldSendToAgent(m: ChatMessage): m is ChatBubbleMessage;
export function shouldCopyToTranscript(m: ChatMessage): boolean;
export function displayTextForTranscript(m: ChatMessage): string;
export function isAssistantError(m: ChatMessage): m is ChatBubbleMessage;shouldSendToAgent() should return false for:
- non-bubble messages
localOnlyerror- hidden/future equivalent if added later
- empty content
It should return true for persisted or streamed user/assistant content bubbles
that are legitimate conversation context.
This helper should replace duplicated bubble checks in:
useChatActions.tsMessageList.tsxMessageRow.tsxtranscriptUtils.tssessionHistory.ts- possibly
liveToolEvents.ts
Do this gradually; the first implementation can update only the paths needed for
the bug fix.
Error Rendering Plan
MessageList
File: src/renderer/src/screens/Chat/MessageList.tsx
Current filtering hides empty content bubbles:
return ((m.content as string) || "").trim().length > 0;Change visible filtering so assistant error bubbles render even when content is
empty:
if (isBubble(m)) {
return Boolean(m.error) || ((m.content as string) || "").trim().length > 0;
}MessageRow
File: src/renderer/src/screens/Chat/MessageRow.tsx
Add rendering for msg.error.
Suggested behavior:
- show a visually distinct error bubble
- display
msg.error - do not run normal media token parsing for error text
- do not show approval bar for error rows
Pseudo-flow:
if (msg.role === "agent" && msg.error) {
return (
<div
className="chat-bubble chat-bubble-agent chat-bubble-error"
role="alert"
>
{msg.error}
</div>
);
}Add CSS only if existing error styles are not enough.
Sending History Plan
File: src/renderer/src/screens/Chat/hooks/useChatActions.ts
Replace:
messagesRef.current.filter(hasContent).map(...)with:
messagesRef.current.filter(shouldSendToAgent).map((m) => ({
role: m.role,
content: m.content,
}));This ensures local errors do not pollute future prompts.
Important: tool/reasoning rows are already excluded by hasContent, so this
should preserve current request-shaping behavior except for excluding local
assistant errors.
Sending Turn Creation Plan
File: src/renderer/src/screens/Chat/hooks/useChatActions.ts
Change pushUser() from a void helper to a helper that returns the inserted
message metadata:
const pushUser = useCallback((content, idPrefix, attachments) => {
const turnId = `turn-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const userId = `${idPrefix}-${Date.now()}`;
setMessages((prev) => [
...prev,
{
id: userId,
role: "user",
content,
turnId,
...
},
]);
return { turnId, userId };
}, ...);Better: avoid two Date.now() calls for turnId and userId; compute once.
In handleSend():
setIsLoading(true);
const turn = pushUser(text, "user", attachments);
activeTurnRef.current = {
...turn,
startIndex: messagesRef.current.length,
status: "running",
};
onSessionStarted?.();
await sendToAgent(text, attachments);The exact startIndex can be approximate because setMessages is async. It is
mostly useful for diagnostics and fallback; the stable userId and turnId are
more important.
Apply similar turn metadata to:
handleQuickAsk()handleApprove()handleDeny()
If /approve and /deny should not be treated as normal chat turns, document
that and avoid active turn changes there. Current code pushes them as user rows,
so they likely should get turn IDs for consistency.
Streaming Turn Update Plan
File: src/renderer/src/screens/Chat/hooks/useChatIPC.ts
Accept an optional activeTurnRef argument:
activeTurnRef: React.MutableRefObject<ActiveTurn | null>;`onChatChunk`
Current behavior appends to the latest agent bubble, regardless of turn. That can
remain for the first pass to avoid changing streaming behavior.
Optional improvement:
- when creating a new agent bubble, include
turnId: activeTurnRef.current?.turnId - when appending to an existing agent bubble, preserve
turnId - if latest agent bubble is an error, do not append content to it; create a new
assistant bubble instead
This prevents a late chunk from accidentally mutating an error row.
`onChatReasoningChunk`
Preserve current insertion behavior.
Add turnId to newly created reasoning rows only if we later add turnId to
history artifacts. Not required for this fix.
`onChatToolEvent`
Preserve current upsertLiveToolEvent() behavior.
Do not change tool rendering or ordering in the first implementation.
`onChatError`
Replace append-only behavior with an anchored helper:
setMessages((prev) => markActiveTurnFailed(prev, error, activeTurnRef.current));markActiveTurnFailed() should:
- Normalize message text:
- raw stored
errorshould not include a duplicatedError:prefix unless
the backend already supplied it.
- raw stored
- If there is a pending/current assistant bubble for this turn:
- set
error - set
content: ""unless there is useful partial content to preserve - set
pending: false - set
localOnly: true
- set
- Else insert an assistant error bubble immediately after the active user row.
- If the active user row cannot be found:
- append the error at the tail as a fallback
- Set
activeTurnRef.current.status = "failed" - Clear loading/tool progress as today.
Decision point: partial content plus error.
Recommended first behavior:
- if there is partial assistant content and then an error, keep
contentand seterror - render both in
MessageRow: content first, then error status - mark
localOnly: trueonly if the assistant message has no DB equivalent
This avoids losing partial streamed output after a late provider failure.
`onChatDone`
Before fetching DB:
const activeTurn = activeTurnRef.current;
if (activeTurn?.status === "failed") return;After success:
- set loading false as today
- set active turn status to completed
- fetch DB as today
- call the safer reconciliation wrapper instead of raw
reconcileStreamedWithDb - clear
activeTurnRef.currentif it belongs to the completed session/turn
Do not skip DB refresh for successful turns yet; DB refresh is still needed to
fill reasoning/tool rows that may only exist in state.db.
Reconciliation Plan
Do not delete reconcileStreamedWithDb() immediately. It already handles useful
cases:
- DB-only reasoning insertion
- future streamed reasoning ID preservation
- DB-only tool call/result insertion
- duplicate streamed content FIFO matching
- assistant split artifact removal
- DB attachments copied into streamed bubbles
Instead add a wrapper around it, likely in sessionHistory.ts:
export function reconcileAfterDbRefresh(
current: ReadonlyArray<ChatMessage>,
db: ReadonlyArray<ChatMessage>,
options?: {
activeTurn?: ActiveTurn | null;
},
): ChatMessage[] {
if (options?.activeTurn?.status === "failed") return [...current];
const syncableCurrent = current.filter(isSyncableWithDb);
const localOnly = current.filter(isLocalOnlyPreserved);
const reconciled = reconcileStreamedWithDb(syncableCurrent, db);
return preserveLocalAssistantErrors(reconciled, current);
}`isSyncableWithDb()`
Should return false for:
- assistant bubbles with
error localOnly
Should return true for:
- user/assistant content bubbles without local error state
- reasoning/tool rows
Reasoning/tool rows may be local live artifacts and should continue to be
matched/deduped against DB where possible.
`preserveLocalAssistantErrors()`
Adapt upstream's helper to Hermes One.
Input:
nextMessages: DB-reconciled messagescurrentMessages: local current transcript
Algorithm:
- Build
existingIdsfromnextMessages. - Build a normalized tail-user matcher from
nextMessages. - Find all local assistant error bubbles:
role === "agent"errorpresent- not hidden if hidden is later added
- For each local error:
- if
nextMessagesalready contains same ID, mergeerrorinto that row - otherwise find nearest preceding visible user in
currentMessages - find matching user in
nextMessagesby:- same
id, if available - same
turnId, if available - normalized content plus attachment refs/content
- same
- insert the error immediately after the matched user and any local/DB rows
that belong before assistant response for that turn - if no matched DB user exists, preserve the local user+error pair together
- if
- Preserve order among multiple local failed turns.
- Do not append all failures blindly to the end.
The hardest detail is where to insert error relative to DB rows that follow the
matched user. Recommended first pass:
- insert immediately after the matched user if the DB does not contain an
assistant response before the next user - if DB already has an assistant response for that same user, do not preserve the
error unless it has the same ID as that assistant row
That avoids creating impossible turns like:
user A
assistant success A
assistant error AFor the bad-key-then-good-provider case, DB likely contains:
user good
assistant goodwhile current contains:
user bad
assistant error bad
user good
assistant goodThe result should be:
user bad
assistant error bad
user good
assistant goodSplit Artifact Logic
Keep buildDbAssistantSplitSequences() and isCoveredByDbBubbleSplit() for now.
These fix a separate class of duplicated assistant text around tool calls.
But do not allow split-artifact logic to drop local error rows:
- local errors should be filtered out before split artifact checks
- error rows should have
reconciliationKey() === null
Reconciliation Key Changes
Update reconciliationKey():
- if bubble has
errororlocalOnly, returnnull - otherwise keep current role/content matching
This prevents local errors from matching real assistant text.
Completion Error Text Plan
Upstream detects provider/gateway errors that arrive as final assistant text.
Hermes One currently can surface errors through onError, but failures may also
arrive as normal completion content depending on API behavior.
Add a helper similar to upstream:
const COMPLETION_ERROR_PATTERNS = [
/^API call failed after \d+ retries:/i,
/^HTTP\s+\d{3}\b/i,
/^(Provider|Gateway)\s+error:/i,
/missing .*api.*key/i,
/api key.*missing/i,
/unauthorized|forbidden|invalid api key/i,
];Use cautiously. False positives could turn legitimate assistant text into error
UI. Recommended phase:
- do not use this in the first patch unless there is a known repro where Hermes
One receives provider errors aschat-donecontent - add tests before enabling
Restored Session Behavior
The proposed changes preserve restored session artifacts that are persisted instate.db.
Cold restored sessions still flow through:
getSessionMessages(sessionId)
-> expandRowsToHistory()
-> dbItemsToChatMessages()
-> setMessages()This restores:
- user messages
- assistant messages
- reasoning/thought rows
- tool call rows
- tool result rows
- DB-backed attachments
It does not restore local provider errors after app restart if Hermes Agent never
wrote them to state.db. That is already true today.
If restart-persistent local errors are required, add a separate phase:
Optional Later Phase: Desktop Session Overlay
Persist local-only events to a desktop-owned overlay file, e.g.
profileHome(activeProfile)/desktop/session-overlays.jsonShape:
interface SessionOverlay {
sessionId: string;
localEvents: Array<{
id: string;
turnId: string;
afterUserContent: string;
afterUserAttachments?: string[];
role: "agent";
error: string;
createdAt: number;
}>;
}On restored session load:
- load DB transcript
- overlay local error events by matching the nearest user
- prune overlays when a session is deleted
Do not implement this in the first stabilization patch unless the product
requires failed provider rows to survive app restart.
Streaming Behavior Expectations
Current streaming should remain mostly unchanged.
Preserved:
- assistant text still streams through
chat-chunk - reasoning still streams through
chat-reasoning-chunk - live tools still stream through
chat-tool-event - successful completion still fetches DB rows to fill missing artifacts
- session ID handling remains unchanged
Changed:
- failures become error metadata, not normal assistant content
- failed turns skip broad DB reconciliation
- local errors render even with empty content
- local errors are excluded from future LLM request history
Potential behavior improvement:
- partial output followed by error can be displayed as partial content plus
error status, instead of losing one or the other
Test Plan
Unit Tests: Message Helpers
New or existing test file:
tests/chat-message-helpers.test.ts
Cases:
shouldSendToAgent()includes normal user/assistant content.shouldSendToAgent()excludes local assistant error.shouldSendToAgent()excludes empty pending assistant.shouldSendToAgent()excludes reasoning/tool rows.- transcript copying includes a readable error line if desired.
Unit Tests: Error Preservation
New or existing file:
tests/reconcile-streamed-with-db.test.ts
Add cases:
- Local failed turn before later successful DB turn:
current: user bad, assistant error bad, user good, assistant good streamed
db: user good, assistant good
expect: user bad, assistant error bad, user good, assistant good- Local failed turn where DB has the user but no assistant:
current: user bad, assistant error bad
db: user bad
expect: user bad, assistant error bad- Repeated prompt text:
current: user "hi", error, user "hi", assistant "ok"
db: user "hi", assistant "ok"
expect: do not attach error after successful "hi"This may require turnId or positional heuristics to pass robustly.
- Existing DB artifacts still insert:
current: user, assistant content
db: user, reasoning, tool_call, tool_result, assistant content
expect: reasoning/tools present, streamed IDs preservedLocal error is not dropped by split artifact logic.
Local error is not appended at the tail after unrelated later DB rows.
Unit Tests: IPC/Error Flow
If the current test harness supports renderer hooks, add tests arounduseChatIPC():
onChatErrormarks current active turn failed and inserts error after user.onChatDoneskips DB reconciliation if active turn failed.onChatDonestill reconciles if active turn succeeded.
If hook tests are expensive, keep this behavior in pure helper functions and
unit-test those helpers directly.
Existing Tests To Keep Passing
Run:
npm test -- reconcile-streamed-with-db
npm test -- transcriptUtils
npm test -- session-history
npm test -- sessions-history-itemsThen broader:
npm testAdjust exact commands to the repo's configured Vitest filters.
Manual QA Plan
Scenario 1: Bad Provider Key Then Good Provider
- Configure a provider with an invalid/missing key.
- Send
hello with bad provider. - Confirm visible transcript:
- user prompt
- assistant error directly after that prompt
- Switch to a working provider in the same session.
- Send
hello with good provider. - Confirm visible transcript order:
- bad user
- bad assistant error
- good user
- good assistant response
- Confirm the bad error did not move after the good response.
- Confirm request history for the good provider did not include the bad
provider error as assistant text.
Scenario 2: Reasoning/Tool Successful Turn
- Use a model/tool path that produces reasoning and tool calls.
- Confirm live streaming still works.
- Confirm DB refresh still inserts missing reasoning/tool results after done.
- Confirm no duplicate assistant split text appears.
Scenario 3: Partial Output Then Error
- Force a stream that emits some text and then fails.
- Confirm partial content remains visible if useful.
- Confirm error status is visible and anchored.
- Confirm next successful turn does not reorder the failed turn.
Scenario 4: Restored Session
- Open an existing persisted session.
- Confirm user/assistant rows restore.
- Confirm reasoning rows restore.
- Confirm tool calls/results restore.
- Confirm attachments restore.
- Confirm local-only errors from before app restart are not expected unless the
optional overlay phase is implemented.
Phased Implementation Plan
This should be implemented in phases. The failure touches message typing,
streaming, rendering, request-history construction, and DB reconciliation. Doing
everything in one patch would make regressions hard to isolate.
Each phase should leave the app buildable and should preserve current streaming
behavior unless the phase explicitly changes it.
Phase 0: Baseline Audit And Repro Tests
Goal:
- Capture the current bug as failing tests before changing behavior.
- Confirm the implementation agent understands the current boundaries.
Files to inspect:
src/renderer/src/screens/Chat/types.tssrc/renderer/src/screens/Chat/hooks/useChatActions.tssrc/renderer/src/screens/Chat/hooks/useChatIPC.tssrc/renderer/src/screens/Chat/sessionHistory.tssrc/renderer/src/screens/Chat/MessageList.tsxsrc/renderer/src/screens/Chat/MessageRow.tsxtests/reconcile-streamed-with-db.test.ts
Implementation work:
- Add tests for the known bad-key-then-good-provider ordering bug.
- Add tests proving local error text is currently preserved but lands in the
wrong place, if that is the existing behavior. - Add tests showing existing successful DB artifact insertion still works:
reasoning, tool calls, tool results, and split assistant dedupe.
Recommended test cases:
current: user bad, assistant local error, user good, assistant good streamed
db: user good, assistant good
expect: user bad, assistant local error, user good, assistant goodcurrent: user bad, assistant local error
db: user bad
expect: user bad, assistant local errorcurrent: user "hi", assistant local error, user "hi", assistant good
db: user "hi", assistant good
expect: error remains attached to first "hi", not the later successful "hi"Acceptance criteria:
- At least one new test fails on the current implementation and describes the
real reported bug. - Existing reconciliation tests still pass before behavior changes, except for
any newly added bug repro tests.
Stop/rollback guidance:
- If the bug cannot be reproduced in a pure function, do not proceed directly to
UI changes. First extract the currently implicit merge behavior into a helper
that can be tested.
Phase 1: Add Message Metadata Without Behavior Changes
Goal:
- Make the type system capable of representing local errors as metadata.
- Avoid changing runtime behavior yet.
Primary files:
src/renderer/src/screens/Chat/types.ts
Implementation work:
- Add optional fields to
ChatBubbleMessage:error?: stringpending?: booleanlocalOnly?: booleanturnId?: string
- Keep all fields optional.
- Do not change
onChatErroryet. - Do not change reconciliation yet.
Acceptance criteria:
- TypeScript still compiles.
- No renderer behavior changes.
- Existing tests pass.
Stop/rollback guidance:
- If adding these fields creates type noise in many places, keep them limited to
ChatBubbleMessage; do not add metadata to reasoning/tool rows in this phase.
Phase 2: Centralize Chat Message Helpers
Goal:
- Stop spreading ad hoc checks like
"content" in mand!kindacross critical
paths before changing error semantics.
Primary files:
- new
src/renderer/src/screens/Chat/chatMessages.ts src/renderer/src/screens/Chat/hooks/useChatActions.tssrc/renderer/src/screens/Chat/MessageList.tsxsrc/renderer/src/screens/Chat/transcriptUtils.ts
Implementation work:
- Add helper functions:
isBubbleMessage()isAssistantError()normalizeMessageText()visibleBubbleText()shouldSendToAgent()shouldCopyToTranscript()displayTextForTranscript()
- Initially wire only low-risk paths, or use the helpers in tests first.
shouldSendToAgent()must exclude:- assistant errors
localOnlymessages- empty bubbles
- reasoning/tool rows
Tests:
- Add unit tests for helper behavior.
- Include a local assistant error and prove it is not sendable.
Acceptance criteria:
- No visible UI changes yet.
- Request-history filtering can be changed in a later phase by swapping to the
helper.
Stop/rollback guidance:
- If helper adoption becomes too broad, only adopt
shouldSendToAgent()inuseChatActions.tsand leave display helpers for later.
Phase 3: Exclude Local Errors From Future LLM History
Goal:
- Fix the context pollution issue independently from UI ordering.
Primary file:
src/renderer/src/screens/Chat/hooks/useChatActions.ts
Implementation work:
- Replace the current history construction:
messagesRef.current.filter(hasContent).map(...)with:
messagesRef.current.filter(shouldSendToAgent).map(...)- Keep the outgoing shape unchanged:
{
role: m.role,
content: m.content,
}Tests:
- Unit-test
shouldSendToAgent(). - If practical, add a hook-level test that verifies a local assistant error is
not included inwindow.hermesAPI.sendMessage()history.
Acceptance criteria:
- Normal user/assistant content is still sent.
- Reasoning/tool rows are still excluded.
- Local assistant errors are excluded.
Stop/rollback guidance:
- If hook-level testing is expensive, keep this phase covered by helper tests and
one focused manual check.
Phase 4: Track Active Turns
Goal:
- Give local errors an anchor so they can be inserted near the user prompt that
caused them.
Primary files:
src/renderer/src/screens/Chat/Chat.tsxsrc/renderer/src/screens/Chat/hooks/useChatActions.tssrc/renderer/src/screens/Chat/hooks/useChatIPC.ts
Implementation work:
- Add
activeTurnRefinChat.tsx:
const activeTurnRef = useRef<ActiveTurn | null>(null);- Define
ActiveTurnnear chat types or in a local hook module:
interface ActiveTurn {
turnId: string;
userId: string;
startIndex: number;
status: "running" | "failed" | "completed";
}- Pass
activeTurnRefintouseChatActions()anduseChatIPC(). - Change
pushUser()to create and return stable{ turnId, userId }. - Set
activeTurnRef.currentimmediately before sending to the agent. - Add
turnIdto the optimistic user bubble.
Important constraints:
- Do not change streaming chunk behavior in this phase.
- Do not change error rendering in this phase.
Tests:
- If helpers are extracted, test
createTurnIds()or equivalent. - Otherwise rely on Phase 5 behavior tests.
Acceptance criteria:
- Sending messages still works.
- Queued messages still drain.
/approve,/deny, and/btwbehavior is unchanged except for optionalturnIdmetadata.
Stop/rollback guidance:
- If passing
activeTurnRefthrough hooks creates too much churn, put turn state
in a smalluseActiveChatTurn()hook and pass only the needed callbacks.
Phase 5: Render Error Metadata
Goal:
- Make the UI capable of showing assistant error metadata before switching
onChatErrorto produce it.
Primary files:
src/renderer/src/screens/Chat/MessageList.tsxsrc/renderer/src/screens/Chat/MessageRow.tsx- possibly chat CSS files
Implementation work:
- Update
MessageListfiltering so empty-content error bubbles remain visible. - Update
MessageRowsomsg.errorrenders as an error state. - Ensure approval detection does not run on error rows.
- Ensure media parsing does not treat error text as normal assistant markdown
unless intentionally desired.
Tests:
- Add render test if existing setup makes it easy.
- Otherwise unit-test visibility helper if
MessageListfiltering is extracted.
Acceptance criteria:
- A message with
{ role: "agent", content: "", error: "OpenRouter 403" }
renders visibly. - Empty non-error assistant placeholders remain hidden.
Stop/rollback guidance:
- If styling becomes contentious, render error text inside the existing agent
bubble first, with a class hook for later visual polish.
Phase 6: Replace Loose Error Append With Anchored Error Metadata
Goal:
- Fix the local error model.
Primary file:
src/renderer/src/screens/Chat/hooks/useChatIPC.ts
Supporting helper:
markActiveTurnFailed(messages, error, activeTurn)
Implementation work:
- Replace
onChatErrorappend-only behavior withmarkActiveTurnFailed(). - The helper should:
- find the active user row by
activeTurn.userIdorturnId - find an existing assistant bubble in the active turn if present
- preserve partial content if present
- set
error - set
pending: false - set
localOnly: truewhen there is no DB equivalent - insert the error immediately after the active user if no assistant row exists
- append as fallback only when no anchor can be found
- find the active user row by
- Set
activeTurnRef.current.status = "failed". - Preserve existing
setToolProgress(null)andsetIsLoading(false).
Tests:
- Pure helper tests for:
- no assistant yet -> insert after active user
- partial assistant exists -> mark partial assistant as errored
- active user missing -> append fallback
- repeated user text -> use ID/turnId rather than text
Acceptance criteria:
- Provider error appears directly after the prompt that caused it.
- Error is not represented as normal
content: "Error: ..."assistant text.
Stop/rollback guidance:
- If preserving partial content complicates rendering, first support empty-content
error rows. Add partial-content preservation in a follow-up.
Phase 7: Add Safe DB Refresh Wrapper
Goal:
- Keep DB artifact fill-in for successful turns while protecting local-only
errors from global reordering.
Primary file:
src/renderer/src/screens/Chat/sessionHistory.ts
Implementation work:
- Add
reconcileAfterDbRefresh(current, db, options). - Keep
reconcileStreamedWithDb()intact initially. - The wrapper should:
- return current unchanged if
activeTurn.status === "failed" - remove local-only/error bubbles from the DB-syncable input
- run
reconcileStreamedWithDb(syncableCurrent, db) - call
preserveLocalAssistantErrors(reconciled, current)
- return current unchanged if
- Update
reconciliationKey()so local error bubbles returnnull.
Tests:
- Existing reconciliation tests must still pass.
- New failed-turn tests must pass.
- DB-only reasoning/tool insertion tests must still pass.
Acceptance criteria:
- Successful turns still get DB-only reasoning/tool rows.
- Failed local errors do not move to the tail after later successful DB refresh.
Stop/rollback guidance:
- If the wrapper reveals too many edge cases, keep raw
reconcileStreamedWithDb()as an escape hatch and gate the wrapper only around
transcripts that contain local errors.
Phase 8: Wire Safe Reconciliation Into `onChatDone`
Goal:
- Change the runtime completion path after the pure reconciliation behavior is
tested.
Primary file:
src/renderer/src/screens/Chat/hooks/useChatIPC.ts
Implementation work:
- In
onChatDone:- set session ID as today
- clear loading/tool progress as today
- if active turn is failed, skip DB fetch/reconciliation
- otherwise fetch DB messages
- call
reconcileAfterDbRefresh(prev, dbMessages, { activeTurn }) - mark active turn completed/clear it
Tests:
- Hook-level tests if feasible.
- Otherwise rely on pure helper tests and manual QA.
Acceptance criteria:
- Successful turns still refresh artifacts.
- Failed turns do not trigger a DB merge that can reorder local errors.
Stop/rollback guidance:
- If skipping DB fetch on failed turn hides something important, change the guard
to fetch DB but run onlypreserveLocalAssistantErrors()without full
reconciliation.
Phase 9: Restored Session Verification
Goal:
- Confirm cold/restored sessions are not regressed.
Primary files:
src/renderer/src/screens/Layout/Layout.tsxsrc/renderer/src/screens/Chat/sessionHistory.tssrc/main/sessions.tssrc/main/ssh-remote.ts
Implementation work:
- Prefer no code changes in this phase.
- Verify
handleResumeSession()still uses pure DB:
setMessages(dbItemsToChatMessages(items));Tests:
session-history-mappingsessions-history-items- any SSH session history tests if present
Acceptance criteria:
- Persisted reasoning rows restore.
- Persisted tool calls restore.
- Persisted tool results restore.
- Persisted prompt image attachments restore.
- Local-only provider errors are not expected to survive restart unless the
optional overlay phase is later implemented.
Stop/rollback guidance:
- If users require restart-persistent error rows, do not overload
state.dbreconciliation. Add the optional desktop overlay phase separately.
Phase 10: Manual QA Gate
Goal:
- Exercise real IPC/streaming behavior that unit tests may not cover.
Manual scenarios:
- Bad provider key, then good provider in same session.
- Missing key error, then same prompt after switching provider.
- Successful reasoning-heavy model response.
- Successful tool-heavy response.
- Partial stream followed by failure.
- Resume existing session with reasoning/tools/attachments.
- Queue messages while one turn is loading.
- Abort a turn.
/approveand/denyif approval flow is active.
Acceptance criteria:
- No error rows jump to the bottom after a later success.
- No missing user/assistant lines.
- No duplicate split assistant artifacts.
- Reasoning/tool rows still appear.
- Local error text is not included in subsequent model context.
- Restored sessions still show persisted artifacts.
Stop/rollback guidance:
- If normal streaming regresses, first revert only Phase 8 wiring and keep the
type/helper/test work. That should restore current runtime behavior while
preserving most preparatory work.
Optional Later Phase: Persistent Local Error Overlay
This is not required for the first stabilization fix.
Goal:
- Preserve local-only provider errors across app restart, even when Hermes Agent
never wrote a failed turn tostate.db.
Possible storage:
profileHome(activeProfile)/desktop/session-overlays.jsonPossible event shape:
interface LocalErrorOverlayEvent {
id: string;
sessionId: string;
turnId: string;
afterUserContent: string;
afterUserAttachmentIds?: string[];
error: string;
createdAt: number;
}Implementation notes:
- Write overlay event when a local provider error is created.
- On restored session load, load DB transcript first.
- Overlay local errors by matching the nearest user row.
- Prune overlay events when sessions are deleted.
- Keep this separate from DB reconciliation so synthetic desktop UI state does
not pretend to be canonical Hermes Agent history.
Risks and Mitigations
Risk: Error Rows Disappear Because Empty Bubbles Are Filtered
Mitigation:
- Update
MessageListvisibility filter before changingonChatError. - Add test for empty-content error rendering.
Risk: Local Error Is Sent Back To Model
Mitigation:
- Centralize request-history filtering in
shouldSendToAgent(). - Unit-test it.
Risk: Successful Reasoning/Tool Artifacts Stop Appearing
Mitigation:
- Keep
reconcileStreamedWithDb()for successful turns. - Add regression test with DB-only reasoning/tool rows.
Risk: Duplicate User Rows With Same Text
Mitigation:
- Prefer
idandturnIdover text matching when possible. - Use text matching only as fallback.
- Add repeated-prompt tests.
Risk: Partial Assistant Output Followed By Error Gets Lost
Mitigation:
- Preserve partial
contentwhen marking a pending assistant as errored. - Render content plus error status.
Risk: Too Many Files Change At Once
Mitigation:
- Implement in phases.
- Keep upstream
parts[]model out of scope. - Keep restored-session DB mapping unchanged.
Success Criteria
The implementation is successful when:
- A provider/key failure creates an anchored assistant error, not a loose text
bubble. - A later successful turn in the same session does not move, drop, or duplicate
the failed turn. - Local error text is not sent as assistant history to the next model/provider.
- Successful turns still receive DB-only reasoning, tool calls, tool results,
and attachment metadata. - Restored sessions still load all persisted DB artifacts.
- Existing split-assistant/tool-call deduplication behavior remains intact.
- The fix is covered by unit tests that reproduce the bad-provider-then-good-
provider case.
Reconciliation Regression Playbook (`docs/reconciliation-regression-playbook.md`)
Chat Reconciliation Regression Playbook
This playbook is the repeatable gate for the sandboxed Hermes One reconciliation work.
Run it after Hermes One changes, Hermes Agent engine updates, compatibility
addon changes, or remote/SSH lab changes.
Scope
The goal is to verify that Hermes One uses the dashboard event stream as the
active-turn source of truth without losing behavior that existed in the legacy
desktop app:
- ordered assistant output, reasoning, tool calls, tool results, errors, and
artifacts; - restored sessions that match live sessions;
- successful continuation after restoring a session;
- recovery from failed provider turns;
- local, Remote HTTP, and SSH dashboard parity;
- legacy fallback still available where configured.
Automated Gate
Run from the separate development worktree:
cd C:\Users\pmos6\Documents\Claude\Projects\Hermes-Desktop-reconcile
npm run typecheck
npm test -- --run tests/remote-sessions.test.ts tests/remote-metadata.test.ts tests/remote-models.test.ts tests/dashboard-chat-transport.test.ts tests/dashboard-event-adapter.test.ts tests/live-tool-events.test.ts tests/live-reasoning-events.test.ts tests/tool-activity-group-title.test.ts tests/reconcile-streamed-with-db.test.ts tests/session-history-mapping.test.ts tests/sessions-history-items.test.ts tests/sessions-decode-content.test.ts src/renderer/src/screens/Chat/mediaUtils.test.ts src/renderer/src/screens/Chat/hooks/useChatIPC.test.tsx tests/chat-messages.test.ts tests/session-continuation-store.test.ts tests/dashboard-remote.test.ts tests/dashboard-launch.test.ts tests/dashboard-gateway-client.test.ts tests/hermes-agent-compat.test.ts tests/run-stream.test.ts src/renderer/src/screens/Sessions/Sessions.test.tsxExpected result:
- TypeScript node and web checks pass.
- All listed test files pass.
When time permits, also run the entire suite:
npm test -- --runLab Setup
Start the disposable Remote HTTP target:
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\remote-lab.ps1 up
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\remote-lab.ps1 statusStart the SSH tunnel lab:
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\ssh-lab.ps1 up
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\ssh-lab.ps1 statusStart the sandboxed Hermes One instance:
npm run dev:sandboxPass criteria:
- Remote status shows dashboard status, dashboard session auth, and legacy
OpenAI models auth as OK. - SSH status reaches the remote dashboard through the tunnel.
- Hermes One title bar says
Hermes One. - Settings shows the active connection mode and active chat transport.
Connection Matrix
Run the manual cases below in these modes:
- Local, dashboard auto.
- Local, dashboard forced.
- Local, legacy fallback.
- Remote HTTP, dashboard auto.
- Remote HTTP, dashboard forced.
- Remote HTTP, legacy fallback.
- SSH, dashboard auto.
- SSH, dashboard forced.
- SSH, legacy fallback.
For auto modes, Settings should show the resolved active path. Switching between
Local, Remote HTTP, and SSH should refresh:
- model selector rows;
- Models page rows;
- Sessions page rows;
- Settings Hermes Agent metadata.
Remote HTTP and SSH should show models configured on the remote Hermes Agent
side, not the local desktop's model library.
Manual Cases
1. Clean Text Turn
Prompt:
Reply with exactly TEXT_OK and no tools.Pass criteria:
- Streaming starts promptly.
- Final assistant message is present once.
- Restoring the session shows the user prompt and assistant answer once.
2. Bad Provider Then Recovery
Switch to a known-bad model/provider.
Prompt:
Live regression bad provider turn. Reply with BAD_KEY_SHOULD_FAIL.Then switch to a known-good model/provider in the same visible session.
Prompt:
Live regression recovery after bad provider. Reply with exactly RECOVERY_AFTER_BAD_OK and do not mention BAD_KEY_SHOULD_FAIL. Do not use tools.Pass criteria:
- Failed turn appears as an error in sequence.
- Good-provider recovery does not repeat the bad-provider error.
- Restored session keeps both user prompts, the error, and the recovery answer
in order. - Repeating failed/non-failed/failed/non-failed turns keeps every semi-session
boundary in order.
3. Reasoning And Interleaved Output
Use a reasoning-heavy model such as DeepSeek V4 Pro.
Prompt:
Think briefly, then answer in two short sentences. Mention the words FIRST and SECOND in separate sentences.Pass criteria:
- Reasoning/thought blocks appear when the backend emits them.
- Intermediate assistant output stays in sequence with reasoning and tools.
- Restored session preserves reasoning blocks and assistant text order.
4. Tool-Heavy Image Generation
Use the AI Playground / ComfyUI skill.
Prompt:
Generate an image of a toy duck in a bathtub using AI Playground / ComfyUI. Save it to the media folder if the workflow supports that, and show me the resulting file path.Pass criteria:
- Tool calls and tool results stream in order when emitted.
- Sequential tool calls are grouped as
N tools called. - Inside each group, each tool call is paired with its matching result.
- Final answer does not duplicate the same image because of markdown/path
parsing. - Existing local Windows paths render as media when they exist.
- Remote/SSH paths under
/opt/data/imagesrender through the dashboard media
endpoint. - Restored session shows the same grouped calls/results and the same media.
5. Pasted Image Prompt
Paste an image into the prompt box.
Prompt:
What is this?Pass criteria:
- The user bubble appears once.
- The pasted image thumbnail appears in the user bubble.
- Hermes Agent fallback text such as
[The user attached an image but analysis failed.]
is not shown as user-visible prompt content. - The same session restored from Sessions still shows the image thumbnail, not
raw fallback text. - Continuing the restored session does not duplicate the prior pasted-image
prompt.
6. Remote Vision Tool
In Remote HTTP and SSH dashboard modes, ask about a known image using a text-only
chat model plus configured auxiliary vision.
Prompt:
Use vision_analyze to describe the attached image in one short sentence.Pass criteria:
vision_analyzereturns semantic visual content, not just pixel statistics.- The remote lab resolves auxiliary vision to OpenRouter
google/gemini-3-flash-previewwhen an OpenRouter key is available.
Optional container smoke test:
docker exec hermes-two-remote-lab-agent sh -lc 'cd /opt/hermes && HERMES_HOME=/opt/data /opt/hermes/.venv/bin/python3 - <<\"PY\"
import asyncio
from tools.vision_tools import vision_analyze_tool
async def main():
out = await vision_analyze_tool("/opt/data/images/duck_bathtub.png", "Describe this image in one short sentence.")
print(out[:1200])
asyncio.run(main())
PY'7. Session Search, Restore, Continue
For each connection mode:
- Open Sessions.
- Restore the newest session from that mode.
- Verify the visible transcript matches the original live transcript.
- Send:
Continue this session with exactly CONTINUE_OK and no tools.Pass criteria:
- Sessions list belongs to the active connection mode.
- Session restore does not mix local, Remote HTTP, and SSH sessions.
- Continuing a restored dashboard session does not produce
session not found. - Continuing does not write the remote/SSH session into local-only history.
Known Upstream Limitations
- Gemini failures in the current lab have been traced to Hermes Agent upstream
behavior, not Hermes One dashboard reconciliation. - Plain Remote HTTP cannot be patched by Hermes One unless the target exposes a
future deploy endpoint or is also reachable over SSH. - The remote lab intentionally bridges to this Windows host's AI Playground
ComfyUI for testing. That is not normal remote deployment behavior.
Exit Criteria
Before asking for review or preparing a PR:
- Automated gate passes.
- Remote and SSH lab health checks pass.
- At least one dashboard-mode live pass succeeds for Local, Remote HTTP, and
SSH. - Legacy fallback is checked at least once after any change that touches legacy
IPC or/v1paths. - Any known failure is classified as Hermes One, Hermes Agent upstream, lab
setup, or provider/service behavior.
Remote Access Lab (`docs/remote-access-lab.md`)
Remote Access Lab
This lab creates a disposable remote Hermes target for the sandboxed Hermes One instance without
touching the normal Hermes One worktree, config, or database.
Shape
- Lab state lives in
.sandbox/remote-lab/hermes-home. - Containers are named
hermes-two-remote-lab-*. - The desktop connects to one URL:
http://127.0.0.1:19080. - A small nginx proxy routes:
/api/*and/api/wsto the Hermes dashboard service in the agent container./v1/*to the OpenAI-compatible API server in the same agent container.
- One disposable token is used for both:
X-Hermes-Session-Tokendashboard auth.Authorization: Bearer ...legacy API auth.
- The Hermes Agent image is built from the bundled checkout at
.sandbox/hermes-home/hermes-agentintohermes-two-remote-lab-agent:local,
because the publicnousresearch/hermes-agent:latestimage can lag the
bundled dashboard auth contract. - The lab copies working model configuration, then strips messaging/webhook
platform credentials and opts out of bundled skill sync to avoid side effects. - For regression testing only, the lab exports
COMFYUI_HOSTashttp://host.docker.internal:49000so Remote HTTP and SSH-dashboard tests can
use this Windows machine's AI Playground ComfyUI. Normal remote deployments
should not rely on access to connecting-host resources. - Remote-generated images should be copied into
/opt/data/imagesand surfaced
asMEDIA:/opt/data/images/<file>.pngwhen possible. That path is under the
Hermes home media roots exposed by the upstream dashboard/api/media
endpoint. - If the copied config has an
OPENROUTER_API_KEY, the lab pinsauxiliary.visionto OpenRouter withgoogle/gemini-3-flash-preview. This
keepsvision_analyzeon a vision-capable auxiliary model even when the
active chat model is a text-only custom provider such as DeepSeek.
Commands
Run from the separate development worktree:
cd C:\Users\pmos6\Documents\Claude\Projects\Hermes-Desktop-reconcile
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\remote-lab.ps1 init
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\remote-lab.ps1 up
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\remote-lab.ps1 status
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\remote-lab.ps1 configure-desktopStop the lab:
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\remote-lab.ps1 downRemove all lab state:
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\remote-lab.ps1 cleanExpected Probes
status should show three OK checks:
dashboard statusdashboard sessions authlegacy OpenAI models auth
If Docker Desktop returns pipe/API 500 errors or hangs on docker ps, restart
Docker Desktop or run:
wsl --shutdownThen reopen Docker Desktop and retry scripts\remote-lab.ps1 up.
The first up may take several minutes because Docker builds the local Hermes
Agent image. Later starts reuse the image cache.
Hermes One Sandbox Settings
After configure-desktop, the sandboxed Hermes One instance is set to:
- Connection mode: Remote
- Remote URL:
http://127.0.0.1:19080 - Chat transport: Auto
That is the intended regression-test mode: dashboard first, legacy fallback
available through the same remote URL.
Ssh Dashboard Transport (`docs/ssh-dashboard-transport.md`)
SSH dashboard transport — design for full support (issue #667)
Status: design / not yet implemented. A reliable legacy-HTTP fallback shipped first (see
"Shipped now" below). This document captures the design for making the dashboard chat transport
(profile switching, session history, slash commands, background prompts) work over an SSH tunnel.
Background: why SSH dashboard chat is broken
The desktop's dashboard chat transport speaks WebSocket JSON-RPC at /api/ws
(src/renderer/src/screens/Chat/dashboardGatewayClient.ts, URL built in src/main/dashboard.ts).
In hermes-agent, /api/ws is served only by hermes dashboard
(hermes_cli/web_server.py → start_server, gated by _DASHBOARD_EMBEDDED_CHAT_ENABLED = True). It is
never served by hermes gateway (gateway/..., the api_server, which serves /v1/chat/completions,/health, etc.).
But SSH mode today:
- Starts
hermes gateway starton the remote —buildGatewayStartCommandinsrc/main/ssh-remote.ts. - Tunnels the gateway port (
config.remotePort, default 8642) —ensureSshTunnelinsrc/main/ssh-tunnel.ts. - Connects
ws://127.0.0.1:{tunnelPort}/api/ws— which 404s on the gateway.
A second, independent blocker: /api/ws authenticates with HERMES_DASHBOARD_SESSION_TOKEN
(web_server.py _SESSION_TOKEN; ?token=<…> on loopback), but the SSH path passes the remoteAPI_SERVER_KEY (sshReadRemoteApiKey in src/main/ssh-remote.ts). Even a correctly-tunneled
dashboard would reject the WS upgrade.
Shipped now: reliable legacy fallback
src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts now latches a stickydashboardUnavailableRef on the first failed ensureClient for a remote/SSH connection, so subsequent
messages fall back to the working legacy HTTP transport (/v1/chat/completions through the tunnel)
immediately instead of re-running the multi-second status+probe each time. It also firesonDashboardUnavailable once, which Chat.tsx surfaces as a one-time toast. The flag resets on any
connection change. This makes SSH chat work (degraded: no profile switching / session history /
dashboard slash commands).
Full design: run a remote `hermes dashboard` and tunnel it
Goal: restore the dashboard transport over SSH by talking to a real remote hermes dashboard.
1. Start the remote dashboard (not the gateway)
Add sshStartDashboard(config, sessionToken, port) in src/main/ssh-remote.ts, mirroringbuildGatewayStartCommand. It should run, detached:
HERMES_DASHBOARD_SESSION_TOKEN=<sessionToken> \
nohup hermes dashboard --no-open --host 127.0.0.1 --port <port> \
> $HOME/.hermes/dashboard.log 2>&1 &This mirrors the local spawn in src/main/hermes.ts:559 (dashboard --no-open --host 127.0.0.1 --port , gated by HERMES_DASHBOARD_SESSION_TOKEN) — reuse that flag/arg shape. Add a matching
status/stop command pair (buildDashboardStatusCommand / buildDashboardStopCommand).
2. Tunnel the dashboard port (in addition to / instead of the gateway port)
The legacy fallback still needs the gateway tunnel for /v1/chat/completions, while the dashboard
transport needs the dashboard's /api/ws + /api/sessions + /api/status. Options:
- Second forward: generalize
ensureSshTunnel/getSshTunnelUrlinsrc/main/ssh-tunnel.tsto
manage a named set of forwards (gateway + dashboard), eachlocalPort → 127.0.0.1:remotePort. - Remote dashboard port: pick a free remote port over SSH (run a tiny Python one-liner like the
existing helpers inssh-remote.ts) or document a fixed default; surface it in the SSH config UI.
3. Authenticate `/api/ws` with the session token
The desktop generates a sessionToken (e.g. randomUUID()), exports it to the remote dashboard's env
(step 1), and builds the WS URL as ws://127.0.0.1:{dashTunnelPort}/api/ws?token=<sessionToken> —
replacing API_SERVER_KEY. Rewrite sshDashboardConnectionFromConfig in src/main/dashboard.ts to
this flow (it currently calls sshStartGateway + sshReadRemoteApiKey). Note web_server.py's_ws_auth_ok accepts the ?token= query only on loopback / --insecure; over the SSH tunnel the
endpoint is loopback on the remote, so this should hold — verify on a real host.
4. Compatibility
ensureSshDashboardCompatibility (src/main/hermes-agent-compat.ts) already patches web_server.py's
embedded-chat default and /api/model/set; keep it. Confirm the remote hermes build acceptsdashboard --no-open --host --port and the HERMES_DASHBOARD_SESSION_TOKEN env (v0.16.0 has_DASHBOARD_EMBEDDED_CHAT_ENABLED = True, so embedded chat is available).
5. Lifecycle
- Stop the remote
hermes dashboardon disconnect / app quit (best-effort, likesshStopGateway). - Health-check the dashboard (
/api/statusthrough the tunnel) and restart on failure. - Keep the gateway running too if other features depend on it.
Testing
- Unit: command builders (
sshStartDashboard/ status / stop) and the multi-forward tunnel wiring,
following the existingssh-remote/ssh-tunneltest patterns. - Manual E2E (required before merge): an SSH host running
hermes-agent(seedocs/SSH-TUNNEL-VPS.md). Verify: tunnel up → dashboard starts remotely →/api/wsconnects with the
session token → a sent message streams back → profile switching and session history work. This step
cannot be exercised without a real remote host.
Superpowers Plans 2026 04 30 Windows Winget Fedora Rpm Release (`docs/superpowers/plans/2026-04-30-windows-winget-fedora-rpm-release.md`)
Windows (winget) and Fedora (RPM) Release Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Extend the existing GitHub Actions release pipeline to produce a Windows NSIS installer + winget manifests, and a Fedora .rpm, alongside the existing macOS/Linux artifacts. End state: open a PR from Aiacos/hermes-desktop:feat/winget-rpm-release to fathah/hermes-desktop:main.
Architecture: Two new jobs added to .github/workflows/release.yml (Windows build + winget manifest generator), one existing job extended (Linux gets rpm), one job gated on a new dry_run input. Winget manifests are filled from YAML templates by a tested Node ESM script and uploaded as a CI artifact for manual submission to microsoft/winget-pkgs.
Tech Stack: GitHub Actions, electron-builder 26, Node 22 ESM, vitest, fpm/rpmbuild on Ubuntu runner.
Spec: docs/superpowers/specs/2026-04-30-windows-winget-fedora-rpm-release-design.md
Branch: feat/winget-rpm-release (already created locally; see Task 1 to confirm).
Pre-flight: confirm starting state
Task 0: Confirm we're on the right branch with a clean tree
- Step 1: Check branch and status
Run: git status && git branch --show-current && git log --oneline -3
Expected output:
- Current branch:
feat/winget-rpm-release - Working tree clean
- Top commit:
docs: add design spec for Windows (winget) and Fedora (RPM) release
If on a different branch or tree is dirty, stop and resolve before proceeding.
Phase 1: electron-builder configuration
Task 1: Add RPM target and Linux packaging metadata to electron-builder.yml
Files:
Modify:
electron-builder.ymlStep 1: Replace the
linux:block and addrpm:block
Open electron-builder.yml. Replace the existing block:
linux:
target:
- AppImage
- snap
- deb
maintainer: electronjs.org
category: Utility
appImage:
artifactName: ${name}-${version}.${ext}with:
linux:
target:
- AppImage
- snap
- deb
- rpm
maintainer: electronjs.org
vendor: Nous Research
category: Utility
synopsis: Self-improving AI assistant desktop app
description: |
Hermes Desktop is a native desktop app for installing, configuring, and chatting
with Hermes Agent — a self-improving AI assistant with tool use, multi-platform
messaging, and a closed learning loop.
appImage:
artifactName: ${name}-${version}.${ext}
rpm:
artifactName: ${name}-${version}.${ext}- Step 2: Verify YAML is still valid
Run: node -e "console.log(require('js-yaml').load(require('fs').readFileSync('electron-builder.yml','utf-8')).linux.target)" 2>/dev/null || node -e "console.log(JSON.parse(JSON.stringify(require('yaml').parse(require('fs').readFileSync('electron-builder.yml','utf-8')))).linux.target)" 2>/dev/null || python3 -c "import yaml; print(yaml.safe_load(open('electron-builder.yml')).get('linux', {}).get('target'))"
Expected output: ['AppImage', 'snap', 'deb', 'rpm'] (Python form) or [ 'AppImage', 'snap', 'deb', 'rpm' ] (Node form).
If neither js-yaml, yaml, nor Python yaml is available, just open the file and visually verify the four entries.
Task 2: Make NSIS settings explicit in electron-builder.yml
Files:
Modify:
electron-builder.ymlStep 1: Replace the
nsis:block
Replace:
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: alwayswith:
nsis:
artifactName: ${name}-${version}-setup.${ext}
shortcutName: ${productName}
uninstallDisplayName: ${productName}
createDesktopShortcut: always
oneClick: true
perMachine: falseThe two new fields make the existing electron-builder defaults explicit so the behavior cannot silently shift across electron-builder versions.
Task 3: Add `build:rpm` script to package.json
Files:
Modify:
package.jsonStep 1: Insert the script next to the existing
build:linux
In package.json "scripts" block, after the line:
"build:linux": "electron-vite build && electron-builder --linux",add:
"build:rpm": "npm run build && electron-builder --linux rpm",Final scripts block excerpt should look like:
"build:mac": "electron-vite build && electron-builder --mac",
"build:linux": "electron-vite build && electron-builder --linux",
"build:rpm": "npm run build && electron-builder --linux rpm",
"test:watch": "vitest"- Step 2: Verify package.json still parses
Run: node -e "console.log(require('./package.json').scripts['build:rpm'])"
Expected output: npm run build && electron-builder --linux rpm
Task 4: Local sanity-check the RPM build (Fedora host only)
This step verifies that the new electron-builder rpm target produces a real RPM on the local Fedora machine before we trust CI to do it. This is a manual, non-CI check. Skip if not on a Fedora host.
- Step 1: Confirm rpmbuild is present
Run: which rpmbuild && rpmbuild --version
Expected: a path under /usr/bin/ and a version string. If missing, install with sudo dnf install rpm-build.
- Step 2: Run the RPM build
Run: npm install && npm run build:rpm
Expected: completes without errors, ~2-5 minutes. Final lines should mention writing an .rpm file under dist/.
- Step 3: Confirm the RPM was produced
Run: ls -la dist/*.rpm && rpm -qpi dist/*.rpm | head -20
Expected:
- A file
dist/hermes-desktop-0.2.3.rpm(or current version) of non-trivial size (~120-200 MB) rpm -qpishowsName: hermes-desktop,Version: 0.2.3,Vendor: Nous Research,License,Summarymatching oursynopsis.
If the RPM is missing or metadata is wrong, go back to Task 1 and fix.
- Step 4: Clean up dist before committing
Run: rm -rf dist out node_modules/.cache
Reason: keeps the working tree clean so the next commit only contains our config changes.
Task 5: Commit electron-builder + package.json changes
- Step 1: Stage and commit
Run:
git add electron-builder.yml package.json
git status
git commit -m "build: add rpm target and explicit nsis settings to electron-builder"Expected: 1 commit, 2 files changed. git status should show clean working tree afterwards.
Phase 2: Winget manifest infrastructure
Task 6: Create the three winget manifest templates
Files:
Create:
build/winget/Installer.template.yamlCreate:
build/winget/Locale.en-US.template.yamlCreate:
build/winget/Version.template.yamlStep 1: Create the directory
Run: mkdir -p build/winget
- Step 2: Create
build/winget/Installer.template.yaml
Contents:
# Generated from this template by scripts/generate-winget-manifests.mjs.
# Placeholders ({{...}}) are replaced at build time. Do not edit the generated copy in dist/.
PackageIdentifier: NousResearch.HermesDesktop
PackageVersion: { { VERSION } }
InstallerLocale: en-US
InstallerType: nullsoft
Scope: user
MinimumOSVersion: 10.0.17763.0
ReleaseDate: { { RELEASE_DATE } }
Installers:
- Architecture: x64
InstallerUrl: { { INSTALLER_URL } }
InstallerSha256: { { INSTALLER_SHA256 } }
UpgradeBehavior: install
ManifestType: installer
ManifestVersion: 1.6.0- Step 3: Create
build/winget/Locale.en-US.template.yaml
Contents:
# Generated from this template by scripts/generate-winget-manifests.mjs.
PackageIdentifier: NousResearch.HermesDesktop
PackageVersion: { { VERSION } }
PackageLocale: en-US
Publisher: Nous Research
PublisherUrl: https://github.com/fathah/hermes-desktop
PublisherSupportUrl: https://github.com/fathah/hermes-desktop/issues
PackageName: Hermes Agent
PackageUrl: https://github.com/fathah/hermes-desktop
License: MIT
LicenseUrl: https://github.com/fathah/hermes-desktop/blob/main/LICENSE
ShortDescription: Self-improving AI assistant desktop app
Description: |-
Hermes Desktop is a native desktop app for installing, configuring, and chatting
with Hermes Agent — a self-improving AI assistant with tool use, multi-platform
messaging, and a closed learning loop.
Tags:
- ai
- agent
- desktop
- electron
- llm
ReleaseNotesUrl: { { RELEASE_NOTES_URL } }
ManifestType: defaultLocale
ManifestVersion: 1.6.0- Step 4: Create
build/winget/Version.template.yaml
Contents:
# Generated from this template by scripts/generate-winget-manifests.mjs.
PackageIdentifier: NousResearch.HermesDesktop
PackageVersion: { { VERSION } }
DefaultLocale: en-US
ManifestType: version
ManifestVersion: 1.6.0- Step 5: Verify the three files exist
Run: ls -la build/winget/
Expected: three .template.yaml files listed.
Task 7: Write the failing test for the manifest generator
Files:
Create:
tests/winget-generator.test.tsStep 1: Create
tests/winget-generator.test.ts
Contents:
/* Detailed source-code truncated for AI context efficiency. */Task 8: Verify the test fails
- Step 1: Run vitest
Run: npm run test
Expected: tests/winget-generator.test.ts fails with an import resolution error like Failed to resolve import "../scripts/generate-winget-manifests.mjs" or similar. The other existing tests must still pass. Total: 4 new tests failing, all existing tests passing.
If existing tests fail, stop — that's an unrelated breakage we caused. Investigate before proceeding.
Task 9: Implement the manifest generator
Files:
Create:
scripts/generate-winget-manifests.mjsStep 1: Create the directory
Run: mkdir -p scripts
- Step 2: Create
scripts/generate-winget-manifests.mjs
Contents:
/* Detailed source-code truncated for AI context efficiency. */Task 10: Verify the test passes
- Step 1: Run vitest
Run: npm run test
Expected: all four winget-generator tests pass. All previously passing tests still pass. Output should end with Tests count incremented by 4.
If failing: read the assertion error and fix the script. Do not modify the test to make it pass — modify the implementation.
Task 11: Commit winget infrastructure
- Step 1: Stage and commit
Run:
git add build/winget/ scripts/generate-winget-manifests.mjs tests/winget-generator.test.ts
git status
git commit -m "feat: add winget manifest generator with templates and tests"Expected: 5 files added, 1 commit. Working tree clean.
Phase 3: GitHub Actions workflow
The following four tasks edit .github/workflows/release.yml in sequence. Each task ends with the workflow still being a syntactically valid YAML, but only after Task 16 is the workflow logically complete. Commit at the end of Phase 3 (Task 17), not after each individual edit.
Task 12: Add `dry_run` input and `is_dry_run` output
Files:
Modify:
.github/workflows/release.ymlStep 1: Replace the
on:block
Replace:
on:
push:
branches:
- release
workflow_dispatch:with:
on:
push:
branches:
- release
workflow_dispatch:
inputs:
dry_run:
description: "Run all build jobs but skip publish (no tag, no GitHub Release)"
type: boolean
default: true- Step 2: Add
is_dry_runto thepreparejob outputs and add a step to compute it
In the prepare job, replace:
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
tag_exists: ${{ steps.check.outputs.exists }}with:
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
tag_exists: ${{ steps.check.outputs.exists }}
is_dry_run: ${{ steps.mode.outputs.is_dry_run }}Then, after the existing "Check if tag already exists" step inside prepare.steps, append:
- name: Compute dry-run flag
id: mode
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.dry_run }}" = "true" ]; then
echo "is_dry_run=true" >> "$GITHUB_OUTPUT"
echo "Dry run: builds will run, publish will be skipped."
else
echo "is_dry_run=false" >> "$GITHUB_OUTPUT"
echo "Real release: publish will run if tag does not exist."
fiTask 13: Extend `release_linux` with rpm
Files:
Modify:
.github/workflows/release.ymlStep 1: Add
rpmapt install before packaging, and rpm to the electron-builder targets
In the release_linux job, locate the steps after Install dependencies and Build app. Replace:
- name: Package Linux artifacts
run: npx electron-builder --linux AppImage deb --publish never
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: linux-artifacts
path: |
dist/*.AppImage
dist/*.deb
dist/latest-linux.ymlwith:
- name: Install rpmbuild
run: sudo apt-get update && sudo apt-get install -y rpm
- name: Package Linux artifacts
run: npx electron-builder --linux AppImage deb rpm --publish never
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: linux-artifacts
path: |
dist/*.AppImage
dist/*.deb
dist/*.rpm
dist/latest-linux.ymlTask 14: Add `release_windows` job
Files:
Modify:
.github/workflows/release.ymlStep 1: Insert a new job after
release_linux, beforepublish
Add the following job block:
release_windows:
name: Build Windows
needs: prepare
if: needs.prepare.outputs.tag_exists == 'false'
runs-on: windows-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build app
run: npm run build
- name: Package Windows artifacts
run: npx electron-builder --win nsis --x64 --publish never
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: windows-artifacts
path: |
dist/*.exe
dist/*.exe.blockmap
dist/latest.ymlTask 15: Add `generate_winget` job
Files:
Modify:
.github/workflows/release.ymlStep 1: Insert the new job after
release_windows
Add the following job block:
generate_winget:
name: Generate winget manifests
needs: [prepare, release_windows]
if: needs.prepare.outputs.tag_exists == 'false'
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Download Windows installer artifact
uses: actions/download-artifact@v4
with:
name: windows-artifacts
path: dist/
- name: Generate winget manifests
env:
VERSION: ${{ needs.prepare.outputs.version }}
PUBLISH_OWNER: fathah
run: node scripts/generate-winget-manifests.mjs
- name: Upload winget manifests artifact
uses: actions/upload-artifact@v4
with:
name: winget-manifests-${{ needs.prepare.outputs.version }}
path: dist/winget/Task 16: Update the `publish` job (gate + explicit file list)
Files:
Modify:
.github/workflows/release.ymlStep 1: Replace the
publishjob header and the gh-release files glob
Replace the existing publish job:
publish:
name: Publish Release
needs: [prepare, release_mac, release_linux]
if: needs.prepare.outputs.tag_exists == 'false'
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Create tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag ${{ needs.prepare.outputs.tag }}
git push origin ${{ needs.prepare.outputs.tag }}
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Publish GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.prepare.outputs.tag }}
name: Hermes Desktop ${{ needs.prepare.outputs.tag }}
generate_release_notes: true
files: artifacts/*with:
publish:
name: Publish Release
needs: [prepare, release_mac, release_linux, release_windows, generate_winget]
if: needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.tag_exists == 'false'
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Create tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag ${{ needs.prepare.outputs.tag }}
git push origin ${{ needs.prepare.outputs.tag }}
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Publish GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.prepare.outputs.tag }}
name: Hermes Desktop ${{ needs.prepare.outputs.tag }}
generate_release_notes: true
files: |
artifacts/*.dmg
artifacts/*.zip
artifacts/*.AppImage
artifacts/*.deb
artifacts/*.rpm
artifacts/*.exe
artifacts/*.blockmap
artifacts/latest.yml
artifacts/latest-linux.yml
artifacts/latest-mac.ymlTask 17: Validate workflow syntax and commit
- Step 1: Verify YAML parses
Run: python3 -c "import yaml; d = yaml.safe_load(open('.github/workflows/release.yml')); print('jobs:', list(d['jobs'].keys()))"
Expected output: jobs: ['prepare', 'release_mac', 'release_linux', 'release_windows', 'generate_winget', 'publish']
If not, the YAML has a structural issue — open it and verify indentation.
- Step 2 (optional): Run actionlint if installed
Run: command -v actionlint && actionlint .github/workflows/release.yml || echo "actionlint not installed, skipping"
Expected: either no output (lint clean) or "actionlint not installed, skipping". A real lint error must be fixed before commit.
- Step 3: Commit
Run:
git add .github/workflows/release.yml
git status
git commit -m "ci: build Windows NSIS, Fedora RPM, and winget manifests in release workflow"Expected: 1 commit, 1 file changed. Working tree clean.
Phase 4: Documentation
Task 18: Update README install section
Files:
Modify:
README.mdStep 1: Replace the platform table and add notes
Locate the current Install section (lines ~22-37):
## Install
Download the latest build from the [Releases](https://github.com/fathah/hermes-desktop/releases/) page.
| Platform | File |
| -------- | --------------------- |
| macOS | `.dmg` |
| Linux | `.AppImage` or `.deb` |
> **macOS users:** The app is not code-signed or notarized. macOS will block it on first launch. To fix this, run the following after installing:
>
> ```bash
> xattr -cr "/Applications/Hermes Agent.app"
> ```
>
> Or right-click the app → **Open** → click **Open** in the confirmation dialog.Replace the table and add a Linux/Windows notes block. The new section:
## Install
Download the latest build from the [Releases](https://github.com/fathah/hermes-desktop/releases/) page.
| Platform | File |
| -------------- | ----------------------- |
| macOS | `.dmg` |
| Linux (any) | `.AppImage` |
| Linux (Debian) | `.deb` |
| Linux (Fedora) | `.rpm` |
| Windows | `.exe` (NSIS installer) |
### Windows (winget)
Once the manifest has been accepted into [`microsoft/winget-pkgs`](https://github.com/microsoft/winget-pkgs), you can install with:
```powershell
winget install NousResearch.HermesDesktop
```Until then, download the .exe from the Releases page.
Windows users: The installer is not code-signed. Windows SmartScreen will warn on first launch — click "More info" → "Run anyway".
Fedora (RPM)
sudo dnf install ./hermes-desktop-<version>.rpmFedora users: The
.rpmis not GPG-signed. If your system enforces signature checking, append--nogpgcheckto the install command. Auto-update is not supported for.rpmbuilds (limitation ofelectron-updater); reinstall the new.rpmto update.
macOS
macOS users: The app is not code-signed or notarized. macOS will block it on first launch. To fix this, run the following after installing:
bashxattr -cr "/Applications/Hermes Agent.app"Or right-click the app → Open → click Open in the confirmation dialog.
- [ ] **Step 2: Verify the markdown renders sensibly**
Open `README.md` and skim the new Install section. Confirm: table is well-formed, code fences close, no leftover duplicate headings.
### Task 19: Commit README
- [ ] **Step 1: Stage and commit**
Run:
```bash
git add README.md
git status
git commit -m "docs: document Windows (winget) and Fedora (RPM) install paths"
```
/* Detailed source-code truncated for AI context efficiency. */
```bash
gh pr create \
--repo fathah/hermes-desktop \
--base main \
--head Aiacos:feat/winget-rpm-release \
--title "ci: add Windows (winget) and Fedora (RPM) release artifacts" \
--body "$(cat <<'EOF'
## Summary
- Adds a `release_windows` job that builds an NSIS installer (`hermes-desktop-<version>-setup.exe`) on `windows-latest`.
- Adds a `generate_winget` job that fills YAML manifest templates (`build/winget/*.template.yaml`) with the installer SHA256 and uploads them as the `winget-manifests-<version>` CI artifact, ready for manual submission to [`microsoft/winget-pkgs`](https://github.com/microsoft/winget-pkgs).
- Extends the existing `release_linux` job to also build an `.rpm` for Fedora alongside the existing `.AppImage` and `.deb`.
- Adds explicit `oneClick: true` / `perMachine: false` to NSIS (matches electron-builder defaults; pinning prevents future drift) and Linux packaging metadata (`vendor`, `synopsis`, `description`).
- Adds a `dry_run` boolean input to `workflow_dispatch` (default `true`) so the workflow can be tested on a branch without creating tags or releases. Real releases (push to `release` branch) are unaffected.
The winget manifests are deliberately **not** included in the GitHub Release files (operator-facing, not user-facing). To publish to winget, download the `winget-manifests-<version>` artifact from the release run and submit a PR to `microsoft/winget-pkgs`.
Spec: `docs/superpowers/specs/2026-04-30-windows-winget-fedora-rpm-release-design.md`
Plan: `docs/superpowers/plans/2026-04-30-windows-winget-fedora-rpm-release.md`
## Verification
- [x] `npm run lint`
- [x] `npm run typecheck`
- [x] `npm run test` (4 new tests for the manifest generator)
- [x] `npm run build:rpm` produces a valid `.rpm` on Fedora
- [x] `workflow_dispatch` with `dry_run=true` on the fork: all build jobs succeed, `publish` skipped as expected
- [x] Generated winget manifests inspected: no leftover placeholders, valid SHA256, correct URLs
## Notes for the maintainer
- **No code-signing introduced.** Windows SmartScreen will warn on first install; Fedora `dnf` will warn on unsigned RPM. Adding signing is out of scope for this PR (would require certificates / GPG keys in repo secrets).
- **No auto-submit to winget-pkgs.** That would need a GitHub PAT in upstream secrets; the manual submit flow is one `cp -r` from the artifact directory.
- **Fedora `.rpm` does not auto-update** (electron-updater limitation). Documented in README.
## Test plan
- [ ] Maintainer pushes a real release (push to `release` branch); confirms all six jobs run and `publish` produces a GitHub Release with `.exe`, `.rpm`, `.deb`, `.AppImage`, `.dmg`, `.zip`.
- [ ] Maintainer downloads `winget-manifests-<version>` artifact and (optionally) submits to `microsoft/winget-pkgs`.
EOF
)"
```
Expected: `gh` returns the PR URL. Print it back to the user.
- [ ] **Step 3: Print the PR URL**
Done.
---
## Self-review checklist (executed before saving)
**Spec coverage:**
- ✅ Windows NSIS build → Tasks 14, 17
- ✅ Winget manifest generation → Tasks 6, 7, 9, 11, 15
- ✅ Fedora RPM in CI → Task 13
- ✅ Local rpm sanity-check → Task 4
- ✅ `dry_run` workflow_dispatch input → Task 12
- ✅ `publish` gated on `is_dry_run` → Task 16
- ✅ Explicit gh-release files list (replaces `artifacts/*` glob) → Task 16
- ✅ Linux metadata (vendor/synopsis/description) → Task 1
- ✅ Explicit NSIS oneClick/perMachine → Task 2
- ✅ README updates for Windows + Fedora install → Task 18
- ✅ Local verification (lint/typecheck/test) → Task 20
- ✅ CI verification on fork → Tasks 21–23
- ✅ Open PR upstream → Task 24
**Placeholder scan:** No "TBD" or "fill in details" left. The generated PR body in Task 24 has a "Test plan" with unchecked boxes — that is intentional, those are TODOs for the _maintainer_, not for the implementer.
**Type/name consistency:**
- `generateWingetManifests({ rootDir, version, name, publishOwner })` — same signature in test (Task 7), implementation (Task 9), and CLI entrypoint.
- `winget-manifests-${{ needs.prepare.outputs.version }}` — same artifact name in `generate_winget` job (Task 15) and in the CI inspection step (Task 23).
- `is_dry_run` output — defined in Task 12, consumed in Task 16.
- `publishOwner: "fathah"` — same in workflow env (Task 15) and CLI default (Task 9).
---
### Superpowers Plans 2026 07 14 Remote Dashboard Oauth (`docs/superpowers/plans/2026-07-14-remote-dashboard-oauth.md`)
# Remote Dashboard OAuth Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add automatic OAuth browser authentication for direct Remote-mode Hermes dashboards while preserving token Remote mode and SSH behavior.
**Architecture:** A focused Electron main-process module owns the persistent OAuth session, login window, cookie-authenticated REST, and single-use WebSocket tickets. Dashboard resolution selects token or OAuth behavior from `/api/status`; renderer receives only bounded auth state and a fresh connection URL.
**Tech Stack:** Electron 39 (`session`, `net`, `BrowserWindow`), TypeScript 5.9, React 19, Vitest 4, Node HTTP/WebSocket tests, lat.md.
## Global Constraints
- Direct Remote mode only; SSH mode remains unchanged.
- Authentication mode is detected automatically from public `GET /api/status`.
- OAuth cookies and refresh tokens never cross IPC or enter `desktop.json`.
- OAuth WebSocket tickets are freshly minted before every connection attempt.
- OAuth failure never falls back to local gateway or legacy `/v1` transport.
- Existing token Remote mode remains compatible.
- Production code follows failing-test-first TDD.
- Existing untracked `.npmrc` remains untouched.
---
### Task 1: Remote OAuth session boundary
**Files:**
- Create: `src/main/remote-oauth.ts`
- Create: `tests/remote-oauth.test.ts`
**Interfaces:**
- Produces: `REMOTE_OAUTH_PARTITION`, `RemoteOAuthError`, `remoteOAuthSessionState(baseUrl)`, `openRemoteOAuthLogin(baseUrl, parent?)`, `clearRemoteOAuthSession(baseUrl)`, `requestRemoteOAuthJson(url, options?)`, `mintRemoteOAuthWsTicket(baseUrl)`, `buildRemoteOAuthWsUrl(baseUrl, ticket)`.
- Depends on: Electron `app`, `session`, `net`, and `BrowserWindow`; normalized HTTP(S) URL input.
- [ ] **Step 1: Write failing cookie-state and URL tests**
Add tests proving access cookie or refresh cookie means signed in, unrelated cookies do not, HTTPS maps to WSS, HTTP maps to WS, and invalid protocols fail.
```ts
expect(cookiesHaveRemoteOAuthSession([{ name: "hermes_session_rt" }])).toBe(
true,
);
expect(cookiesHaveRemoteOAuthSession([{ name: "other" }])).toBe(false);
expect(buildRemoteOAuthWsUrl("https://host", "once")).toBe(
"wss://host/api/ws?ticket=once",
);
```
- [ ] **Step 2: Verify RED**
Run: `npm test -- --run tests/remote-oauth.test.ts`
Expected: failure because `src/main/remote-oauth.ts` does not exist.
- [ ] **Step 3: Implement session lookup and pure helpers**
Use `persist:hermes-remote-oauth`; recognize bare and host-prefixed `hermes_session_at` / `hermes_session_rt` names; normalize only HTTP(S) URLs; derive ticket WebSocket URL from gateway origin.
- [ ] **Step 4: Write failing authenticated-request and ticket tests**
Inject or mock Electron session/net boundaries. Assert `useSessionCookies: true`, selected persistent session, POST `/api/auth/ws-ticket`, malformed response rejection, and 401 mapping to `RemoteOAuthError` with code `oauth_login_required`.
- [ ] **Step 5: Verify RED, then implement minimal request/ticket behavior**
Run: `npm test -- --run tests/remote-oauth.test.ts`
Implement JSON serialization, timeout/abort, status handling, HTML rejection, ticket validation, and structured errors. Re-run until green.
- [ ] **Step 6: Write failing login lifecycle and scoped sign-out tests**
Assert sandboxed `BrowserWindow` preferences, `/login` navigation, cookie completion, cancellation error `oauth_cancelled`, and removal of only cookies returned for selected gateway URL.
- [ ] **Step 7: Implement login and sign-out; verify Task 1**
Run: `npm test -- --run tests/remote-oauth.test.ts`
Expected: all Task 1 tests pass.
### Task 2: Authentication-mode detection and public IPC
**Files:**
- Modify: `src/main/config.ts`
- Modify: `src/main/ipc/register.ts`
- Modify: `src/preload/index.ts`
- Modify: `src/preload/index.d.ts`
- Modify: `tests/connection-config-security.test.ts`
- Modify: `tests/preload-api-surface.test.ts`
- Create: `tests/remote-auth-mode.test.ts`
**Interfaces:**
- Produces: `RemoteAuthMode = "auto" | "token" | "oauth"`; public `remoteAuthMode`; preload operations `probeRemoteAuthMode`, `remoteOAuthLogin`, `remoteOAuthLogout`, `remoteOAuthSessionState`, `freshDashboardWsUrl`.
- Consumes: Task 1 remote OAuth functions and existing connection-config normalization.
- [ ] **Step 1: Write failing config normalization tests**
Assert missing/unknown values normalize to `auto`; `token` and `oauth` survive; public config exposes mode and boolean state only, never secrets.
- [ ] **Step 2: Verify RED, then add minimal config types**
Run: `npm test -- --run tests/connection-config-security.test.ts tests/remote-auth-mode.test.ts`
Add normalized config field without changing existing configuration semantics.
- [ ] **Step 3: Write failing IPC/preload surface tests**
Assert bounded methods exist, arbitrary partitions/cookie reads do not, URLs are validated in main process, and OAuth endpoints operate only for configured direct Remote URL.
- [ ] **Step 4: Implement IPC/preload bridge and verify Task 2**
Run: `npm test -- --run tests/connection-config-security.test.ts tests/preload-api-surface.test.ts tests/remote-auth-mode.test.ts`
Expected: all Task 2 tests pass.
### Task 3: OAuth-aware dashboard transport
**Files:**
- Modify: `src/main/dashboard.ts`
- Modify: `tests/dashboard-remote.test.ts`
- Modify: `src/preload/index.ts`
- Modify: `src/preload/index.d.ts`
**Interfaces:**
- Produces: OAuth-capable `DashboardConnection` with `authMode`; `freshDashboardWebSocketUrl(profile?)` that returns static token URL or newly minted OAuth ticket URL.
- Consumes: Task 1 OAuth requests/ticket minting; Task 2 configured/detected auth mode.
- [ ] **Step 1: Write failing OAuth dashboard-status tests**
Test public status `{ auth_required: true }`, signed-out result with `needsOAuthLogin`, cookie-authenticated `/api/sessions`, ticket-based WebSocket probe, and no token header/query use.
- [ ] **Step 2: Verify RED**
Run: `npm test -- --run tests/dashboard-remote.test.ts`
Expected: current hard-coded “not wired” result fails OAuth expectations.
- [ ] **Step 3: Implement OAuth connection resolution**
Retain token branch. OAuth branch checks session, requests authenticated sessions, mints one ticket for probe, and returns structured status. SSH branch remains token-only and unchanged.
- [ ] **Step 4: Write failing fresh-ticket tests**
Call `freshDashboardWebSocketUrl()` twice and assert two ticket endpoint calls/two URLs. Token mode returns existing URL without minting.
- [ ] **Step 5: Implement fresh URL function and verify Task 3**
Run: `npm test -- --run tests/dashboard-remote.test.ts tests/dashboard-gateway-client.test.ts`
Expected: all Task 3 tests pass.
### Task 4: Renderer reconnect path and Settings UX
**Files:**
- Modify: `src/renderer/src/screens/Chat/dashboardGatewayClient.ts`
- Modify: `src/renderer/src/screens/Chat/dashboardGatewayClient.test.ts`
- Modify: `src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts`
- Modify: `src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.test.tsx`
- Modify: `src/renderer/src/components/settings/useSettingsData.ts`
- Modify: `src/renderer/src/components/settings/ConnectionPane.tsx`
- Modify: `src/shared/i18n/locales/en/settings.ts`
- Modify corresponding non-English settings locale files with English fallback copy where project convention requires key parity.
- Add or modify focused Settings tests under `src/renderer/src/components/settings/`.
**Interfaces:**
- Consumes: Task 2 OAuth IPC and Task 3 `freshDashboardWsUrl`.
- Produces: automatic token/OAuth Settings states and fresh URL before every renderer WebSocket connection.
- [ ] **Step 1: Write failing reconnect test**
Assert each initial/reconnect client creation asks preload for a fresh URL; OAuth login-required error stops fallback and reaches UI callback; token URL behavior remains valid.
- [ ] **Step 2: Verify RED, implement minimal reconnect change, verify GREEN**
Run: `npm test -- --run src/renderer/src/screens/Chat/dashboardGatewayClient.test.ts src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.test.tsx`
- [ ] **Step 3: Write failing Settings UX tests**
Test automatic probing, hidden API-key input for OAuth, Sign in, Sign out, connected state, cancellation, expiry, URL-change reprobe, and token input for non-OAuth gateway.
- [ ] **Step 4: Implement Settings state and copy**
Keep OAuth logic in `useSettingsData`; keep `ConnectionPane` presentational. Never store ticket/cookie values in React state.
- [ ] **Step 5: Verify Task 4**
Run focused Chat and Settings tests. Expected: all pass.
### Task 5: Architecture/test documentation and integrated verification
**Files:**
- Modify: `lat.md/main-process.md`
- Create or modify: `lat.md/remote-dashboard-oauth.md`
- Add `@lat:` references beside focused source tests.
**Interfaces:**
- Documents: auth split, cookie boundary, ticket lifecycle, no-fallback invariant, and test specifications.
- [ ] **Step 1: Add lat.md architecture and test sections**
Every heading receives a leading paragraph no longer than 250 characters. Test leaf sections map one-to-one to focused tests through `@lat:` comments.
- [ ] **Step 2: Run focused verification**
Run:
```bash
npm test -- --run tests/remote-oauth.test.ts tests/remote-auth-mode.test.ts tests/connection-config-security.test.ts tests/preload-api-surface.test.ts tests/dashboard-remote.test.ts tests/dashboard-gateway-client.test.ts src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.test.tsx
npm run typecheck
```
Expected: zero failures and zero type errors.
- [ ] **Step 3: Run full verification**
Run:
```bash
npm test
npm run build
npm exec --yes --package=lat.md -- lat check
git diff --check
```
Expected: all tests pass, build exits zero, LAT reports all checks passed, diff check exits zero.
- [ ] **Step 4: Inspect final scope**
Confirm `git status --short` contains only intended feature/docs files plus pre-existing untracked `.npmrc`; inspect `git diff --stat` and secret scans for cookie/ticket logging or persistence.
- [ ] **Step 5: Commit implementation**
Stage only intended files and commit with `feat: support OAuth remote dashboards`.
### Task 6: P1 review remediation
**Files:**
- Modify: `src/main/remote-oauth.ts`
- Modify: `src/main/ipc/register.ts`
- Create: `src/main/dashboard-websocket-relay.ts`
- Modify: `src/main/dashboard.ts`
- Modify: `src/main/ws.d.ts`
- Modify: `src/main/app/start.ts`
- Modify: `src/main/hermes.ts`
- Modify: `src/renderer/index.html`
- Modify: `tests/remote-oauth.test.ts`
- Create: `tests/dashboard-websocket-relay.test.ts`
- Modify: `tests/dashboard-csp.test.ts`
- Modify: `src/main/hermes.test.ts`
- Modify: `lat.md/remote-dashboard-oauth.md`
**Interfaces:**
- Produces: post-login config revalidation and a capability-protected, one-shot loopback relay for insecure non-loopback dashboard WebSockets.
- Preserves: current connection changes, direct `wss:` transport, and loopback local/SSH transport.
- [ ] **Step 1: Add failing config-race and CSP/relay tests**
Prove login completion preserves current settings, rejects a changed gateway or mode, suppresses stale bearer headers in OAuth mode, forwards one WebSocket through loopback, rejects an invalid capability, and removes wildcard `ws:` from both CSP layers.
- [ ] **Step 2: Verify RED**
Run focused Vitest files. Expected: missing helper/relay exports and wildcard CSP assertions fail.
- [ ] **Step 3: Implement config revalidation and loopback relay**
Re-read connection configuration after interactive login. Suppress stored bearer keys while OAuth is active. Proxy only non-loopback `ws:` targets; wait for the target handshake before accepting the renderer connection, then bridge frames and close the listener.
- [ ] **Step 4: Verify focused behavior and security invariants**
Run focused tests, Node typecheck, `lat check`, and `git diff --check`. Confirm target URL and ticket never appear in the relay URL.
- [ ] **Step 5: Run integrated verification and restack dependent branches**
Run the full suite and build, commit the review fixes, push PR 853, rebase/push descendant branches with exact leases, then reply to and resolve the two review threads.
---
### Superpowers Plans 2026 08 23 Hermes Agent Enhanced Desktop Roadmap (`docs/superpowers/plans/2026-08-23-hermes-agent-enhanced-desktop-roadmap.md`)
# Hermes Agent Enhanced Desktop Roadmap
**Status:** In progress
**Created:** 2026-08-23
**Last updated:** 2026-08-24
**Upstream reference:** `NousResearch/hermes-agent` desktop and release notes through the audit baseline
**Owner:** hermes-desktop maintainers
## Goal
Track the missing Hermes Agent desktop capabilities that should be implemented in hermes-desktop while keeping this repository an independent, enhanced client for a system-installed Hermes Agent.
## Product boundary
Hermes Agent owns agent execution, gateway protocols, bot behavior, routines, cron execution, voice services, messaging integrations, and agent-side plugins. hermes-desktop owns native windows, orchestration, presentation, local integrations, and user workflows.
The desktop must consume Agent capabilities instead of copying Agent backend logic. The desktop app and Hermes Agent continue to use independent update channels.
## How to use this tracker
- Check an item only after its implementation and focused verification are complete.
- Update the milestone status table and `Last updated` date in the same PR.
- Link the implementing PR or commit in the milestone's Notes section.
- Add or update the relevant `lat.md/` architecture and test sections whenever behavior changes.
- Run the milestone verification commands plus `lat check` before marking a milestone complete.
- Refresh the upstream comparison before beginning a new release train.
Status values are `Not started`, `In progress`, `Blocked`, and `Complete`.
## Milestone status
| ID | Milestone | Depends on | Status |
| --- | ------------------------------------------- | -------------------------------------- | ----------- |
| M0 | Compatibility baseline | — | Complete |
| M1 | Multi-gateway Connections registry | M0 | In progress |
| M2 | Internal contribution framework | M0, M1 | Not started |
| M3 | Artifacts workbench | M2 | Not started |
| M4 | Bot Mode | M1, M2 | Not started |
| M5 | Multi-window, Quick Entry, and HUD | M1 | Not started |
| M6 | Coding workspace | M1, M2 | Not started |
| M7 | Advanced chat steering | M0, M1 | Not started |
| M8 | Conversational voice | M0, M7 | Not started |
| M9 | Automation upgrades | M0, M4 | Not started |
| M10 | External plugin SDK | M2 and at least two built-in consumers | Not started |
| M11 | Performance, themes, and platform hardening | M3–M10 | Not started |
## Already available — do not reimplement
These capabilities are present today and should be extended through their existing seams.
- [x] Skills, Tools, and MCP capability surfaces
- [x] Local, Remote, SSH, and Docker operation
- [x] Profile-aware routing
- [x] Image, text, PDF, path-reference, and drag-and-drop attachments
- [x] Sandboxed web preview with inspect and annotation workflows
- [x] Context-folder and worktree file browser
- [x] Grouped reasoning and tool activity
- [x] Provider and model management
- [x] Kanban and schedules
- [x] Desktop auto-update
- [x] Multiple chat tabs in the main window
- [x] Voice dictation and transcription
## Architecture invariants
- [ ] Every session is addressed by `{ connectionId, profile, sessionId }`.
- [ ] Agent features are enabled through advertised capabilities, not scattered version checks.
- [ ] Secrets remain in the Electron main process or Hermes Agent; renderer APIs expose bounded state only.
- [ ] Local, Remote, SSH, Docker, and cloud routes share one typed connection contract.
- [ ] Built-in and external contributions use one command and pane registry.
- [ ] Secondary windows reuse the normal chat/session state and submission path.
- [ ] Hermes Agent remains the authority for bots, routines, cron, webhooks, and voice execution.
- [ ] Older supported Agent installations continue to provide the current desktop experience.
- [ ] New dependencies are added only where the native platform and current dependencies cannot provide the feature.
---
## Release Train R1 — Compatibility and architecture
R1 creates the identities and extension seams required by every later feature.
### M0 — Compatibility baseline
**Outcome:** The desktop knows what the connected Agent supports and can safely expose newer features without breaking older installations.
#### Implementation
- [x] Define a typed `AgentCapabilities` snapshot.
- [x] Read capabilities from the API capability endpoint and dashboard runtime contract where available.
- [x] Add command-inventory evidence for feature-specific gates that require it.
- [x] Add conservative probes for capabilities not explicitly advertised.
- [x] Cache capabilities per connection and invalidate them after reconnect, connection changes, or Agent update.
- [x] Expose the installed Agent version and minimum/recommended desktop contracts.
- [x] Add an explicit Agent update action while preserving the separate desktop updater.
- [x] Disable the unsafe local update action for direct Remote Agents and provide a host-update hint.
- [x] Avoid using release numbers as the primary feature switch.
#### Verification
- [x] Unit tests cover capability normalization and unknown fields.
- [x] Contract integration tests cover an older Agent and the latest supported Agent.
- [x] A failed capability probe degrades to the existing desktop experience.
- [x] Agent and desktop update actions remain visibly separate.
- [x] Relevant `lat.md/` sections are updated and `lat check` passes.
#### Completion gate
- [x] Older Agent installations can open chats and use all currently supported features.
- [x] New capability surfaces appear automatically when supported by the connected Agent.
#### Notes
- PR/commit: branch `codex/hermes-enhanced-desktop-roadmap`; M0 complete.
- Decisions: capability states are tri-state; missing evidence is `unknown`, never automatically unsupported. Runtime evidence is reduced to five compatibility fields before main-process caching.
- Verified 2026-08-23: `npm test -- --maxWorkers=2` (189 files, 1,906 passed, 3 skipped), `npm run typecheck`, `npm run build`, `npm run lint` (warnings only), and `lat check`.
- Command inventory slice: `commands.catalog` is reduced to bounded canonical names and gates background prompts, queues, tool-call steering, session loops, voice commands, and automation blueprints without version guesses.
- Integration coverage uses checked-in representative Agent payloads so CI does not depend on a sibling checkout or live Agent process.
- Completion evidence: unknown capabilities preserve the existing chat/fallback paths, while supported runtime, API, and command evidence automatically populates the typed snapshot and Settings contract status.
### M1 — Multi-gateway Connections registry
**Outcome:** Multiple local and remote Hermes machines can remain connected, and each chat is permanently routed to the correct machine and profile.
#### Data and migration
- [x] Replace the singleton connection record with a versioned connection registry.
- [x] Assign every connection a stable `connectionId` and user-visible name.
- [x] Migrate the existing Local/Remote/SSH configuration without losing credentials or preferences.
- [x] Introduce `SessionLocation = { connectionId, profile, sessionId }`.
- [x] Add `connectionId` to chat-run identity and persisted desktop session metadata.
- [x] Keep API keys, OAuth cookies, SSH secrets, and tunnel credentials outside renderer state.
#### Connection behavior
- [x] Support named Local, Remote/cloud, and SSH connections, including SSH Docker targets.
- [x] Track health, latency, authentication state, Agent version, and capabilities per connection.
- [ ] Allow multiple connections to stay active simultaneously.
- [ ] Route sessions, metadata, attachments, models, and commands through the session's connection.
- [ ] Reconnect one connection without interrupting unrelated connections.
- [x] Preserve the existing single unified SSH dashboard/tunnel invariant per SSH machine.
- [ ] Add connection create, edit, test, reconnect, remove, and select workflows. Create, edit, test, remove, and select are complete; isolated reconnect remains.
- [ ] Add a fleet overview with connection-specific update actions.
Progress (2026-09-02): Settings now manages redacted named records through main-process CRUD IPC and exposes manual per-record health, latency, authentication, version, and capability snapshots. Dashboard and legacy Local/direct-Remote chats keep an immutable saved connection while other records are selected, including URL, authentication, capability probes, fallbacks, cancellation, resumed transcript reads, and live transcript reconciliation. Sidebar and full-list browsing now route list, cache sync, pagination, search, rename, single delete, and bulk delete through the selected connection and profile; Local cache and database access retain that explicit profile, while inactive SSH operations cannot retarget the global tunnel. Remaining desktop metadata, attachments, model/command routing, isolated reconnect, and true simultaneous connection lifecycles are pending.
#### Verification
- [x] Migration tests cover existing local, direct remote, and SSH configurations.
- [x] Routing tests prove profile and connection identities cannot cross.
- [ ] Reconnect tests prove one failing connection does not reset other chat runs.
- [x] Security tests prove credentials never cross preload IPC.
- [ ] Local plus two remote chats can stream concurrently.
- [x] Relevant `lat.md/` sections are updated and `lat check` passes.
#### Completion gate
- [ ] Restarting the desktop restores every connection and reopens chats against their original locations.
#### Notes
- PR/commit: branch `codex/hermes-enhanced-desktop-roadmap`; M1 registry foundation in progress.
- Decisions: version 1 lives inside the existing `desktop.json`; current callers keep using `getConnectionConfig()` as an active-record adapter. Session locations use a credential-free global desktop metadata file instead of the active profile's Agent database, so background runs cannot persist under the wrong profile. Status refresh is initial/manual rather than polling so saved SSH targets do not spawn recurring background probes.
- Verified 2026-08-24: `npm test -- --reporter=json --maxWorkers=2` (191 files, 1,921 passed, 3 skipped), `npm run typecheck`, focused ESLint, and `npm run build`. Full multi-connection routing remains pending.
- Verified 2026-09-02: connection-explicit session browsing passed 192 focused tests across renderer, cache, database deletion, and preload contracts; focused ESLint and the production build also pass.
### M2 — Internal contribution framework
**Outcome:** New desktop features can register UI and commands without expanding central layout and IPC switches.
#### Contribution contract
- [ ] Add a small typed registry for navigation items, panes/pages, commands, keybindings, and status items.
- [ ] Reuse the existing slash-command router for command execution.
- [ ] Namespace contribution IDs and record their source.
- [ ] Return disposers from registrations so contributions can unload cleanly.
- [ ] Define ordering, visibility, capability requirements, and activation rules.
- [ ] Add minimal theme-token contribution support without allowing arbitrary global CSS initially.
#### First built-in consumers
- [ ] Register Artifacts through the contribution framework.
- [ ] Register Bot Mode through the contribution framework.
- [ ] Register Coding workspace panes and commands through the contribution framework.
- [ ] Remove the corresponding one-off central layout cases after migration.
#### IPC organization
- [ ] Split only the touched sections of the main IPC registry into domain registrars.
- [ ] Keep one narrow context-isolated preload contract.
- [ ] Add preload surface tests for every new domain.
#### Verification
- [ ] Registry tests cover duplicate IDs, ordering, disposal, and capability gating.
- [ ] A sample built-in contribution adds a pane and command without editing the central layout switch.
- [ ] Disabling a contribution removes all of its registrations and listeners.
- [ ] Relevant `lat.md/` sections are updated and `lat check` passes.
#### Completion gate
- [ ] At least three built-in features use the framework before the external SDK is finalized.
#### Notes
- PR/commit:
- Decisions:
---
## Release Train R2 — Agent workbench
R2 surfaces substantial Agent output and multi-agent collaboration in the desktop.
### M3 — Artifacts workbench
**Outcome:** Generated content becomes a versioned, attributable work product instead of being trapped inside transcript messages.
#### Detection and identity
- [ ] Detect HTML, SVG, Markdown, code, diagrams, and generated files.
- [ ] Derive stable artifact identity from session, kind, language, and title/slug.
- [ ] Deduplicate repeated streaming renders using a content hash.
- [ ] Associate every artifact version with connection, profile, session, message, and timestamp.
- [ ] Treat transcript content/files as the durable source and store only required index metadata.
#### Experience
- [ ] Add inline artifact cards without hijacking the active pane during streaming.
- [ ] Add session and global artifact galleries.
- [ ] Add version selection and comparison.
- [ ] Reuse the existing file viewer and web-preview pane.
- [ ] Sandbox HTML/SVG previews and keep active scripts disabled by default.
- [ ] Add copy, export, reveal, reopen, and provenance actions.
- [ ] Prune indexes and in-memory content using documented limits.
#### Verification
- [ ] Tests cover detection, stable identity, deduplication, and version ordering.
- [ ] Reloading a transcript rebuilds its artifacts and history.
- [ ] Untrusted artifact content cannot access Node, preload APIs, or unrestricted navigation.
- [ ] Large artifacts do not enter localStorage or block transcript rendering.
- [ ] Relevant `lat.md/` sections are updated and `lat check` passes.
#### Completion gate
- [ ] An artifact generated three times appears once with three selectable versions and complete provenance.
#### Notes
- PR/commit:
- Decisions:
### M4 — Bot Mode
**Outcome:** Users can operate Hermes Agent bots, rooms, routines, and handoffs from the standalone desktop across multiple machines.
#### Agent contract
- [ ] Inventory the current Bot Mode RPC, event, REST, plugin, and routine contracts.
- [ ] Keep bot execution, routing, rooms, and routine state authoritative in Hermes Agent.
- [ ] Define capability-gated desktop adapters for supported Agent versions.
- [ ] Document fallback behavior when Bot Mode is unavailable.
#### Experience
- [ ] Add Sessions/Bots navigation.
- [ ] Add a bot roster with avatar, status, current work, connection, and last activity.
- [ ] Add one-to-one bot chats.
- [ ] Add group rooms.
- [ ] Add `@agent` mentions and explicit handoffs.
- [ ] Render bot and handoff events in the normal ordered transcript timeline.
- [ ] Add routine and schedule management by extending the existing schedule UI.
- [ ] Reuse Agent Sync persona, color, memory, and model metadata.
- [ ] Support bots located on different registered connections.
#### Verification
- [ ] Tests cover roster updates, mentions, handoffs, room membership, and reconnect.
- [ ] Older Agents hide or explain Bot Mode without affecting normal sessions.
- [ ] Cross-connection bot events cannot be applied to the wrong room.
- [ ] Two bots on different connections can participate in one supported room workflow.
- [ ] Relevant `lat.md/` sections are updated and `lat check` passes.
#### Completion gate
- [ ] Bot status, routines, messages, and handoff history survive desktop restart through Agent-owned state.
#### Notes
- PR/commit:
- Decisions:
---
## Release Train R3 — Native desktop power
R3 adds operating-system windows and a focused coding workflow without creating parallel state systems.
### M5 — Multi-window, Quick Entry, and HUD
**Outcome:** Sessions can be viewed and controlled from multiple native windows, including a global lightweight prompt surface.
#### Window foundation
- [ ] Add a main-process window registry keyed by `SessionLocation`.
- [ ] Centralize secure web preferences for every chat-capable window.
- [ ] Pop out an existing chat and focus an existing pop-out instead of duplicating it.
- [ ] Add complete secondary application windows.
- [ ] Persist safe window geometry and constrain restored bounds to available displays.
- [ ] Keep background streaming active only while work is running; restore idle throttling afterward.
#### Quick Entry and HUD
- [ ] Add a configurable global Quick Entry shortcut with accelerator validation.
- [ ] Detect and report shortcut registration conflicts.
- [ ] Forward Quick Entry through the normal prompt-submission path.
- [ ] Add connection/profile/session target selection.
- [ ] Add a compact HUD for running agents, approvals, and voice state.
- [ ] Ensure Escape, blur, and submission behavior are predictable and accessible.
#### Verification
- [ ] Window registry tests cover focus-or-create and cleanup.
- [ ] URL/route tests preserve full connection/profile/session identity.
- [ ] A stream continues while its window is hidden, blurred, or minimized.
- [ ] Idle hidden windows do not keep active polling or animation timers.
- [ ] Shortcut tests cover invalid, reserved, conflicting, disabled, and valid accelerators.
- [ ] Relevant `lat.md/` sections are updated and `lat check` passes.
#### Completion gate
- [ ] The same chat can move between the main window and a pop-out without message duplication or stream loss.
#### Notes
- PR/commit:
- Decisions:
### M6 — Coding workspace
**Outcome:** A session's context folder or worktree can be inspected, executed, reviewed, committed, and published from the desktop.
#### Terminal
- [ ] Add `xterm` and `node-pty` only when this milestone starts.
- [ ] Add persistent terminal tabs scoped to connection, session, and worktree.
- [ ] Keep PTY ownership in the main process and expose only create/write/resize/close/data IPC.
- [ ] Validate working directories against the selected context/worktree.
- [ ] Add local terminal support.
- [ ] Add SSH and Docker terminal transports using the existing connection routing.
- [ ] Restore terminal metadata after restart without pretending dead processes survived.
- [ ] Close processes cleanly during session deletion and app shutdown.
#### Git review and worktrees
- [ ] Detect repository and worktree state from the selected context folder.
- [ ] Add changed-files tree and staged/unstaged/branch/last-turn scopes.
- [ ] Add safe text and binary diff views.
- [ ] Add stage, unstage, revert, commit, fetch, pull, and push.
- [ ] Add branch and worktree creation and switching.
- [ ] Add GitHub PR creation when the `gh` CLI is installed and authenticated.
- [ ] Use `execFile` with validated executable, arguments, repository, and relative paths; do not build shell commands.
- [ ] Add remote Git operations through the existing SSH/Docker transport rather than assuming local paths.
#### Verification
- [ ] PTY tests cover lifecycle, resize, exit, and invalid working directories.
- [ ] Git tests cover spaces, renames, binary files, untracked files, and non-repositories.
- [ ] Path-validation tests prevent traversal outside the selected repository/worktree.
- [ ] Mutating actions require explicit user intent and produce actionable errors.
- [ ] A worktree can be reviewed, selectively staged, committed, pushed, and opened as a PR.
- [ ] Relevant `lat.md/` sections are updated and `lat check` passes.
#### Completion gate
- [ ] Local and remote coding flows use the same renderer experience with transport-specific main-process adapters.
#### Notes
- PR/commit:
- Decisions:
---
## Release Train R4 — Interaction and autonomy
R4 improves control of active turns, voice conversations, and recurring work.
### M7 — Advanced chat steering
**Outcome:** Users can redirect active work and edit upcoming work without corrupting the ordered session timeline.
#### Steering and queue
- [ ] Add active-turn redirect when supported by the gateway.
- [ ] Add editable queued prompts.
- [ ] Add queue reorder and removal.
- [ ] Add composer history navigation and undo.
- [ ] Define explicit fallback behavior for Agents without redirect/queue RPCs.
#### Timeline and clarification
- [ ] Add multi-question clarification cards.
- [ ] Preserve answers and unresolved questions across reconnect.
- [ ] Add transcript find and an event timeline.
- [ ] Add jump targets for reasoning, tools, artifacts, approvals, handoffs, and user edits.
- [ ] Keep live and restored rows projected through the same ordered event normalizer.
#### Verification
- [ ] State-machine tests cover redirect during reasoning, tools, and message streaming.
- [ ] Queue tests cover edit, reorder, delete, reconnect, and submission failure.
- [ ] Redirect never duplicates user messages or assistant output.
- [ ] Timeline search works on long and restored transcripts.
- [ ] Relevant `lat.md/` sections are updated and `lat check` passes.
#### Completion gate
- [ ] A busy session can be redirected and its remaining queue edited without losing or duplicating any turn.
#### Notes
- PR/commit:
- Decisions:
### M8 — Conversational voice
**Outcome:** Dictation becomes an interruptible, hands-free conversation while preserving explicit privacy controls.
#### Voice pipeline
- [ ] Keep the current dictation/transcription path as the push-to-talk fallback.
- [ ] Add streaming TTS playback through Agent-supported voice services.
- [ ] Add explicit listening, transcribing, thinking, speaking, and interrupted states.
- [ ] Add automatic turn-taking.
- [ ] Add barge-in that stops playback and redirects or interrupts the active turn.
- [ ] Add per-profile voice, speed, device, and hands-free settings.
- [ ] Add optional wake-word support only when explicitly enabled and supported.
- [ ] Ensure first-turn playback works in voice-started windows without weakening other security settings.
#### Privacy and resilience
- [ ] Show persistent microphone state and a one-action stop control.
- [ ] Never start always-on listening without user opt-in.
- [ ] Handle microphone denial, device removal, TTS failure, and reconnect.
- [ ] Prevent old audio from playing after redirect, session switch, or interruption.
#### Verification
- [ ] Tests cover voice state transitions and stale-audio cancellation.
- [ ] Barge-in stops playback before submitting the interruption.
- [ ] Push-to-talk continues working when conversational voice is unavailable.
- [ ] Wake-word mode remains off after fresh installation and profile creation.
- [ ] Relevant `lat.md/` sections are updated and `lat check` passes.
#### Completion gate
- [ ] A user can interrupt a spoken response and continue the same session without overlapping audio or duplicate prompts.
#### Notes
- PR/commit:
- Decisions:
### M9 — Automation upgrades
**Outcome:** Existing schedules gain reusable blueprints, durable context, richer reasoning controls, and secure outbound notifications.
#### Automation experience
- [ ] Add Cron Blueprint creation, editing, import, export, and instantiation.
- [ ] Add per-job model and reasoning settings.
- [ ] Expose Agent-owned persistent cron memory and execution context.
- [ ] Link scheduled jobs to bots and routines.
- [ ] Add run history, output, duration, retry, duplicate, pause, and test-run actions.
- [ ] Add signed outbound webhook configuration through Hermes Agent.
- [ ] Keep webhook secrets, signature generation, retries, and delivery in Hermes Agent.
- [ ] Gate fields and actions by the selected connection's capabilities.
#### Verification
- [ ] Blueprint round-trip tests preserve all supported job settings.
- [ ] Webhook secrets and signatures never enter desktop persistence or logs.
- [ ] Run history is scoped to the correct connection, profile, bot, and job.
- [ ] Older Agents retain the current schedules experience.
- [ ] Relevant `lat.md/` sections are updated and `lat check` passes.
#### Completion gate
- [ ] A blueprint can create a scheduled Bot routine whose signed webhook execution is visible in desktop history.
#### Notes
- PR/commit:
- Decisions:
---
## Release Train R5 — Ecosystem and hardening
R5 opens the proven contribution seam to trusted plugins and completes cross-platform performance and polish work.
### M10 — External plugin SDK
**Outcome:** Trusted plugins can extend the desktop and their Hermes Agent backend without changing core desktop files.
#### Plugin lifecycle
- [ ] Finalize the SDK only after at least two substantial built-in consumers validate it.
- [ ] Support bundled and explicitly trusted on-disk plugins.
- [ ] Add discovery, inventory, enable, disable, error, and reload states.
- [ ] Add development hot reload with complete disposer cleanup.
- [ ] Namespace contribution IDs, storage, translations, and Agent endpoints by plugin ID.
- [ ] Record plugin provenance for every contribution and OS action.
#### Curated host capabilities
- [ ] Expose contribution registration through a scoped plugin context.
- [ ] Expose namespaced persistence.
- [ ] Expose profile/connection-aware plugin REST and socket access.
- [ ] Expose bounded notifications, clipboard, reveal-path, and open-URL operations.
- [ ] Do not expose raw Electron, unrestricted filesystem access, or arbitrary IPC.
- [ ] Validate URLs and paths in the main process.
- [ ] Define desktop/Agent capability and version requirements in plugin metadata.
#### Distribution and documentation
- [ ] Document the trusted-plugin security model and installation consent.
- [ ] Add an example plugin repository or in-tree fixture.
- [ ] Document contribution APIs, lifecycle, storage, Agent APIs, testing, and packaging.
- [ ] Add a compatibility and deprecation policy before declaring the SDK stable.
#### Verification
- [ ] Tests cover discovery, duplicate IDs, toggles, activation failure, disposal, and reload.
- [ ] A plugin can add a pane, command, keybinding, status item, and Agent-backed live view.
- [ ] A disabled plugin leaves no listeners, sockets, timers, commands, or UI behind.
- [ ] Relevant `lat.md/` sections are updated and `lat check` passes.
#### Completion gate
- [ ] The example plugin works without any core-file edits and cannot access capabilities outside the documented host API.
#### Notes
- PR/commit:
- Decisions:
### M11 — Performance, themes, and platform hardening
**Outcome:** The expanded desktop remains responsive, quiet at idle, accessible, secure, and supportable across packaged platforms.
#### Performance
- [ ] Record baseline startup, idle CPU, active streaming, memory, and long-transcript measurements.
- [ ] Eliminate active polling and animation timers in idle hidden windows.
- [ ] Use CSS/compositor-driven progress indicators.
- [ ] Add transcript virtualization only where measurements show it is needed.
- [ ] Evaluate React Compiler only after profiling and compatibility checks.
- [ ] Add performance regression coverage for long transcripts, multiple windows, terminals, bots, and voice.
#### Appearance and accessibility
- [ ] Add glass/frost options with an accessible solid-background fallback.
- [ ] Add plugin-provided theme tokens through the contribution framework.
- [ ] Verify keyboard operation for every new pane, dialog, queue, and window.
- [ ] Respect reduced motion and platform contrast preferences.
- [ ] Verify screen-reader labels and focus restoration.
#### Security and packaging
- [ ] Re-audit CSP, context isolation, sandboxing, navigation, URLs, paths, terminals, Git, artifacts, and plugins.
- [ ] Run packaged smoke tests on macOS, Windows, and Linux.
- [ ] Verify signing, notarization, updater feeds, and independent Agent update behavior.
- [ ] Publish a desktop-to-Agent compatibility matrix.
- [ ] Publish user-facing migration notes and release notes for every release train.
#### Verification
- [ ] `npm run typecheck` passes.
- [ ] `npm test` passes.
- [ ] `npm run build` passes.
- [ ] Packaged platform smoke tests pass.
- [ ] `lat check` passes.
- [ ] `git diff --check` passes.
#### Completion gate
- [ ] The app has no persistent idle work, maintains responsive streaming under the agreed stress fixture, and passes accessibility and security review on all supported platforms.
#### Notes
- PR/commit:
- Decisions:
---
## Release gates
Each release train must satisfy these checks before merging into a stable release.
- [ ] Focused unit and integration tests pass.
- [ ] Main/preload IPC contract tests pass.
- [ ] Local, direct Remote, SSH, and Docker smoke paths pass where applicable.
- [ ] Compatibility checks pass against the oldest supported and latest recommended Hermes Agent.
- [ ] Critical Playwright flows pass in a packaged build.
- [ ] No renderer exposure of credentials, cookies, webhook secrets, PTY handles, or unrestricted filesystem operations.
- [ ] User-visible changes have release notes and upgrade guidance.
- [ ] Architecture and test behavior are documented in `lat.md/`.
- [ ] `lat check`, typecheck, tests, build, and `git diff --check` pass.
## Upstream refresh checklist
Run this before beginning each release train so the roadmap does not implement an already-replaced protocol.
- [ ] Fetch the latest `NousResearch/hermes-agent` release notes and desktop changes.
- [ ] Record the inspected release/tag/commit and date below.
- [ ] Compare capability contracts before comparing UI implementation.
- [ ] Update this roadmap when upstream removes, renames, or supersedes a feature.
- [ ] Prefer the current Hermes Agent public contract over copying upstream desktop internals.
| Inspected on | Upstream release/tag/commit | Roadmap impact |
| ------------ | ------------------------------------------------------------ | --------------- |
| 2026-08-23 | Audit baseline through the previously reviewed release notes | Initial roadmap |
## Upstream behavioral references
These paths are behavioral references in the sibling Hermes Agent checkout, not code-copy requirements.
- `../hermes-agent/apps/desktop/electron/connection-config.ts`
- `../hermes-agent/apps/desktop/src/contrib/plugin.ts`
- `../hermes-agent/apps/desktop/src/contrib/plugins.ts`
- `../hermes-agent/apps/desktop/src/store/artifacts.ts`
- `../hermes-agent/apps/desktop/electron/session-windows.ts`
- `../hermes-agent/apps/desktop/electron/quick-entry.ts`
- `../hermes-agent/apps/desktop/electron/git-review-ops.ts`
- `../hermes-agent/apps/desktop/src/lib/voice-barge-in.ts`
- `https://github.com/NousResearch/hermes-agent/releases`
## Deferred until evidence requires them
These are intentionally not prerequisites for the roadmap.
- A second renderer state system for secondary windows
- A desktop-owned Bot Mode protocol
- Desktop-owned webhook execution or secret storage
- Arbitrary untrusted renderer plugins
- A custom Git parser framework or terminal protocol when the platform tools suffice
- React Compiler adoption without a measured performance case
- Rewriting existing attachments, preview, worktree, schedules, or command systems
---
### Superpowers Specs 2026 04 30 Windows Winget Fedora Rpm Release Design (`docs/superpowers/specs/2026-04-30-windows-winget-fedora-rpm-release-design.md`)
# Windows (winget) and Fedora (RPM) release automation
**Status:** Approved (brainstorming) — pending implementation plan
**Date:** 2026-04-30
**Branch target:** `Aiacos/hermes-desktop:feat/winget-rpm-release` → PR upstream `fathah/hermes-desktop:main`
## Goal
Extend the existing release pipeline so it produces:
1. **Windows artifacts** — an NSIS installer (`.exe`) published in each GitHub Release, plus winget manifests (`Installer.yaml`, `Locale.en-US.yaml`, `Version.yaml`) generated as a CI artifact for manual submission to `microsoft/winget-pkgs`.
2. **Fedora artifacts** — an unsigned `.rpm` published in each GitHub Release, alongside the existing `.AppImage` and `.deb`.
No changes to macOS. The existing `.AppImage` / `.deb` artifacts are rebuilt by the same job that now also produces `.rpm`; the only adjacent change touching them is shared `linux.*` metadata (`synopsis`, `description`, `vendor`) which improves their packaging metadata too. No code-signing introduced (none available). No COPR repository, no auto-submission to `microsoft/winget-pkgs`.
## Non-goals
- Code signing for Windows (no certificate available; SmartScreen warning persists).
- macOS notarization changes.
- GPG-signing the RPM (no signing key available; users install with `sudo dnf install ./file.rpm` or `--nogpgcheck`).
- A Fedora COPR repository.
- Automated PR submission to `microsoft/winget-pkgs` (would require a GitHub PAT in upstream secrets, which the PR author does not control).
- Auto-update support for `.rpm` (limitation of `electron-updater`).
- Windows ARM64 build (requires extra toolchain; nobody is testing it).
## Architecture
The existing `release.yml` workflow is extended in place. Two new jobs are added; one existing job is extended; a `dry_run` input is added to the manual-dispatch trigger.
```
release.yml (extended)
├─ prepare [ubuntu] unchanged + new is_dry_run output
├─ release_mac [macos] unchanged
├─ release_linux [ubuntu] ADD: rpm to electron-builder targets, install rpm apt package
├─ release_windows [windows] NEW: NSIS .exe + .blockmap + latest.yml
├─ generate_winget [ubuntu] NEW: SHA256 the .exe, fill manifest templates, upload as artifact
└─ publish [ubuntu] gated: skip if is_dry_run == 'true'
```
### Triggers
- `push` to branch `release` — unchanged. Behaves as a real release: builds run, publish runs.
- `workflow_dispatch` — adds `inputs.dry_run` (boolean, default `true`). When `dry_run=true`, all build jobs run; `publish` is skipped.
The default-true on dispatch is a safety net: a stray click in the Actions UI cannot trigger a real release.
### Identifiers and naming
- **Winget `PackageIdentifier`:** `NousResearch.HermesDesktop` (stable, never renamed)
- **Winget `Publisher`:** `Nous Research` (free-text; binaries hosted under `fathah/hermes-desktop`, which the moderation review will verify against the `InstallerUrl`)
- **Winget `PackageName`:** `Hermes Agent` (matches `productName` in `electron-builder.yml`)
- **NSIS scope:** `oneClick: true`, `perMachine: false` — installs into `%LOCALAPPDATA%`, no UAC prompt, aligns with `winget install` default user scope and with the app's existing `~/.hermes` user-state model.
- **RPM artifact name:** `hermes-desktop-<version>.rpm` (no spaces, no arch suffix — consistent with the existing `.deb` and `.AppImage` naming. The default `${productName}` would produce `Hermes Agent-...rpm` which breaks `dnf install ./file.rpm`. We explicitly only build `x86_64`, so the missing arch suffix is unambiguous in practice.).
## File changes
### Modified
#### `.github/workflows/release.yml` (~+120 / -10 lines)
- Add `workflow_dispatch.inputs.dry_run` (boolean, default `true`).
- Add `prepare.outputs.is_dry_run` computed as `${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}` (string `'true'`/`'false'`).
- Add new job `release_windows`:
- `runs-on: windows-latest`
- `needs: prepare`
- `if: needs.prepare.outputs.tag_exists == 'false'`
- Steps: checkout, setup-node 22 with `cache: npm`, `npm ci`, `npm run build`, `npx electron-builder --win nsis --x64 --publish never`, upload `dist/*.exe`, `dist/*.exe.blockmap`, `dist/latest.yml` as artifact `windows-artifacts`.
- Modify existing `release_linux`:
- Add `sudo apt-get update && sudo apt-get install -y rpm` step before packaging.
- Change packaging command to `npx electron-builder --linux AppImage deb rpm --publish never`.
- Extend artifact upload paths to include `dist/*.rpm`.
- Add new job `generate_winget`:
- `runs-on: ubuntu-latest`
- `needs: [prepare, release_windows]`
- `if: needs.prepare.outputs.tag_exists == 'false'`
- Steps: checkout, download `windows-artifacts` to `dist/`, run `node scripts/generate-winget-manifests.mjs` with env `VERSION` and `PUBLISH_OWNER=fathah`, upload `dist/winget/` as artifact `winget-manifests-${{ needs.prepare.outputs.version }}`.
- Modify `publish`:
- `needs: [prepare, release_mac, release_linux, release_windows, generate_winget]`
- `if: needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.tag_exists == 'false'`
- `download-artifact` step keeps `merge-multiple: true` and downloads into `artifacts/`. The winget manifests artifact ends up under `artifacts/winget/manifests/...` and is **deliberately excluded** from the GitHub Release files (manifests are operator-facing, not user-facing).
- The `softprops/action-gh-release` `files` input is changed from the existing `artifacts/*` glob to an **explicit list of patterns** that names each user-facing artifact: `artifacts/*.dmg`, `artifacts/*.zip`, `artifacts/*.AppImage`, `artifacts/*.deb`, `artifacts/*.rpm`, `artifacts/*.exe`, `artifacts/*.blockmap`, `artifacts/latest*.yml`. This is deterministic regardless of how `merge-multiple` lays out subdirectories, and prevents future artifacts from leaking into releases by accident.
Concurrency block (`group: release`, `cancel-in-progress: true`) remains unchanged. **Caveat (pre-existing, not introduced by this change):** with `cancel-in-progress: true`, a _new_ run cancels the _currently running_ one in the same group. So a stray `workflow_dispatch` triggered during a real release would cancel the release. This risk is low (dispatches are manual; the dispatch defaults to `dry_run=true` which would not produce a tag anyway, but the cancellation of the in-flight real release is the actual hazard). Not addressed here to keep scope focused; flagged for a follow-up that scopes the concurrency group by `github.event_name`.
#### `electron-builder.yml` (~+15 lines)
- `linux.target`: add `rpm` (existing `AppImage`, `snap`, `deb` retained).
- `linux.synopsis`: short one-line description (required by fpm/rpmbuild for valid RPM metadata).
- `linux.description`: longer description.
- `linux.vendor`: `Nous Research` (or repo owner of upstream; finalized during implementation).
- New `rpm:` block with `artifactName: ${name}-${version}.${ext}`.
- `nsis:` block extended with explicit `oneClick: true` and `perMachine: false` (currently relies on electron-builder defaults; making them explicit prevents silent behavior change across electron-builder versions).
### New
#### `build/winget/Installer.template.yaml`
YAML manifest with placeholders: `{{VERSION}}`, `{{INSTALLER_URL}}`, `{{INSTALLER_SHA256}}`, `{{RELEASE_DATE}}`. Format follows winget v1.6 schema, `InstallerType: nullsoft`, `Scope: user`, `MinimumOSVersion: 10.0.17763.0` (Windows 10 1809 — minimum supported by Electron 39).
#### `build/winget/Locale.en-US.template.yaml`
Locale manifest with placeholders: `{{VERSION}}`, `{{RELEASE_NOTES_URL}}`. Includes `Publisher: Nous Research`, `PublisherUrl: https://github.com/fathah/hermes-desktop`, `PackageName: Hermes Agent`, `License: MIT`, `LicenseUrl: https://github.com/fathah/hermes-desktop/blob/main/LICENSE`, `ShortDescription`, `Tags: [ai, agent, desktop, electron, llm]`.
#### `build/winget/Version.template.yaml`
Root version manifest with placeholders: `{{VERSION}}`. Trivial: `PackageIdentifier`, `PackageVersion`, `DefaultLocale: en-US`, `ManifestType: version`, `ManifestVersion: 1.6.0`.
#### `scripts/generate-winget-manifests.mjs`
Node ESM script (~50 lines, zero external deps). Reads `package.json` to get `version` and `name`. Locates `dist/<name>-<version>-setup.exe`. Computes SHA256 with `node:crypto` `createHash('sha256')` over the file. Reads each `*.template.yaml` from `build/winget/`. Replaces all `{{KEY}}` placeholders by string `replaceAll`. Writes output to `dist/winget/manifests/n/NousResearch/HermesDesktop/<version>/`:
- `NousResearch.HermesDesktop.installer.yaml`
- `NousResearch.HermesDesktop.locale.en-US.yaml`
- `NousResearch.HermesDesktop.yaml`
The path mirrors the directory layout in `microsoft/winget-pkgs`, so the operator submitting the PR can `cp -r` directly.
Exit code 1 with explicit error if the `.exe` is not found.
#### `package.json`
Add script `"build:rpm": "npm run build && electron-builder --linux rpm"` for local Fedora developers. CI does not use this script (it calls `npx electron-builder` directly).
#### `README.md`
Update the Install section's platform table to add Windows (`.exe` and, once accepted into winget-pkgs, `winget install NousResearch.HermesDesktop`) and Fedora (`.rpm`). Add a note that:
- The Windows build is unsigned; Windows SmartScreen will warn on first launch.
- The `.rpm` is unsigned; install with `sudo dnf install ./hermes-desktop-<version>.x86_64.rpm` (or use `--nogpgcheck` if a system policy enforces signature checking).
- Auto-update on Linux is supported only for `.AppImage` builds; `.rpm` and `.deb` users must download new releases manually.
## Data flow
### CI build (Windows)
```
checkout
→ npm ci → node_modules + electron-builder install-app-deps
→ npm run build → out/main + out/preload + out/renderer
→ electron-builder --win nsis --x64
→ dist/hermes-desktop-<version>-setup.exe
→ dist/hermes-desktop-<version>-setup.exe.blockmap
→ dist/latest.yml
→ upload-artifact "windows-artifacts"
```
### CI generate winget manifests
```
download-artifact "windows-artifacts" → dist/
node scripts/generate-winget-manifests.mjs
→ SHA256(dist/hermes-desktop-<version>-setup.exe)
→ fill 3 templates with VERSION, INSTALLER_URL, INSTALLER_SHA256, RELEASE_DATE
→ write dist/winget/manifests/n/NousResearch/HermesDesktop/<version>/*.yaml
upload-artifact "winget-manifests-<version>"
```
### CI build (Linux)
```
checkout
→ npm ci
→ npm run build
→ apt install rpm
→ electron-builder --linux AppImage deb rpm
→ dist/hermes-desktop-<version>.AppImage
→ dist/hermes-desktop-<version>.deb
→ dist/hermes-desktop-<version>.x86_64.rpm
→ dist/latest-linux.yml
→ upload-artifact "linux-artifacts"
```
### Publish (only on real release)
```
download-artifact merge-multiple=true → artifacts/
*.dmg, *.zip, *.AppImage, *.deb, *.rpm, *.exe, *.blockmap, latest-*.yml, latest.yml
+ artifacts/winget/manifests/... (excluded from release files glob)
git tag v<version> + push
softprops/action-gh-release files=artifacts/* (excludes winget/ subdir)
```
## Error handling and edge cases
1. **Missing NSIS installer in `generate_winget` job:** The script exits with code 1 and a clear message. Failure is loud, not silent.
2. **`rpm` package missing on Linux runner:** electron-builder fails with a clear "Cannot find rpmbuild" error. We pre-install via apt, so this should not surface.
3. **Tag already exists:** All build jobs gate on `tag_exists == 'false'`; new jobs replicate this guard.
4. **Concurrency cancellation (pre-existing behavior):** With `cancel-in-progress: true`, a new run in the same group cancels the currently running one. A stray dispatch during a real release would cancel the release. Mitigation: dispatches are manual and rare; the safer fix (per-event-type concurrency group) is intentionally out of scope here.
5. **`workflow_dispatch` on a branch other than `release`:** Allowed and intended — that is how E2 verification works. The `prepare` step still reads version from `package.json`, so it builds whatever version is on the branch. Real releases only happen on `release` branch (no `is_dry_run` short-circuit there because `is_dry_run` is `false` for push events by definition).
6. **Operator submits stale manifests:** The CI artifact name includes the version (`winget-manifests-<version>`), so it cannot be confused with a different release's manifests. The operator copies the directory wholesale into their `microsoft/winget-pkgs` clone.
7. **Manifest schema validation:** Validated by the `microsoft/winget-pkgs` moderation bot at PR-submission time. Common failure modes (missing `MinimumOSVersion`, invalid `License`, mismatched `InstallerType`) are addressed up front in the templates.
## Verification plan
### Local
1. Create branch `feat/winget-rpm-release`.
2. Apply all file changes.
3. `npm run lint`
4. `npm run typecheck`
5. `npm run test`
6. `npm run build:rpm` — produces a real `.rpm` on this Fedora host (sanity-checks the new electron-builder rpm target).
7. `node scripts/generate-winget-manifests.mjs` against a placeholder `dist/<name>-<version>-setup.exe` (created via `dd if=/dev/urandom of=dist/... bs=1M count=1` to provide arbitrary content) — verifies that the generator produces three valid YAML files with consistent placeholders replaced.
8. (Optional) `actionlint .github/workflows/release.yml` if installed.
### CI on fork
9. Push `feat/winget-rpm-release` to `Aiacos/hermes-desktop`.
10. Trigger `workflow_dispatch` from the Actions UI on `feat/winget-rpm-release` with `dry_run=true`.
11. Verify all build jobs succeed:
- `prepare` ✓
- `release_mac` (x64 + arm64) ✓
- `release_linux` (with `.rpm`) ✓
- `release_windows` ✓
- `generate_winget` ✓
- `publish` SKIPPED (status: skipped, not failed)
12. Download the `winget-manifests-<version>` artifact and inspect the three YAML files for correctness (URL, SHA256, version, no leftover placeholders).
### PR
13. Open PR `Aiacos:feat/winget-rpm-release` → `fathah:main`. PR description summarizes what was added, the verification done, and the manual steps the maintainer has to take post-merge to actually publish to winget (download manifest artifact from the first real release run, submit to `microsoft/winget-pkgs`).
## Open questions deferred to implementation
- Exact wording of `linux.synopsis` and `linux.description` (will follow `package.json.description` style).
- Final value of `linux.vendor` (`Nous Research` vs `fathah`).
- Whether to include `manifestVersion: 1.10.0` (current latest) or stick with `1.6.0` (more compatible with older `winget` clients). Default to `1.6.0` unless implementation reveals required fields.
## Out-of-scope follow-ups (future PRs, if desired)
- Auto-submit winget PR via `vedantmgoyal2009/winget-releaser` action (requires GitHub PAT in upstream secrets).
- Fedora COPR repository for `dnf install` integration.
- GPG-signing the RPM.
- Windows code signing.
- Windows ARM64 build.
---
### Superpowers Specs 2026 07 14 Remote Dashboard Oauth Design (`docs/superpowers/specs/2026-07-14-remote-dashboard-oauth-design.md`)
# Remote Dashboard OAuth Design
## Goal
Hermes One will connect directly to OAuth-gated remote Hermes dashboards through browser authentication, cookie-authenticated REST requests, and fresh single-use WebSocket tickets.
The feature applies only to direct Remote mode. Local and SSH connection behavior remains unchanged.
## User experience
Hermes One automatically detects the remote gateway authentication mode from the public `GET /api/status` response.
- When `auth_required` is false, Settings retains the existing session-token field and token-based connection behavior.
- When `auth_required` is true, Settings hides the token field and shows remote OAuth state with Sign in and Sign out actions.
- Sign in opens a sandboxed Electron browser window at the gateway `/login` route. The window closes after the gateway callback establishes an OAuth session.
- A cancelled login preserves the remote URL and existing connection configuration.
- Completing sign-in revalidates the current connection before committing OAuth mode. A gateway or mode change rejects that completion; unrelated concurrent settings remain intact.
- An expired session produces a specific reauthentication action instead of starting a local gateway or falling back to the legacy `/v1` transport.
Changing the remote URL refreshes authentication detection. OAuth cookies remain scoped by Electron's cookie jar to their gateway domain.
## Authentication architecture
Remote OAuth uses a dedicated persistent Electron session partition named `persist:hermes-remote-oauth`.
The partition owns all OAuth browser state and HttpOnly gateway session cookies. Cookies, refresh tokens, and reusable authentication material never enter renderer state, `desktop.json`, IPC return values, logs, or analytics.
The main process opens a sandboxed `BrowserWindow` in this partition and navigates to `<gateway>/login`. It observes successful navigations and polls the partition cookie jar until either a Hermes access cookie or refresh cookie exists. The login window uses context isolation, disables Node integration, enables the Chromium sandbox, and retains web security.
A focused `src/main/remote-oauth.ts` module owns:
- persistent partition lookup;
- gateway-scoped cookie presence and removal;
- interactive login-window lifecycle;
- cookie-authenticated JSON requests through Electron `net`;
- single-use WebSocket ticket minting;
- structured OAuth errors, including reauthentication requirements.
## Connection data flow
Settings sends a candidate remote URL to the main process for authentication-mode detection. The main process normalizes the URL and reads public `GET /api/status` without credentials.
For token gateways, existing behavior continues: authenticated REST requests use `X-Hermes-Session-Token`, and the dashboard WebSocket uses `?token=`.
When a gateway resolves to OAuth, any previously stored Remote API key becomes inactive. Shared main-process request headers must not attach it, even though the value remains saved for a future return to token mode.
For OAuth gateways:
1. Settings requests interactive sign-in.
2. The main process opens `<gateway>/login` in the dedicated OAuth partition.
3. The identity-provider redirect returns to `/auth/callback`, which writes HttpOnly access and refresh cookies into that partition.
4. Authenticated dashboard REST requests use Electron `net.request` with the same partition and `useSessionCookies: true`.
5. Immediately before every WebSocket connection, the main process sends cookie-authenticated `POST /api/auth/ws-ticket`.
6. A `wss://` ticket URL is returned directly. A non-loopback `ws://` URL stays in the main process, which creates a one-shot relay on `127.0.0.1` behind a random capability path.
7. The renderer opens the direct secure URL or loopback relay URL and discards it after that connection attempt.
The WebSocket URL must be minted per connection, including reconnects. It cannot be cached in `DashboardConnection` because OAuth tickets are short-lived and single-use.
## Configuration and IPC contract
Connection configuration gains a normalized remote authentication mode: `auto`, `token`, or `oauth`. Existing configuration without this field behaves as `auto`.
Automatic mode is authoritative for normal UI use:
- `auto` probes `/api/status` and resolves to token or OAuth behavior;
- `token` and `oauth` exist as normalized internal states for a resolved connection and future diagnostics, not as a manual Settings selector.
Public connection configuration exposes authentication mode and signed-in state, never cookie values.
New preload APIs provide bounded operations:
- probe remote authentication mode;
- start OAuth login;
- sign out from the selected remote gateway;
- query OAuth session state;
- obtain a fresh dashboard WebSocket URL.
Every IPC handler validates and normalizes the remote URL in the main process. Renderer callers cannot select arbitrary session partitions or request cookies.
OAuth login completion re-reads configuration after the browser window closes. It sets `remoteAuthMode` on that current snapshot only if Remote mode and the normalized URL still match the login target.
## Dashboard transport integration
`src/main/dashboard.ts` resolves a remote dashboard connection according to detected authentication mode.
Token connections keep the current `requestJson`, authenticated `/api/sessions` check, and `?token=` WebSocket probe.
OAuth connections use the partition-bound request helper for authenticated endpoints and mint a ticket before WebSocket probing. `DashboardStatus` distinguishes a signed-out OAuth gateway from an unsupported dashboard.
The dashboard renderer requests a fresh WebSocket URL immediately before every `DashboardGatewayClient.connect()` call. A cached token URL remains valid for token mode; OAuth mode always mints another ticket.
Insecure, non-loopback dashboard WebSockets are never exposed to renderer code. The main process creates a one-connection loopback relay, waits for the target handshake, then bridges frames and closes the listener.
OAuth dashboard failure never triggers legacy `/v1` or local-gateway fallback. Those paths use incompatible credentials and could silently route a conversation to the wrong backend.
## Error behavior
Errors remain specific and actionable:
- Closing the login window before cookies appear returns `oauth_cancelled`.
- Changing the connection mode or gateway during login returns `oauth_connection_changed` without overwriting the newer configuration.
- Missing access and refresh cookies returns `oauth_login_required`.
- HTTP 401 from an authenticated REST or ticket request returns `oauth_login_required` and clears the connected indicator.
- Missing or malformed `/api/auth/ws-ticket` response reports gateway incompatibility and names the endpoint.
- WebSocket rejection requests one new ticket for one retry. A second rejection surfaces the transport error without another fallback or retry loop.
- Network, TLS, and invalid-URL errors preserve their underlying message while adding remote OAuth operation context.
Sign out removes only cookies applicable to the selected gateway URL. It does not clear unrelated gateway, Hermes account, provider OAuth, web-preview, or default-session cookies.
## Testing strategy
Implementation follows test-driven development. Each behavior receives a failing test before production code changes.
Focused tests cover:
- `auth_required` detection and malformed status responses;
- access-cookie and refresh-cookie session recognition;
- gateway-domain cookie scoping and sign-out;
- partition-bound REST requests with session cookies enabled;
- WebSocket ticket response validation;
- a new ticket minted for every connect and reconnect;
- 401 mapping to the structured reauthentication state;
- login cancellation preserving configuration;
- token dashboard behavior remaining unchanged;
- SSH behavior remaining unchanged;
- preload and IPC surface restrictions;
- renderer Settings states for probing, signed out, signing in, connected, cancelled, and expired.
- concurrent configuration changes during interactive login;
- relay capability rejection, one-shot forwarding, and target URL confinement;
- CSP rejection of the wildcard `ws:` source while retaining loopback WebSockets.
Verification includes focused tests, Node and renderer typechecks, full Vitest suite, production build, and `lat check`. Relevant architecture and test specifications are added to `lat.md/` with source/test references.
## Security boundaries
OAuth secrets stay in Electron's cookie store. The application never reads, serializes, logs, or returns cookie values.
Login content runs with `nodeIntegration: false`, `contextIsolation: true`, `sandbox: true`, and `webSecurity: true`. Navigation remains limited to the gateway login flow and identity-provider redirects; popup and external navigation behavior follows explicit allowlisting.
Ticket minting accepts only normalized HTTP(S) gateway URLs. `wss:` targets may reach the renderer, but non-loopback `ws:` targets are confined to a capability-protected, one-shot loopback relay owned by the main process.
Both the response-header CSP and packaged renderer meta CSP omit the wildcard `ws:` source. They permit only the existing loopback WebSocket sources needed for local, SSH-tunneled, and relayed connections.
OAuth selection also suppresses stored Remote bearer headers across the shared main-process API path. This prevents fallback and auxiliary requests from crossing principals with a stale token.
## Acceptance criteria
Given a remote URL whose `/api/status` reports `auth_required: true`, Hermes One identifies OAuth mode and offers browser sign-in without requesting a session token.
After successful sign-in, authenticated dashboard REST calls succeed through the persistent OAuth partition, each WebSocket connection receives a newly minted ticket, and chat connects without local or legacy fallback.
If connection settings change while sign-in is open, completion does not restore stale values. If an HTTP remote gateway is used, its target URL and ticket do not cross IPC; the renderer receives only a one-shot loopback URL.
After application restart, a still-valid access or refresh cookie restores the connected state without another interactive login.
After session expiry, Hermes One reports that reauthentication is required and does not expose credentials, start a local gateway, or route chat through another backend.
Token-authenticated Remote mode and SSH mode retain their current tested behavior.
---
--- METRICS ---
- Files Extracted: 12
- Estimated Token Budget: ~56588 tokens
- Canonical Reference: https://codewiki.google/github.com/fathah/hermes-desktop