Compare commits

...

1 Commits

Author SHA1 Message Date
Brooklyn Nicholson
648da6a8d1 feat(gui): make desktop setup flow real and testable
Add a GUI-first setup gate and runtime state API so desktop onboarding is safe, iterative, and works with isolated fresh-mode installs. Scaffold and wire the desktop shell/runtime pieces so this branch runs end-to-end without disturbing existing user installs.
2026-04-25 19:48:02 -05:00
38 changed files with 14234 additions and 528 deletions

58
apps/README.md Normal file
View File

@@ -0,0 +1,58 @@
# Hermes Apps
Platform apps live here. The first app is a cross-platform GUI shell around the
existing Hermes dashboard; it should not fork chat, config, logs, or session UI.
## Shape
```text
apps/
gui/ # cross-platform app shell: dev Chrome shell now, Tauri native next
shared/ # runtime bundle notes/scripts used by Windows + macOS packaging
```
## Desktop Dev
The backend-only GUI mode is:
```bash
hermes dashboard --gui
```
The fast GUI shell is:
```powershell
cd \\wsl$\Ubuntu\home\bb\hermes-agent\apps\gui
npm run dev
```
The native Tauri shell is:
```powershell
cd \\wsl$\Ubuntu\home\bb\hermes-agent\apps\gui
npm run dev:tauri
```
`--gui` implies the embedded TUI; do not pass `--tui` separately for GUI mode.
## MVP Boundary
Included:
- bundled Python runtime
- bundled Node/TUI runtime
- CLI install to PATH
- profile picker and first-run setup
- dashboard health/reconnect state
- tray controls
- desktop notifications
- Windows installer
Deferred:
- code signing
- native self-updater
- store distribution
For MVP updates, the desktop UI should run the existing `hermes update` flow and
surface progress/finish notifications.

102
apps/gui/README.md Normal file
View File

@@ -0,0 +1,102 @@
# Hermes GUI
Cross-platform GUI shell for the Hermes dashboard.
## Fast Dev Shell
This gets a GUI window on Windows/WSL today by launching Chrome in app mode:
```bash
cd apps/gui
npm run dev
```
It starts `hermes dashboard --gui --no-open --port 9120`, waits for
`/api/health`, then opens a standalone app window at `http://127.0.0.1:9120`.
## Native Shell
The native Tauri shell is still scaffolded:
```bash
cd apps/gui
npm run dev:tauri
```
From Windows PowerShell on a `\\wsl$` path, use PowerShell `npm`, not
`npm.cmd`:
```powershell
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
cd \\wsl$\Ubuntu\home\bb\hermes-agent\apps\gui
npm run dev:tauri
```
`npm.cmd` goes through `cmd.exe`, and `cmd.exe` cannot use UNC paths as the
current directory.
If `npm run` still falls through `cmd.exe`, bypass npm entirely:
```powershell
\\wsl$\Ubuntu\home\bb\hermes-agent\apps\gui\dev-tauri.ps1
```
The launcher builds into `%LOCALAPPDATA%\Hermes\cargo-target\gui` instead of
`\\wsl$` because Windows Cargo incremental locks do not work reliably on UNC
WSL filesystems.
In dev, either start Hermes yourself:
```bash
hermes dashboard --gui --no-open --port 9120
```
or let the native shell start it. The tray menu owns:
- Open Hermes
- Open in Browser
- Restart Hermes Runtime
- Quit Hermes
The native shell reuses a healthy GUI runtime when one is already running.
Otherwise it picks the first free port from `9120..9139`, passes that port into
the WSL/backend process, and navigates the Tauri window there. Set
`HERMES_GUI_PORT` to force a starting port.
## Fresh Install Emulation
Use an isolated Hermes home without touching your real `~/.hermes`:
```powershell
powershell.exe -ExecutionPolicy Bypass -File \\wsl$\Ubuntu\home\bb\hermes-agent\apps\gui\dev-tauri.ps1 -Fresh
```
Reset that disposable home and run again:
```powershell
powershell.exe -ExecutionPolicy Bypass -File \\wsl$\Ubuntu\home\bb\hermes-agent\apps\gui\dev-tauri.ps1 -Fresh -ResetFresh
```
Fresh mode stores state in `%LOCALAPPDATA%\Hermes\fresh-install-home` and starts
from port `9140` so it does not collide with your normal GUI dev session.
Set `HERMES_GUI_MIN_SPLASH_MS` only when debugging the startup screen; default
startup is instant once the backend is healthy.
## Boundary
GUI owns:
- app shell/window
- startup state
- sidecar process lifecycle
- future tray/notifications/installers
Hermes owns:
- dashboard UI
- auth/session token
- profiles/config/env
- TUI/PTT chat bridge
- tools/skills/gateway
- update flow

57
apps/gui/dev-tauri.ps1 Normal file
View File

@@ -0,0 +1,57 @@
param(
[string]$Command = "dev",
[switch]$Fresh,
[switch]$ResetFresh
)
$ErrorActionPreference = "Stop"
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force
$AppRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
$Script = Join-Path $AppRoot "scripts\tauri.mjs"
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
throw "Windows Node.js was not found. Install it with: winget install OpenJS.NodeJS.LTS"
}
if (-not (Get-Command rustc -ErrorAction SilentlyContinue)) {
throw "Windows Rust was not found. Install it with: winget install Rustlang.Rustup"
}
$Tauri = Get-Command tauri -ErrorAction SilentlyContinue
$CargoTauri = Get-Command cargo-tauri -ErrorAction SilentlyContinue
if (-not $Tauri -and -not $CargoTauri) {
throw "Tauri CLI not found. Install it with: npm install -g @tauri-apps/cli (run from a normal Windows path, not \\wsl$)"
}
$env:CARGO_INCREMENTAL = "0"
$env:CARGO_TARGET_DIR = Join-Path $env:LOCALAPPDATA "Hermes\cargo-target\gui"
New-Item -ItemType Directory -Force -Path $env:CARGO_TARGET_DIR | Out-Null
if ($Fresh) {
$FreshHome = Join-Path $env:LOCALAPPDATA "Hermes\fresh-install-home"
if ($ResetFresh -and (Test-Path $FreshHome)) {
Remove-Item -Recurse -Force $FreshHome
}
New-Item -ItemType Directory -Force -Path $FreshHome | Out-Null
$env:HERMES_HOME = $FreshHome
$env:HERMES_GUI_PORT = "9140"
$env:HERMES_GUI_FRESH = "1"
Write-Host "Fresh GUI mode"
Write-Host " HERMES_HOME=$FreshHome"
Write-Host " HERMES_GUI_PORT=$env:HERMES_GUI_PORT"
}
Push-Location $AppRoot
try {
if ($Tauri) {
& tauri $Command
}
else {
& cargo tauri $Command
}
}
finally {
Pop-Location
}

13
apps/gui/package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "@hermes/gui",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node scripts/dev-shell.mjs",
"dev:tauri": "node scripts/tauri.mjs dev",
"build": "node scripts/tauri.mjs build",
"dashboard": "node scripts/start-dashboard.mjs",
"tauri": "node scripts/tauri.mjs"
}
}

View File

@@ -0,0 +1,156 @@
import { spawn, spawnSync } from "node:child_process";
import { createServer } from "node:net";
import { dirname, resolve } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "../../..");
const python = process.env.HERMES_PYTHON || "python";
let port = process.env.HERMES_GUI_PORT || "9120";
let url = `http://127.0.0.1:${port}`;
let dashboard = null;
function stop() {
if (dashboard && !dashboard.killed) dashboard.kill();
}
process.on("SIGINT", () => {
stop();
process.exit(130);
});
process.on("SIGTERM", () => {
stop();
process.exit(143);
});
process.on("exit", stop);
async function waitForHealth() {
for (let i = 0; i < 120; i += 1) {
if (await isHealthy()) return true;
await delay(500);
}
return false;
}
async function isHealthy() {
try {
const res = await fetch(`${url}/api/health`, {
signal: AbortSignal.timeout(1000),
});
const data = await res.json();
return res.ok && data.status === "ok";
} catch {
return false;
}
}
function canBind(candidate) {
return new Promise((resolveBind) => {
const server = createServer();
server.once("error", () => resolveBind(false));
server.listen(Number(candidate), "127.0.0.1", () => {
server.close(() => resolveBind(true));
});
});
}
async function choosePort() {
if (process.env.HERMES_GUI_PORT) return;
let candidate = Number(port);
for (let i = 0; i < 20; i += 1) {
if (await canBind(candidate)) {
port = String(candidate);
url = `http://127.0.0.1:${port}`;
return;
}
candidate += 1;
}
}
function startDashboard() {
dashboard = spawn(
python,
[
"-m",
"hermes_cli.main",
"dashboard",
"--gui",
"--no-open",
"--host",
"127.0.0.1",
"--port",
port,
],
{
cwd: repoRoot,
env: {
...process.env,
HERMES_GUI: "1",
},
stdio: "inherit",
},
);
dashboard.on("exit", (code) => {
process.exit(code ?? 0);
});
}
function run(command, args) {
return (
spawnSync(command, args, {
shell: process.platform === "win32",
stdio: "ignore",
}).status === 0
);
}
function openGuiWindow() {
if (process.platform === "win32") {
return (
run("cmd.exe", ["/C", "start", "", "chrome", `--app=${url}`]) ||
run("cmd.exe", ["/C", "start", "", "msedge", `--app=${url}`]) ||
run("cmd.exe", ["/C", "start", "", url])
);
}
if (process.env.WSL_DISTRO_NAME) {
return (
run("cmd.exe", ["/C", "start", "", "chrome", `--app=${url}`]) ||
run("cmd.exe", ["/C", "start", "", "msedge", `--app=${url}`]) ||
run("cmd.exe", ["/C", "start", "", url])
);
}
if (process.platform === "darwin") {
return (
run("open", ["-na", "Google Chrome", "--args", `--app=${url}`]) ||
run("open", [url])
);
}
return (
run("google-chrome", [`--app=${url}`]) ||
run("chromium", [`--app=${url}`]) ||
run("xdg-open", [url])
);
}
if (await isHealthy()) {
console.log(`Hermes GUI already running -> ${url}`);
openGuiWindow();
process.exit(0);
}
await choosePort();
startDashboard();
if (await waitForHealth()) {
console.log(`Hermes GUI -> ${url}`);
openGuiWindow();
} else {
console.error(`Hermes GUI did not become healthy at ${url}`);
}

View File

@@ -0,0 +1,95 @@
import { spawn } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "../../..");
const python = process.env.HERMES_PYTHON || "python";
const port = process.env.HERMES_GUI_PORT || "9120";
const url = `http://127.0.0.1:${port}`;
async function isHealthy() {
try {
const res = await fetch(`${url}/api/health`, {
signal: AbortSignal.timeout(1000),
});
const data = await res.json();
return res.ok && data.status === "ok";
} catch {
return false;
}
}
function wslRepoRoot() {
const normalized = repoRoot.replaceAll("\\", "/");
const parts = normalized.split("/");
const host = parts[2]?.toLowerCase();
if (process.platform !== "win32") return null;
if (host !== "wsl$" && host !== "wsl.localhost") return null;
const distro = parts[3];
const path = `/${parts.slice(4).join("/")}`;
return distro && path !== "/" ? { distro, path } : null;
}
function spawnDashboard() {
const wsl = wslRepoRoot();
if (wsl) {
return spawn(
"wsl.exe",
[
"-d",
wsl.distro,
"--cd",
wsl.path,
"env",
"HERMES_GUI=1",
process.env.HERMES_WSL_PYTHON || "python",
"-m",
"hermes_cli.main",
"dashboard",
"--gui",
"--no-open",
"--host",
"127.0.0.1",
"--port",
port,
],
{ stdio: "inherit" },
);
}
return spawn(
python,
[
"-m",
"hermes_cli.main",
"dashboard",
"--gui",
"--no-open",
"--host",
"127.0.0.1",
"--port",
port,
],
{
cwd: repoRoot,
env: {
...process.env,
HERMES_GUI: "1",
},
stdio: "inherit",
},
);
}
if (await isHealthy()) {
console.log(`Hermes GUI already running -> ${url}`);
process.exit(0);
}
const child = spawnDashboard();
child.on("exit", (code, signal) => {
if (signal) process.kill(process.pid, signal);
process.exit(code ?? 0);
});

View File

@@ -0,0 +1,90 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const appRoot = resolve(here, "..");
const bin = process.platform === "win32" ? "tauri.cmd" : "tauri";
const localTauri = resolve(appRoot, "node_modules", ".bin", bin);
const args = process.argv.slice(2);
function isWsl() {
return process.platform === "linux" && !!process.env.WSL_DISTRO_NAME;
}
function quotePs(value) {
return `'${value.replaceAll("'", "''")}'`;
}
function dispatchToWindows() {
const pathResult = spawnSync("wslpath", ["-w", appRoot], {
encoding: "utf8",
});
const windowsPath = pathResult.stdout.trim();
if (!windowsPath) return false;
const command = [
"$ErrorActionPreference = 'Stop'",
"Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force",
"if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {",
' Write-Error "Windows npm was not found. Install Windows Node.js first: winget install OpenJS.NodeJS.LTS"',
"}",
"if (-not (Get-Command rustc -ErrorAction SilentlyContinue)) {",
' Write-Error "Windows Rust was not found. Install Rust first: winget install Rustlang.Rustup"',
"}",
`Set-Location -LiteralPath ${quotePs(windowsPath)}`,
"& npm run dev:tauri",
].join("; ");
const result = spawnSync(
"powershell.exe",
["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command],
{ stdio: "inherit" },
);
process.exit(result.status ?? 1);
}
function run(command, commandArgs, { exit = true } = {}) {
if (process.platform === "win32") {
const psCommand = [
"$ErrorActionPreference = 'Stop'",
"Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force",
`Set-Location -LiteralPath ${quotePs(appRoot)}`,
`& ${quotePs(command)} ${commandArgs.map(quotePs).join(" ")}`,
].join("; ");
const result = spawnSync(
"powershell.exe",
["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", psCommand],
{ stdio: "inherit" },
);
if (result.error && result.error.code === "ENOENT") return false;
if (exit) process.exit(result.status ?? 1);
return result.status === 0;
}
const result = spawnSync(command, commandArgs, {
cwd: appRoot,
env: process.env,
stdio: "inherit",
});
if (result.error && result.error.code === "ENOENT") return false;
if (exit) process.exit(result.status ?? 1);
return result.status === 0;
}
if (isWsl() && process.env.HERMES_GUI_TAURI_WSL !== "1") {
console.log("Launching native Windows Tauri from WSL...");
dispatchToWindows();
console.error(
"Could not hand off to Windows PowerShell. Run this from Windows PowerShell instead:",
);
console.error(" cd \\\\wsl$\\Ubuntu\\home\\bb\\hermes-agent\\apps\\gui");
console.error(" npm run dev:tauri");
process.exit(1);
}
if (existsSync(localTauri)) run(localTauri, args);
if (run("tauri", args, { exit: false })) process.exit(0);
if (run("cargo", ["tauri", ...args], { exit: false })) process.exit(0);
run("npx", ["--yes", "@tauri-apps/cli@latest", ...args]);

1
apps/gui/src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/target/

5579
apps/gui/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
[package]
name = "hermes-gui"
version = "0.0.0"
description = "Hermes GUI shell"
edition = "2021"
[lib]
name = "hermes_gui_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-notification = "2"
tauri-plugin-opener = "2"

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}

View File

@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default Hermes GUI permissions",
"windows": ["main"],
"permissions": ["core:default", "notification:default", "opener:default"]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default Hermes GUI permissions","local":true,"windows":["main"],"permissions":["core:default","notification:default","opener:default"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="18" fill="#071313"/>
<text x="50" y="70" text-anchor="middle" font-size="68" fill="#f0e6d2"></text>
</svg>

After

Width:  |  Height:  |  Size: 212 B

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,433 @@
use std::{
io::{Read, Write},
net::{TcpListener, TcpStream},
process::{Child, Command, Stdio},
sync::Mutex,
time::{Duration, Instant},
};
use tauri::{
image::Image,
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
App, AppHandle, Manager, WebviewWindow,
};
const GUI_HOST: &str = "127.0.0.1";
const DEFAULT_GUI_PORT: u16 = 9120;
const MIN_SPLASH_MS: u64 = 0;
const SPLASH_URL: &str = "data:text/html,%3C!doctype%20html%3E%3Cmeta%20charset%3Dutf-8%3E%3Cstyle%3Ebody%7Bmargin%3A0%3Bheight%3A100vh%3Bdisplay%3Agrid%3Bplace-items%3Acenter%3Bbackground%3A%23071313%3Bcolor%3A%23f0e6d2%3Bfont%3A14px%20monospace%3Bletter-spacing%3A.08em%3Btext-transform%3Auppercase%7D%3C%2Fstyle%3E%3Cbody%3EStarting%20Hermes%E2%80%A6%3C%2Fbody%3E";
struct GuiState {
child: Mutex<Option<Child>>,
port: Mutex<u16>,
}
fn gui_url(port: u16) -> String {
format!("http://{GUI_HOST}:{port}")
}
fn check_health(port: u16) -> bool {
let Ok(mut stream) = TcpStream::connect_timeout(
&format!("{GUI_HOST}:{port}").parse().unwrap(),
Duration::from_secs(1),
) else {
return false;
};
let _ = stream.set_read_timeout(Some(Duration::from_secs(1)));
let request =
format!("GET /api/health HTTP/1.1\r\nHost: {GUI_HOST}:{port}\r\nConnection: close\r\n\r\n");
if stream.write_all(request.as_bytes()).is_err() {
return false;
}
let mut response = String::new();
let _ = stream.read_to_string(&mut response);
response.contains("200 OK")
&& response.contains("\"status\":\"ok\"")
&& response.contains("\"mode\":\"gui\"")
}
fn can_bind(port: u16) -> bool {
TcpListener::bind((GUI_HOST, port)).is_ok()
}
fn base_port() -> u16 {
std::env::var("HERMES_GUI_PORT")
.ok()
.and_then(|raw| raw.parse().ok())
.unwrap_or(DEFAULT_GUI_PORT)
}
fn select_port() -> u16 {
let start = base_port();
for port in start..start.saturating_add(20) {
if check_health(port) || can_bind(port) {
return port;
}
}
start
}
fn repo_root() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../..")
.canonicalize()
.unwrap_or_else(|_| std::path::PathBuf::from("."))
}
fn runtime_dir() -> Option<std::path::PathBuf> {
std::env::var_os("HERMES_GUI_RUNTIME_DIR").map(std::path::PathBuf::from)
}
fn runtime_python(runtime: &std::path::Path) -> std::path::PathBuf {
if cfg!(target_os = "windows") {
runtime.join("venv").join("Scripts").join("python.exe")
} else {
runtime.join("venv").join("bin").join("python")
}
}
fn wsl_path(root: &std::path::Path) -> Option<(String, String)> {
let raw = root.to_string_lossy().replace('\\', "/");
let parts: Vec<&str> = raw.split('/').collect();
let host = parts.get(2)?.to_ascii_lowercase();
if host != "wsl$" && host != "wsl.localhost" {
return None;
}
let distro = parts.get(3)?.to_string();
let path = format!("/{}", parts.get(4..)?.join("/"));
Some((distro, path))
}
fn start_dashboard(port: u16) -> std::io::Result<Child> {
if let Some(runtime) = runtime_dir() {
let python = runtime_python(&runtime);
let web_dist = runtime.join("web_dist");
let tui_dir = runtime.join("ui-tui");
let port = port.to_string();
return Command::new(python)
.args([
"-m",
"hermes_cli.main",
"dashboard",
"--gui",
"--no-open",
"--host",
GUI_HOST,
"--port",
&port,
])
.env("HERMES_GUI", "1")
.env("HERMES_GUI_PORT", &port)
.env("HERMES_WEB_DIST", web_dist)
.env("HERMES_TUI_DIR", tui_dir)
.envs(
std::env::vars()
.filter(|(key, _)| matches!(key.as_str(), "HERMES_HOME" | "HERMES_GUI_FRESH")),
)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
let root = repo_root();
let port = port.to_string();
if let Some((distro, path)) = wsl_path(&root) {
let port_env = format!("HERMES_GUI_PORT={port}");
let mut env_args = vec!["HERMES_GUI=1".to_string(), port_env];
if let Ok(home) = std::env::var("HERMES_HOME") {
env_args.push(format!("HERMES_HOME={home}"));
}
if let Ok(fresh) = std::env::var("HERMES_GUI_FRESH") {
env_args.push(format!("HERMES_GUI_FRESH={fresh}"));
}
let mut args = vec![
"-d".to_string(),
distro,
"--cd".to_string(),
path,
"env".to_string(),
];
args.extend(env_args);
args.extend([
"python".to_string(),
"-m".to_string(),
"hermes_cli.main".to_string(),
"dashboard".to_string(),
"--gui".to_string(),
"--no-open".to_string(),
"--host".to_string(),
GUI_HOST.to_string(),
"--port".to_string(),
port.clone(),
]);
return Command::new("wsl.exe")
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
Command::new("python")
.args([
"-m",
"hermes_cli.main",
"dashboard",
"--gui",
"--no-open",
"--host",
GUI_HOST,
"--port",
&port,
])
.current_dir(root)
.env("HERMES_GUI", "1")
.env("HERMES_GUI_PORT", &port)
.envs(
std::env::vars()
.filter(|(key, _)| matches!(key.as_str(), "HERMES_HOME" | "HERMES_GUI_FRESH")),
)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
}
fn stop_owned_dashboard(state: &GuiState) {
let Some(mut child) = state.child.lock().expect("gui child lock poisoned").take() else {
return;
};
let _ = child.kill();
let _ = child.wait();
}
fn current_port(state: &GuiState) -> u16 {
*state.port.lock().expect("gui port lock poisoned")
}
fn ensure_dashboard(state: &GuiState) -> Result<(), String> {
let current = current_port(state);
if check_health(current) {
return Ok(());
}
let port = select_port();
*state.port.lock().expect("gui port lock poisoned") = port;
if check_health(port) {
return Ok(());
}
let child = start_dashboard(port).map_err(|err| {
format!(
"Could not auto-start Hermes dashboard ({err}). Start it manually with: hermes dashboard --gui --no-open --port {port}"
)
})?;
*state.child.lock().expect("gui child lock poisoned") = Some(child);
Ok(())
}
fn navigate_when_ready(window: WebviewWindow, port: u16) {
std::thread::spawn(move || {
let started = Instant::now();
while started.elapsed() < Duration::from_secs(60) {
if check_health(port) {
let min_splash = std::env::var("HERMES_GUI_MIN_SPLASH_MS")
.ok()
.and_then(|raw| raw.parse::<u64>().ok())
.unwrap_or(MIN_SPLASH_MS);
let elapsed = started.elapsed();
if elapsed < Duration::from_millis(min_splash) {
std::thread::sleep(Duration::from_millis(min_splash) - elapsed);
}
if let Ok(url) = tauri::Url::parse(&gui_url(port)) {
let _ = window.navigate(url);
let _ = window.show();
let _ = window.set_focus();
}
return;
}
std::thread::sleep(Duration::from_millis(500));
}
});
}
fn show_main_window(app: &AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
fn open_browser(port: u16) {
let url = gui_url(port);
#[cfg(target_os = "windows")]
let _ = Command::new("cmd")
.args(["/C", "start", "", &url])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
#[cfg(target_os = "macos")]
let _ = Command::new("open").arg(&url).spawn();
#[cfg(all(unix, not(target_os = "macos")))]
let _ = Command::new("xdg-open").arg(&url).spawn();
}
fn tray_icon() -> Image<'static> {
let width = 32;
let height = 32;
let mut rgba = Vec::with_capacity(width * height * 4);
for y in 0..height {
for x in 0..width {
let mark = (14..=17).contains(&x) && (5..=26).contains(&y)
|| (8..=23).contains(&x) && (13..=16).contains(&y)
|| (10..=21).contains(&x) && (y == 5 || y == 26);
if mark {
rgba.extend_from_slice(&[0xF0, 0xE6, 0xD2, 0xFF]);
} else {
rgba.extend_from_slice(&[0x07, 0x13, 0x13, 0xFF]);
}
}
}
Image::new_owned(rgba, width as u32, height as u32)
}
fn restart_runtime(app: &AppHandle) -> Result<(), String> {
let state = app.state::<GuiState>();
stop_owned_dashboard(&state);
ensure_dashboard(&state)?;
if let Some(window) = app.get_webview_window("main") {
if let Ok(url) = tauri::Url::parse(SPLASH_URL) {
let _ = window.navigate(url);
}
let port = current_port(&state);
navigate_when_ready(window, port);
}
Ok(())
}
fn setup_tray(app: &App) -> tauri::Result<()> {
let open_item = MenuItem::with_id(app, "open", "Open Hermes", true, None::<&str>)?;
let browser_item = MenuItem::with_id(app, "browser", "Open in Browser", true, None::<&str>)?;
let restart_item =
MenuItem::with_id(app, "restart", "Restart Hermes Runtime", true, None::<&str>)?;
let status_item = MenuItem::with_id(app, "status", "Local runtime", false, None::<&str>)?;
let separator = PredefinedMenuItem::separator(app)?;
let separator2 = PredefinedMenuItem::separator(app)?;
let quit_item = MenuItem::with_id(app, "quit", "Quit Hermes", true, None::<&str>)?;
let menu = Menu::with_items(
app,
&[
&open_item,
&browser_item,
&restart_item,
&separator,
&status_item,
&separator2,
&quit_item,
],
)?;
let icon = tray_icon();
let _tray = TrayIconBuilder::new()
.icon(icon)
.menu(&menu)
.tooltip("Hermes")
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => show_main_window(app),
"browser" => {
let state = app.state::<GuiState>();
open_browser(current_port(&state));
}
"restart" => {
if let Err(err) = restart_runtime(app) {
eprintln!("Failed to restart Hermes runtime: {err}");
}
}
"quit" => {
let state = app.state::<GuiState>();
stop_owned_dashboard(&state);
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
show_main_window(&tray.app_handle());
}
})
.build(app)?;
Ok(())
}
#[tauri::command]
fn runtime_running(app: AppHandle) -> bool {
let state = app.state::<GuiState>();
check_health(current_port(&state))
}
#[tauri::command]
fn restart_runtime_command(app: AppHandle) -> Result<(), String> {
restart_runtime(&app)
}
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_opener::init())
.manage(GuiState {
child: Mutex::new(None),
port: Mutex::new(base_port()),
})
.invoke_handler(tauri::generate_handler![
runtime_running,
restart_runtime_command
])
.setup(|app| {
setup_tray(app)?;
if let Some(window) = app.get_webview_window("main") {
if let Ok(url) = tauri::Url::parse(SPLASH_URL) {
let _ = window.navigate(url);
}
let state = app.state::<GuiState>();
if let Err(err) = ensure_dashboard(&state) {
eprintln!("{err}");
}
let port = current_port(&state);
navigate_when_ready(window, port);
}
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
})
.run(tauri::generate_context!())
.expect("failed to run Hermes GUI");
}

View File

@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
hermes_gui_lib::run();
}

View File

@@ -0,0 +1,38 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hermes",
"version": "0.0.0",
"identifier": "ai.nous.hermes.gui",
"build": {
"beforeDevCommand": "",
"beforeBuildCommand": "",
"devUrl": "http://127.0.0.1:9120",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "Hermes",
"width": 1400,
"height": 900,
"minWidth": 900,
"minHeight": 600,
"resizable": true,
"center": true
}
],
"security": {
"csp": "default-src 'self' http://127.0.0.1:* http://localhost:*; connect-src 'self' http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*; img-src 'self' data: blob: http://127.0.0.1:* http://localhost:*; style-src 'self' 'unsafe-inline' http://127.0.0.1:* http://localhost:*; script-src 'self' 'unsafe-inline' 'unsafe-eval' http://127.0.0.1:* http://localhost:*"
}
},
"bundle": {
"active": true,
"icon": ["icons/32x32.png", "icons/icon.ico", "icons/icon.svg"],
"targets": ["nsis", "dmg", "app"],
"resources": {
"sidecars": "sidecars/"
}
}
}

5
apps/gui/src/main.ts Normal file
View File

@@ -0,0 +1,5 @@
// Browser-side GUI bridge entry.
//
// The dashboard remains in `web/`; this file is reserved for future shell-only
// glue if we need pre-navigation scripts or native event wiring.
export {};

View File

@@ -0,0 +1,44 @@
param(
[string]$Out = "$PSScriptRoot\..\gui\src-tauri\sidecars\hermes-runtime",
[string]$Python = "python"
)
$Root = Resolve-Path "$PSScriptRoot\..\.."
Write-Host "Bundling Hermes GUI runtime"
Write-Host "repo: $Root"
Write-Host "out: $Out"
if (Test-Path $Out) {
Remove-Item -Recurse -Force $Out
}
New-Item -ItemType Directory -Force -Path $Out | Out-Null
Write-Host "-> Building dashboard"
npm --prefix "$Root\web" ci
npm --prefix "$Root\web" run build
Copy-Item -Recurse "$Root\web\dist" "$Out\web_dist"
Write-Host "-> Building TUI"
npm --prefix "$Root\ui-tui" ci
npm --prefix "$Root\ui-tui" run build
New-Item -ItemType Directory -Force -Path "$Out\ui-tui" | Out-Null
Copy-Item -Recurse "$Root\ui-tui\dist" "$Out\ui-tui\dist"
Copy-Item "$Root\ui-tui\package.json" "$Out\ui-tui\package.json"
Copy-Item "$Root\ui-tui\package-lock.json" "$Out\ui-tui\package-lock.json"
Copy-Item -Recurse "$Root\ui-tui\node_modules" "$Out\ui-tui\node_modules"
Write-Host "-> Creating Python runtime"
& $Python -m venv "$Out\venv"
& "$Out\venv\Scripts\python.exe" -m pip install --upgrade pip
& "$Out\venv\Scripts\python.exe" -m pip install -e "$Root[web,pty]"
@"
# Hermes GUI Runtime
Generated by apps/shared/bundle-runtime.ps1.
Set HERMES_GUI_RUNTIME_DIR to this directory before launching the Tauri shell.
"@ | Set-Content "$Out\README.md"
Write-Host "Runtime bundle ready: $Out"

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OUT="${1:-"$ROOT/apps/gui/src-tauri/sidecars/hermes-runtime"}"
PYTHON="${PYTHON:-python}"
echo "Bundling Hermes GUI runtime"
echo "repo: $ROOT"
echo "out: $OUT"
rm -rf "$OUT"
mkdir -p "$OUT"
echo "→ Building dashboard"
npm --prefix "$ROOT/web" ci
npm --prefix "$ROOT/web" run build
cp -a "$ROOT/web/dist" "$OUT/web_dist"
echo "→ Building TUI"
npm --prefix "$ROOT/ui-tui" ci
npm --prefix "$ROOT/ui-tui" run build
mkdir -p "$OUT/ui-tui"
cp -a "$ROOT/ui-tui/dist" "$OUT/ui-tui/dist"
cp -a "$ROOT/ui-tui/package.json" "$ROOT/ui-tui/package-lock.json" "$OUT/ui-tui/"
cp -a "$ROOT/ui-tui/node_modules" "$OUT/ui-tui/node_modules"
echo "→ Creating Python runtime"
"$PYTHON" -m venv "$OUT/venv"
"$OUT/venv/bin/python" -m pip install --upgrade pip
"$OUT/venv/bin/python" -m pip install -e "$ROOT[web,pty]"
cat > "$OUT/README.md" <<EOF
# Hermes GUI Runtime
Generated by apps/shared/bundle-runtime.sh.
Set HERMES_GUI_RUNTIME_DIR to this directory before launching the Tauri shell.
EOF
echo "✓ Runtime bundle ready: $OUT"

View File

@@ -0,0 +1,33 @@
# GUI Runtime Contract
The GUI shell starts Hermes with a small, explicit environment.
## Environment
```text
HERMES_GUI=1
HERMES_WEB_DIST=<bundled web dist>
HERMES_TUI_DIR=<bundled ui-tui dir>
```
The native shell uses `127.0.0.1:9120` as its initial GUI port during dev.
Bundled builds should keep the port private to the local machine and expose it
through `/api/health` and `/api/runtime`.
The shell should also pass the selected profile through the normal Hermes CLI
profile mechanism once the profile picker is wired.
## Ports
Use `127.0.0.1` only. Start with the GUI default port, then fall back to a
free port if occupied. Show the chosen port in the tray menu.
## User Data
The installer owns app files. Hermes owns user state under `HERMES_HOME`.
Uninstallers must not delete user state unless the user explicitly asks.
## Update Model
MVP does not use Tauri's native updater. GUI runs `hermes update`, tails the
action log, notifies completion, then offers to restart the runtime.

View File

@@ -51,6 +51,7 @@ import sys
from pathlib import Path
from typing import Optional
def _add_accept_hooks_flag(parser) -> None:
"""Attach the ``--accept-hooks`` flag. Shared across every agent
subparser so the flag works regardless of CLI position."""
@@ -174,6 +175,7 @@ load_hermes_dotenv(project_env=PROJECT_ROOT / ".env")
try:
if "HERMES_REDACT_SECRETS" not in os.environ:
import yaml as _yaml_early
_cfg_path = get_hermes_home() / "config.yaml"
if _cfg_path.exists():
with open(_cfg_path, encoding="utf-8") as _f:
@@ -1340,7 +1342,9 @@ def cmd_whatsapp(args):
return
if not (bridge_dir / "node_modules").exists():
print("\n→ Installing WhatsApp bridge dependencies (this can take a few minutes)...")
print(
"\n→ Installing WhatsApp bridge dependencies (this can take a few minutes)..."
)
npm = shutil.which("npm")
if not npm:
print(" ✗ npm not found on PATH — install Node.js first")
@@ -1716,14 +1720,14 @@ def _clear_stale_openai_base_url():
# (task_key, display_name, short_description)
_AUX_TASKS: list[tuple[str, str, str]] = [
("vision", "Vision", "image/screenshot analysis"),
("compression", "Compression", "context summarization"),
("web_extract", "Web extract", "web page summarization"),
("session_search", "Session search", "past-conversation recall"),
("approval", "Approval", "smart command approval"),
("mcp", "MCP", "MCP tool reasoning"),
("vision", "Vision", "image/screenshot analysis"),
("compression", "Compression", "context summarization"),
("web_extract", "Web extract", "web page summarization"),
("session_search", "Session search", "past-conversation recall"),
("approval", "Approval", "smart command approval"),
("mcp", "MCP", "MCP tool reasoning"),
("title_generation", "Title generation", "session titles"),
("skills_hub", "Skills hub", "skills search/install"),
("skills_hub", "Skills hub", "skills search/install"),
]
@@ -1822,7 +1826,7 @@ def _aux_config_menu() -> None:
print(" Auxiliary models — side-task routing")
print()
print(" Side tasks (vision, compression, web extraction, etc.) default")
print(" to your main chat model. \"auto\" means \"use my main model\"")
print(' to your main chat model. "auto" means "use my main model"')
print(" Hermes only falls back to a lightweight backend (OpenRouter,")
print(" Nous Portal) if the main model is unavailable. Override a")
print(" task below if you want it pinned to a specific provider/model.")
@@ -1833,15 +1837,20 @@ def _aux_config_menu() -> None:
desc_col = max(len(desc) for _, _, desc in _AUX_TASKS) + 4
entries: list[tuple[str, str]] = []
for task_key, name, desc in _AUX_TASKS:
task_cfg = aux.get(task_key, {}) if isinstance(aux.get(task_key), dict) else {}
task_cfg = (
aux.get(task_key, {}) if isinstance(aux.get(task_key), dict) else {}
)
current = _format_aux_current(task_cfg)
label = f"{name.ljust(name_col)}{('(' + desc + ')').ljust(desc_col)}{current}"
label = (
f"{name.ljust(name_col)}{('(' + desc + ')').ljust(desc_col)}{current}"
)
entries.append((task_key, label))
entries.append(("__reset__", "Reset all to auto"))
entries.append(("__back__", "Back"))
entries.append(("__back__", "Back"))
idx = _prompt_provider_choice(
[label for _, label in entries], default=0,
[label for _, label in entries],
default=0,
)
if idx is None:
return
@@ -1889,7 +1898,9 @@ def _aux_select_for_task(task: str) -> None:
entries: list[tuple[str, str, list[str]]] = [] # (slug, label, models)
# "auto" always first
auto_marker = " ← current" if current_provider == "auto" and not current_base_url else ""
auto_marker = (
" ← current" if current_provider == "auto" and not current_base_url else ""
)
entries.append(("__auto__", f"auto (recommended){auto_marker}", []))
for p in providers:
@@ -1898,7 +1909,9 @@ def _aux_select_for_task(task: str) -> None:
total = p.get("total_models", 0)
models = p.get("models") or []
model_hint = f"{total} models" if total else ""
marker = " ← current" if slug == current_provider and not current_base_url else ""
marker = (
" ← current" if slug == current_provider and not current_base_url else ""
)
entries.append((slug, f"{name}{model_hint}{marker}", list(models)))
# Custom endpoint (raw base_url)
@@ -1966,14 +1979,17 @@ def _aux_flow_provider_model(
selected = val or ""
else:
selected = _prompt_model_selection(
model_list, current_model=current_model, pricing=pricing,
model_list,
current_model=current_model,
pricing=pricing,
)
if selected is None:
print("No change.")
return
_save_aux_choice(task, provider=provider_slug, model=selected or "",
base_url="", api_key="")
_save_aux_choice(
task, provider=provider_slug, model=selected or "", base_url="", api_key=""
)
if selected:
print(f"{display_name}: {provider_slug} · {selected}")
else:
@@ -1993,7 +2009,9 @@ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None:
print(" Provide an OpenAI-compatible base URL (e.g. http://localhost:11434/v1)")
print()
try:
url_prompt = f"Base URL [{current_base_url}]: " if current_base_url else "Base URL: "
url_prompt = (
f"Base URL [{current_base_url}]: " if current_base_url else "Base URL: "
)
url = input(url_prompt).strip()
except (KeyboardInterrupt, EOFError):
print()
@@ -2003,20 +2021,30 @@ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None:
print("No URL provided. No change.")
return
try:
model_prompt = f"Model slug (optional) [{current_model}]: " if current_model else "Model slug (optional): "
model_prompt = (
f"Model slug (optional) [{current_model}]: "
if current_model
else "Model slug (optional): "
)
model = input(model_prompt).strip()
except (KeyboardInterrupt, EOFError):
print()
return
model = model or current_model
try:
api_key = getpass.getpass("API key (optional, blank = use OPENAI_API_KEY): ").strip()
api_key = getpass.getpass(
"API key (optional, blank = use OPENAI_API_KEY): "
).strip()
except (KeyboardInterrupt, EOFError):
print()
return
_save_aux_choice(
task, provider="custom", model=model, base_url=url, api_key=api_key,
task,
provider="custom",
model=model,
base_url=url,
api_key=api_key,
)
short_url = url.replace("https://", "").replace("http://", "").rstrip("/")
print(f"{display_name}: custom ({short_url})" + (f" · {model}" if model else ""))
@@ -2132,7 +2160,9 @@ def _model_flow_ai_gateway(config, current_model=""):
api_key = get_env_value("AI_GATEWAY_API_KEY")
if not api_key:
print("No Vercel AI Gateway API key configured.")
print("Create API key here: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway&title=AI+Gateway")
print(
"Create API key here: https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai-gateway&title=AI+Gateway"
)
print("Add a payment method to get $5 in free credits.")
print()
try:
@@ -2932,7 +2962,9 @@ def _model_flow_named_custom(config, provider_info):
print("Fetching available models...")
models = fetch_api_models(
api_key, base_url, timeout=8.0,
api_key,
base_url,
timeout=8.0,
api_mode=api_mode or None,
)
@@ -3603,7 +3635,12 @@ def _model_flow_stepfun(config, current_model=""):
_save_model_choice,
deactivate_provider,
)
from hermes_cli.config import get_env_value, save_env_value, load_config, save_config
from hermes_cli.config import (
get_env_value,
save_env_value,
load_config,
save_config,
)
from hermes_cli.models import fetch_api_models
provider_id = "stepfun"
@@ -3622,6 +3659,7 @@ def _model_flow_stepfun(config, current_model=""):
if key_env:
try:
import getpass
new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip()
except (KeyboardInterrupt, EOFError):
print()
@@ -3647,7 +3685,10 @@ def _model_flow_stepfun(config, current_model=""):
current_region = _infer_stepfun_region(current_base or pconfig.inference_base_url)
region_choices = [
("international", f"International ({_stepfun_base_url_for_region('international')})"),
(
"international",
f"International ({_stepfun_base_url_for_region('international')})",
),
("china", f"China ({_stepfun_base_url_for_region('china')})"),
]
ordered_regions = []
@@ -4490,6 +4531,7 @@ def cmd_webhook(args):
def cmd_hooks(args):
"""Shell-hook inspection and management."""
from hermes_cli.hooks import hooks_command
hooks_command(args)
@@ -6061,7 +6103,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
import signal as _signal
def _wait_for_service_active(
scope_cmd_: list, svc_name_: str, timeout: float = 10.0,
scope_cmd_: list,
svc_name_: str,
timeout: float = 10.0,
) -> bool:
"""Poll ``systemctl is-active`` until the unit reports active.
@@ -6075,7 +6119,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
try:
_verify = subprocess.run(
scope_cmd_ + ["is-active", svc_name_],
capture_output=True, text=True, timeout=5,
capture_output=True,
text=True,
timeout=5,
)
if _verify.stdout.strip() == "active":
return True
@@ -6086,7 +6132,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
_time.sleep(0.5)
def _service_restart_sec(
scope_cmd_: list, svc_name_: str, default: float = 0.0,
scope_cmd_: list,
svc_name_: str,
default: float = 0.0,
) -> float:
"""Read the unit's ``RestartUSec`` (RestartSec) in seconds.
@@ -6098,11 +6146,16 @@ def _cmd_update_impl(args, gateway_mode: bool):
"""
try:
_show = subprocess.run(
scope_cmd_ + [
"show", svc_name_,
"--property=RestartUSec", "--value",
scope_cmd_
+ [
"show",
svc_name_,
"--property=RestartUSec",
"--value",
],
capture_output=True, text=True, timeout=5,
capture_output=True,
text=True,
timeout=5,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return default
@@ -6144,12 +6197,17 @@ def _cmd_update_impl(args, gateway_mode: bool):
_cfg_drain = None
try:
from hermes_cli.config import load_config
_cfg_agent = (load_config().get("agent") or {})
_cfg_agent = load_config().get("agent") or {}
_cfg_drain = _cfg_agent.get("restart_drain_timeout")
except Exception:
pass
try:
_drain_budget = float(_cfg_drain) if _cfg_drain is not None else float(_DEFAULT_DRAIN)
_drain_budget = (
float(_cfg_drain)
if _cfg_drain is not None
else float(_DEFAULT_DRAIN)
)
except (TypeError, ValueError):
_drain_budget = float(_DEFAULT_DRAIN)
# Add a 15s margin so the drain loop + final exit finish before
@@ -6214,14 +6272,23 @@ def _cmd_update_impl(args, gateway_mode: bool):
_main_pid = 0
try:
_show = subprocess.run(
scope_cmd + [
"show", svc_name,
"--property=MainPID", "--value",
scope_cmd
+ [
"show",
svc_name,
"--property=MainPID",
"--value",
],
capture_output=True, text=True, timeout=5,
capture_output=True,
text=True,
timeout=5,
)
_main_pid = int((_show.stdout or "").strip() or 0)
except (ValueError, subprocess.TimeoutExpired, FileNotFoundError):
except (
ValueError,
subprocess.TimeoutExpired,
FileNotFoundError,
):
_main_pid = 0
_graceful_ok = False
@@ -6230,7 +6297,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
f"{svc_name}: draining (up to {int(_drain_budget)}s)..."
)
_graceful_ok = _graceful_restart_via_sigusr1(
_main_pid, drain_timeout=_drain_budget,
_main_pid,
drain_timeout=_drain_budget,
)
if _graceful_ok:
@@ -6243,13 +6311,17 @@ def _cmd_update_impl(args, gateway_mode: bool):
# units without RestartSec set we fall back
# to the original 10s budget.
_restart_sec = _service_restart_sec(
scope_cmd, svc_name, default=0.0,
scope_cmd,
svc_name,
default=0.0,
)
_post_drain_timeout = max(
10.0, _restart_sec + 10.0,
10.0,
_restart_sec + 10.0,
)
if _wait_for_service_active(
scope_cmd, svc_name,
scope_cmd,
svc_name,
timeout=_post_drain_timeout,
):
restarted_services.append(svc_name)
@@ -6278,7 +6350,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
# restart. systemctl restart returns 0 even
# if the new process crashes immediately.
if _wait_for_service_active(
scope_cmd, svc_name, timeout=10.0,
scope_cmd,
svc_name,
timeout=10.0,
):
restarted_services.append(svc_name)
else:
@@ -6295,7 +6369,9 @@ def _cmd_update_impl(args, gateway_mode: bool):
timeout=15,
)
if _wait_for_service_active(
scope_cmd, svc_name, timeout=10.0,
scope_cmd,
svc_name,
timeout=10.0,
):
restarted_services.append(svc_name)
print(f"{svc_name} recovered on retry")
@@ -6814,13 +6890,17 @@ def cmd_dashboard(args):
from hermes_cli.web_server import start_server
embedded_chat = args.tui or os.environ.get("HERMES_DASHBOARD_TUI") == "1"
gui_mode = getattr(args, "gui", False)
embedded_chat = (
gui_mode or args.tui or os.environ.get("HERMES_DASHBOARD_TUI") == "1"
)
start_server(
host=args.host,
port=args.port,
open_browser=not args.no_open,
allow_public=getattr(args, "insecure", False),
embedded_chat=embedded_chat,
gui_mode=gui_mode,
)
@@ -7514,17 +7594,39 @@ For more help on a command:
"reset", help="Clear exhaustion status for all credentials for a provider"
)
auth_reset.add_argument("provider", help="Provider id")
auth_status = auth_subparsers.add_parser("status", help="Show auth status for a provider")
auth_status = auth_subparsers.add_parser(
"status", help="Show auth status for a provider"
)
auth_status.add_argument("provider", help="Provider id")
auth_logout = auth_subparsers.add_parser("logout", help="Log out a provider and clear stored auth state")
auth_logout = auth_subparsers.add_parser(
"logout", help="Log out a provider and clear stored auth state"
)
auth_logout.add_argument("provider", help="Provider id")
auth_spotify = auth_subparsers.add_parser("spotify", help="Authenticate Hermes with Spotify via PKCE")
auth_spotify.add_argument("spotify_action", nargs="?", choices=["login", "status", "logout"], default="login")
auth_spotify.add_argument("--client-id", help="Spotify app client_id (or set HERMES_SPOTIFY_CLIENT_ID)")
auth_spotify.add_argument("--redirect-uri", help="Allow-listed localhost redirect URI for your Spotify app")
auth_spotify = auth_subparsers.add_parser(
"spotify", help="Authenticate Hermes with Spotify via PKCE"
)
auth_spotify.add_argument(
"spotify_action",
nargs="?",
choices=["login", "status", "logout"],
default="login",
)
auth_spotify.add_argument(
"--client-id", help="Spotify app client_id (or set HERMES_SPOTIFY_CLIENT_ID)"
)
auth_spotify.add_argument(
"--redirect-uri",
help="Allow-listed localhost redirect URI for your Spotify app",
)
auth_spotify.add_argument("--scope", help="Override requested Spotify scopes")
auth_spotify.add_argument("--no-browser", action="store_true", help="Do not attempt to open the browser automatically")
auth_spotify.add_argument("--timeout", type=float, help="Callback/token exchange timeout in seconds")
auth_spotify.add_argument(
"--no-browser",
action="store_true",
help="Do not attempt to open the browser automatically",
)
auth_spotify.add_argument(
"--timeout", type=float, help="Callback/token exchange timeout in seconds"
)
auth_parser.set_defaults(func=cmd_auth)
# =========================================================================
@@ -7734,7 +7836,8 @@ For more help on a command:
hooks_subparsers = hooks_parser.add_subparsers(dest="hooks_action")
hooks_subparsers.add_parser(
"list", aliases=["ls"],
"list",
aliases=["ls"],
help="List configured hooks with matcher, timeout, and consent status",
)
@@ -7747,14 +7850,18 @@ For more help on a command:
help="Hook event name (e.g. pre_tool_call, pre_llm_call, subagent_stop)",
)
_hk_test.add_argument(
"--for-tool", dest="for_tool", default=None,
"--for-tool",
dest="for_tool",
default=None,
help=(
"Only fire hooks whose matcher matches this tool name "
"(used for pre_tool_call / post_tool_call)"
),
)
_hk_test.add_argument(
"--payload-file", dest="payload_file", default=None,
"--payload-file",
dest="payload_file",
default=None,
help=(
"Path to a JSON file whose contents are merged into the "
"synthetic payload before execution"
@@ -7762,7 +7869,8 @@ For more help on a command:
)
_hk_revoke = hooks_subparsers.add_parser(
"revoke", aliases=["remove", "rm"],
"revoke",
aliases=["remove", "rm"],
help="Remove a command's allowlist entries (takes effect on next restart)",
)
_hk_revoke.add_argument(
@@ -9048,6 +9156,11 @@ Examples:
"Alternatively set HERMES_DASHBOARD_TUI=1."
),
)
dashboard_parser.add_argument(
"--gui",
action="store_true",
help="Run dashboard in GUI-shell mode; implies --tui",
)
dashboard_parser.set_defaults(func=cmd_dashboard)
# =========================================================================
@@ -9190,26 +9303,28 @@ Examples:
# the nested subcommand (dest varies by parser).
_AGENT_COMMANDS = {None, "chat", "acp", "rl"}
_AGENT_SUBCOMMANDS = {
"cron": ("cron_command", {"run", "tick"}),
"cron": ("cron_command", {"run", "tick"}),
"gateway": ("gateway_command", {"run"}),
"mcp": ("mcp_action", {"serve"}),
"mcp": ("mcp_action", {"serve"}),
}
_sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))
if (
args.command in _AGENT_COMMANDS
or (_sub_attr and getattr(args, _sub_attr, None) in _sub_set)
if args.command in _AGENT_COMMANDS or (
_sub_attr and getattr(args, _sub_attr, None) in _sub_set
):
_accept_hooks = bool(getattr(args, "accept_hooks", False))
try:
from hermes_cli.plugins import discover_plugins
discover_plugins()
except Exception:
logger.debug(
"plugin discovery failed at CLI startup", exc_info=True,
"plugin discovery failed at CLI startup",
exc_info=True,
)
try:
from hermes_cli.config import load_config
from agent.shell_hooks import register_from_config
register_from_config(load_config(), accept_hooks=_accept_hooks)
except Exception:
logger.debug(
@@ -9222,11 +9337,13 @@ Examples:
if getattr(args, "oneshot", None):
from hermes_cli.oneshot import run_oneshot
sys.exit(run_oneshot(
args.oneshot,
model=getattr(args, "model", None),
provider=getattr(args, "provider", None),
))
sys.exit(
run_oneshot(
args.oneshot,
model=getattr(args, "model", None),
provider=getattr(args, "provider", None),
)
)
# Handle top-level --resume / --continue as shortcut to chat
if (args.resume or args.continue_last) and args.command is None:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,34 @@
import { Backdrop } from "@/components/Backdrop";
import { DesktopBridge } from "@/components/DesktopBridge";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { RuntimeOverlay } from "@/components/RuntimeOverlay";
import { SidebarFooter } from "@/components/SidebarFooter";
import { SidebarStatusStrip } from "@/components/SidebarStatusStrip";
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
import type { SystemAction } from "@/contexts/system-actions-context";
import { useSystemActions } from "@/contexts/useSystemActions";
import { useI18n } from "@/i18n";
import { api, type SetupStateResponse } from "@/lib/api";
import {
useCallback,
useEffect,
useMemo,
useState,
type ComponentType,
type ReactNode,
} from "react";
import {
Routes,
Route,
NavLink,
Navigate,
useLocation,
useNavigate,
} from "react-router-dom";
isDashboardEmbeddedChatEnabled,
isDashboardGuiEnabled,
} from "@/lib/dashboard-flags";
import { cn } from "@/lib/utils";
import AnalyticsPage from "@/pages/AnalyticsPage";
import ChatPage from "@/pages/ChatPage";
import ConfigPage from "@/pages/ConfigPage";
import CronPage from "@/pages/CronPage";
import DocsPage from "@/pages/DocsPage";
import EnvPage from "@/pages/EnvPage";
import LogsPage from "@/pages/LogsPage";
import SessionsPage from "@/pages/SessionsPage";
import SetupPage from "@/pages/SetupPage";
import SkillsPage from "@/pages/SkillsPage";
import type { PluginManifest } from "@/plugins";
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
import { useTheme } from "@/themes";
import { SelectionSwitcher, Typography } from "@nous-research/ui";
import {
Activity,
BarChart3,
@@ -42,30 +57,22 @@ import {
X,
Zap,
} from "lucide-react";
import { SelectionSwitcher, Typography } from "@nous-research/ui";
import { cn } from "@/lib/utils";
import { Backdrop } from "@/components/Backdrop";
import { SidebarFooter } from "@/components/SidebarFooter";
import { SidebarStatusStrip } from "@/components/SidebarStatusStrip";
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
import { useSystemActions } from "@/contexts/useSystemActions";
import type { SystemAction } from "@/contexts/system-actions-context";
import ConfigPage from "@/pages/ConfigPage";
import DocsPage from "@/pages/DocsPage";
import EnvPage from "@/pages/EnvPage";
import SessionsPage from "@/pages/SessionsPage";
import LogsPage from "@/pages/LogsPage";
import AnalyticsPage from "@/pages/AnalyticsPage";
import CronPage from "@/pages/CronPage";
import SkillsPage from "@/pages/SkillsPage";
import ChatPage from "@/pages/ChatPage";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
import { useI18n } from "@/i18n";
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
import type { PluginManifest } from "@/plugins";
import { useTheme } from "@/themes";
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
import {
useCallback,
useEffect,
useMemo,
useState,
type ComponentType,
type ReactNode,
} from "react";
import {
NavLink,
Navigate,
Route,
Routes,
useLocation,
useNavigate,
} from "react-router-dom";
function RootRedirect() {
return <Navigate to="/sessions" replace />;
@@ -144,7 +151,10 @@ function resolveIcon(name: string): ComponentType<{ className?: string }> {
return ICON_MAP[name] ?? Puzzle;
}
function buildNavItems(builtIn: NavItem[], manifests: PluginManifest[]): NavItem[] {
function buildNavItems(
builtIn: NavItem[],
manifests: PluginManifest[],
): NavItem[] {
const items = [...builtIn];
for (const manifest of manifests) {
@@ -240,21 +250,25 @@ function buildRoutes(
export default function App() {
const { t } = useI18n();
const { pathname } = useLocation();
const navigate = useNavigate();
const { manifests } = usePlugins();
const { theme } = useTheme();
const [mobileOpen, setMobileOpen] = useState(false);
const [setupState, setSetupState] = useState<SetupStateResponse | null>(null);
const closeMobile = useCallback(() => setMobileOpen(false), []);
const isDocsRoute = pathname === "/docs" || pathname === "/docs/";
const normalizedPath = pathname.replace(/\/$/, "") || "/";
const isChatRoute = normalizedPath === "/chat";
const guiMode = isDashboardGuiEnabled();
const embeddedChat = isDashboardEmbeddedChatEnabled();
const builtinRoutes = useMemo(
() => ({
...BUILTIN_ROUTES_CORE,
...(guiMode ? { "/setup": SetupPage } : {}),
...(embeddedChat ? { "/chat": ChatPage } : {}),
}),
[embeddedChat],
[embeddedChat, guiMode],
);
const builtinNav = useMemo(
@@ -284,6 +298,48 @@ export default function App() {
const layoutVariant = theme.layoutVariant ?? "standard";
useEffect(() => {
if (!guiMode) return;
let cancelled = false;
const refresh = async () => {
try {
const state = await api.getSetupState();
if (!cancelled) {
setSetupState(state);
}
} catch {
if (!cancelled) {
setSetupState(null);
}
}
};
const onRefresh = () => {
void refresh();
};
void refresh();
window.addEventListener("hermes:setup-refresh", onRefresh);
const id = window.setInterval(refresh, 2500);
return () => {
cancelled = true;
window.clearInterval(id);
window.removeEventListener("hermes:setup-refresh", onRefresh);
};
}, [guiMode]);
useEffect(() => {
if (!guiMode || !setupState) return;
if (setupState.needs_setup && normalizedPath !== "/setup") {
navigate("/setup", { replace: true });
return;
}
if (!setupState.needs_setup && normalizedPath === "/setup") {
navigate("/sessions", { replace: true });
}
}, [guiMode, navigate, normalizedPath, setupState]);
useEffect(() => {
if (!mobileOpen) return;
const onKey = (e: KeyboardEvent) => {
@@ -507,7 +563,8 @@ export default function App() {
<div
className={cn(
"w-full min-w-0",
(isDocsRoute || isChatRoute) && "min-h-0 flex flex-1 flex-col",
(isDocsRoute || isChatRoute) &&
"min-h-0 flex flex-1 flex-col",
)}
>
<Routes>
@@ -527,6 +584,8 @@ export default function App() {
</div>
<PluginSlot name="overlay" />
<DesktopBridge />
<RuntimeOverlay />
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { useEffect } from "react";
import { isDashboardGuiEnabled } from "@/lib/dashboard-flags";
declare global {
interface Window {
__TAURI__?: {
notification?: {
isPermissionGranted: () => Promise<boolean>;
requestPermission: () => Promise<"default" | "denied" | "granted">;
sendNotification: (notification: {
body?: string;
title: string;
}) => void;
};
};
}
}
export function DesktopBridge() {
useEffect(() => {
if (!isDashboardGuiEnabled()) return;
const notify = async (title: string, body?: string) => {
const api = window.__TAURI__?.notification;
if (!api) return;
let granted = await api.isPermissionGranted();
if (!granted) {
granted = (await api.requestPermission()) === "granted";
}
if (granted) api.sendNotification({ body, title });
};
const onNotify = (event: Event) => {
const detail = (event as CustomEvent<{ body?: string; title?: string }>)
.detail;
if (!detail?.title) return;
void notify(detail.title, detail.body);
};
window.addEventListener("hermes:desktop-notify", onNotify);
return () => window.removeEventListener("hermes:desktop-notify", onNotify);
}, []);
return null;
}

View File

@@ -0,0 +1,117 @@
import { RotateCw } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/api";
import { isDashboardGuiEnabled } from "@/lib/dashboard-flags";
import { cn } from "@/lib/utils";
type RuntimeState = "checking" | "healthy" | "reconnecting";
const POLL_MS = 2_500;
export function RuntimeOverlay() {
const [state, setState] = useState<RuntimeState>("checking");
const [isGui, setIsGui] = useState(() => isDashboardGuiEnabled());
const [lastOkAt, setLastOkAt] = useState<number | null>(null);
const [notifiedDown, setNotifiedDown] = useState(false);
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const runtime = await api.getRuntime();
if (cancelled) return;
setIsGui(runtime.gui);
setLastOkAt(Date.now());
if (notifiedDown) {
window.dispatchEvent(
new CustomEvent("hermes:desktop-notify", {
detail: {
body: "The dashboard runtime is healthy again.",
title: "Hermes Reconnected",
},
}),
);
setNotifiedDown(false);
}
setState("healthy");
} catch {
if (cancelled) return;
setNotifiedDown((already) => {
if (!already && isGui) {
window.dispatchEvent(
new CustomEvent("hermes:desktop-notify", {
detail: {
body: "Trying to reconnect to the local Hermes runtime.",
title: "Hermes Runtime Disconnected",
},
}),
);
}
return true;
});
setState((prev) => (prev === "checking" ? "checking" : "reconnecting"));
}
};
void poll();
const id = setInterval(poll, POLL_MS);
return () => {
cancelled = true;
clearInterval(id);
};
}, [isGui, notifiedDown]);
const detail = useMemo(() => {
if (state === "checking") return "Checking local Hermes runtime...";
if (!lastOkAt) return "Trying to reconnect to the local Hermes runtime.";
return `Runtime connection dropped. Last healthy ${Math.max(
1,
Math.round((Date.now() - lastOkAt) / 1000),
)}s ago.`;
}, [lastOkAt, state]);
if (!isGui || state === "healthy") return null;
return (
<div
className={cn(
"fixed inset-0 z-80 flex items-center justify-center",
"bg-black/70 backdrop-blur-sm",
)}
role="status"
aria-live="polite"
>
<div
className={cn(
"w-[min(92vw,28rem)] border border-current/20 bg-background-base/95",
"px-6 py-5 text-midground shadow-2xl",
)}
>
<div className="flex items-start gap-3">
<RotateCw className="mt-0.5 h-4 w-4 shrink-0 animate-spin" />
<div className="min-w-0">
<p className="font-mondwest text-sm tracking-[0.16em]">
Hermes GUI Runtime
</p>
<p className="mt-2 text-xs normal-case leading-5 text-muted-foreground">
{detail}
</p>
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="mt-5 h-8 text-xs"
onClick={() => window.location.reload()}
>
Reload Window
</Button>
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { Typography } from "@nous-research/ui";
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
import { cn } from "@/lib/utils";
import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils";
import { Typography } from "@nous-research/ui";
export function SidebarFooter() {
const status = useSidebarStatus();
@@ -19,7 +19,9 @@ export function SidebarFooter() {
mondwest
className="font-mono-ui text-[0.7rem] tabular-nums tracking-[0.1em] text-muted-foreground/70"
>
{status?.version != null ? `v${status.version}` : "—"}
{status?.version != null
? `v${status.version}${status.gui ? " · GUI" : ""}`
: "—"}
</Typography>
<a

View File

@@ -18,7 +18,10 @@ function setSessionHeader(headers: Headers, token: string): void {
}
}
export async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
export async function fetchJSON<T>(
url: string,
init?: RequestInit,
): Promise<T> {
// Inject the session token into all /api/ requests.
const headers = new Headers(init?.headers);
const token = window.__HERMES_SESSION_TOKEN__;
@@ -40,32 +43,50 @@ async function getSessionToken(): Promise<string> {
_sessionToken = injected;
return _sessionToken;
}
throw new Error("Session token not available — page must be served by the Hermes dashboard server");
throw new Error(
"Session token not available — page must be served by the Hermes dashboard server",
);
}
export const api = {
getHealth: () => fetchJSON<HealthResponse>("/api/health"),
getRuntime: () => fetchJSON<RuntimeResponse>("/api/runtime"),
getSetupState: () => fetchJSON<SetupStateResponse>("/api/setup/state"),
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
getSessions: (limit = 20, offset = 0) =>
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
fetchJSON<PaginatedSessions>(
`/api/sessions?limit=${limit}&offset=${offset}`,
),
getSessionMessages: (id: string) =>
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
fetchJSON<SessionMessagesResponse>(
`/api/sessions/${encodeURIComponent(id)}/messages`,
),
deleteSession: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => {
getLogs: (params: {
file?: string;
lines?: number;
level?: string;
component?: string;
}) => {
const qs = new URLSearchParams();
if (params.file) qs.set("file", params.file);
if (params.lines) qs.set("lines", String(params.lines));
if (params.level && params.level !== "ALL") qs.set("level", params.level);
if (params.component && params.component !== "all") qs.set("component", params.component);
if (params.component && params.component !== "all")
qs.set("component", params.component);
return fetchJSON<LogsResponse>(`/api/logs?${qs.toString()}`);
},
getAnalytics: (days: number) =>
fetchJSON<AnalyticsResponse>(`/api/analytics/usage?days=${days}`),
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
getSchema: () =>
fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>(
"/api/config/schema",
),
getModelInfo: () => fetchJSON<ModelInfoResponse>("/api/model/info"),
saveConfig: (config: Record<string, unknown>) =>
fetchJSON<{ ok: boolean }>("/api/config", {
@@ -107,18 +128,29 @@ export const api = {
// Cron jobs
getCronJobs: () => fetchJSON<CronJob[]>("/api/cron/jobs"),
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }) =>
createCronJob: (job: {
prompt: string;
schedule: string;
name?: string;
deliver?: string;
}) =>
fetchJSON<CronJob>("/api/cron/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(job),
}),
pauseCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/pause`, { method: "POST" }),
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/pause`, {
method: "POST",
}),
resumeCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/resume`, { method: "POST" }),
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/resume`, {
method: "POST",
}),
triggerCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/trigger`, { method: "POST" }),
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/trigger`, {
method: "POST",
}),
deleteCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}`, { method: "DELETE" }),
@@ -134,7 +166,9 @@ export const api = {
// Session search (FTS5)
searchSessions: (q: string) =>
fetchJSON<SessionSearchResponse>(`/api/sessions/search?q=${encodeURIComponent(q)}`),
fetchJSON<SessionSearchResponse>(
`/api/sessions/search?q=${encodeURIComponent(q)}`,
),
// OAuth provider management
getOAuthProviders: () =>
@@ -163,7 +197,11 @@ export const api = {
},
);
},
submitOAuthCode: async (providerId: string, sessionId: string, code: string) => {
submitOAuthCode: async (
providerId: string,
sessionId: string,
code: string,
) => {
const token = await getSessionToken();
return fetchJSON<OAuthSubmitResponse>(
`/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
@@ -209,8 +247,7 @@ export const api = {
fetchJSON<{ ok: boolean; count: number }>("/api/dashboard/plugins/rescan"),
// Dashboard themes
getThemes: () =>
fetchJSON<DashboardThemesResponse>("/api/dashboard/themes"),
getThemes: () => fetchJSON<DashboardThemesResponse>("/api/dashboard/themes"),
setTheme: (name: string) =>
fetchJSON<{ ok: boolean; theme: string }>("/api/dashboard/theme", {
method: "PUT",
@@ -244,6 +281,7 @@ export interface StatusResponse {
active_sessions: number;
config_path: string;
config_version: number;
embedded_chat: boolean;
env_path: string;
gateway_exit_reason: string | null;
gateway_health_url: string | null;
@@ -252,12 +290,68 @@ export interface StatusResponse {
gateway_running: boolean;
gateway_state: string | null;
gateway_updated_at: string | null;
gui: boolean;
hermes_home: string;
latest_config_version: number;
release_date: string;
version: string;
}
export interface HealthResponse {
embedded_chat: boolean;
mode: "browser" | "gui";
profile: string;
status: "ok";
version: string;
}
export interface RuntimeResponse {
dashboard: {
embedded_chat: boolean;
};
gateway: {
pid: number | null;
platforms: Record<string, PlatformStatus>;
running: boolean;
state: string | null;
};
gui: boolean;
hermes_home: string;
profile: string;
status: string;
}
export interface SetupStateResponse {
checklist: {
model: boolean;
provider: boolean;
terminal: boolean;
};
gui: boolean;
hermes_home: string;
is_fresh_mode: boolean;
model: {
configured: boolean;
value: string;
};
needs_setup: boolean;
profile: string;
provider: {
active_provider: string | null;
configured_env_keys: string[];
recommended_keys: Array<{
description: string;
is_set: boolean;
name: string;
url: string | null;
}>;
};
terminal: {
backend: string;
configured: boolean;
};
}
export interface SessionInfo {
id: string;
source: string | null;

View File

@@ -2,6 +2,8 @@ declare global {
interface Window {
/** Set true by the server only for `hermes dashboard --tui` (or HERMES_DASHBOARD_TUI=1). */
__HERMES_DASHBOARD_EMBEDDED_CHAT__?: boolean;
/** Set true by the server for `hermes dashboard --gui`. */
__HERMES_DASHBOARD_GUI__?: boolean;
/** @deprecated Older injected name; treated as on when true. */
__HERMES_DASHBOARD_TUI__?: boolean;
}
@@ -13,3 +15,8 @@ export function isDashboardEmbeddedChatEnabled(): boolean {
if (window.__HERMES_DASHBOARD_EMBEDDED_CHAT__ === true) return true;
return window.__HERMES_DASHBOARD_TUI__ === true;
}
export function isDashboardGuiEnabled(): boolean {
if (typeof window === "undefined") return false;
return window.__HERMES_DASHBOARD_GUI__ === true;
}

View File

@@ -18,6 +18,9 @@ export function resolvePageTitle(
pluginTabs: { path: string; label: string }[],
): string {
const normalized = pathname.replace(/\/$/, "") || "/";
if (normalized === "/setup") {
return "Setup";
}
if (normalized === "/") {
return t.app.nav.sessions;
}

434
web/src/pages/SetupPage.tsx Normal file
View File

@@ -0,0 +1,434 @@
import { OAuthProvidersCard } from "@/components/OAuthProvidersCard";
import { Toast } from "@/components/Toast";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/useToast";
import { api, type EnvVarInfo, type SetupStateResponse } from "@/lib/api";
import { PluginSlot } from "@/plugins";
import {
ArrowRight,
CheckCircle2,
Circle,
KeyRound,
Loader2,
Settings2,
Sparkles,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
const MODEL_PRESETS = [
"anthropic/claude-sonnet-4.6",
"openai/gpt-4.1",
"google/gemini-2.5-pro",
"deepseek/deepseek-reasoner",
];
const FALLBACK_PROVIDER_KEYS = [
"OPENROUTER_API_KEY",
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"NOUS_API_KEY",
];
function readModelValue(config: Record<string, unknown> | null): string {
if (!config) return "";
const modelValue = config.model;
if (typeof modelValue === "string") return modelValue;
if (
modelValue &&
typeof modelValue === "object" &&
!Array.isArray(modelValue)
) {
const defaultModel = (modelValue as Record<string, unknown>).default;
if (typeof defaultModel === "string") return defaultModel;
}
return "";
}
export default function SetupPage() {
const navigate = useNavigate();
const { toast, showToast } = useToast();
const [setupState, setSetupState] = useState<SetupStateResponse | null>(null);
const [envVars, setEnvVars] = useState<Record<string, EnvVarInfo> | null>(
null,
);
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const [savingKey, setSavingKey] = useState(false);
const [savingConfig, setSavingConfig] = useState(false);
const [providerKey, setProviderKey] = useState("");
const [providerValue, setProviderValue] = useState("");
const [modelValue, setModelValue] = useState("");
const [terminalBackend, setTerminalBackend] = useState("local");
const load = useCallback(async () => {
setLoading(true);
try {
const [state, vars, cfg] = await Promise.all([
api.getSetupState(),
api.getEnvVars(),
api.getConfig(),
]);
setSetupState(state);
setEnvVars(vars);
setConfig(cfg);
setModelValue(state.model.value || readModelValue(cfg));
setTerminalBackend(state.terminal.backend || "local");
const preferredKeys = [
...state.provider.recommended_keys.map((k) => k.name),
...FALLBACK_PROVIDER_KEYS,
];
const availableKeys = preferredKeys.filter((key) => vars[key] != null);
const firstUnset = availableKeys.find((key) => !vars[key]?.is_set);
const nextKey = firstUnset || availableKeys[0] || "";
setProviderKey((prev) => (prev && vars[prev] ? prev : nextKey));
} catch (error) {
showToast(`Failed to load setup state: ${error}`, "error");
} finally {
setLoading(false);
}
}, [showToast]);
useEffect(() => {
void load();
}, [load]);
const providerOptions = useMemo(() => {
if (!envVars || !setupState) return [];
const keySet = new Set<string>();
const options: Array<{
key: string;
description: string;
isSet: boolean;
url: string | null;
}> = [];
for (const item of setupState.provider.recommended_keys) {
if (!envVars[item.name]) continue;
keySet.add(item.name);
options.push({
key: item.name,
description: item.description,
isSet: envVars[item.name]?.is_set ?? item.is_set,
url: item.url,
});
}
for (const [key, info] of Object.entries(envVars)) {
if (keySet.has(key)) continue;
if (info.category !== "provider") continue;
if (!(key.endsWith("_API_KEY") || key.endsWith("_TOKEN"))) continue;
options.push({
key,
description: info.description,
isSet: info.is_set,
url: info.url,
});
}
return options;
}, [envVars, setupState]);
const selectedProviderMeta = providerOptions.find(
(o) => o.key === providerKey,
);
const checklist = setupState?.checklist;
const ready = !!checklist?.provider && !!checklist?.model;
const completeCount =
Number(!!checklist?.provider) +
Number(!!checklist?.model) +
Number(!!checklist?.terminal);
const saveProviderKey = async () => {
if (!providerKey || !providerValue.trim()) return;
setSavingKey(true);
try {
await api.setEnvVar(providerKey, providerValue.trim());
setProviderValue("");
showToast(`Saved ${providerKey}`, "success");
window.dispatchEvent(new Event("hermes:setup-refresh"));
await load();
} catch (error) {
showToast(`Failed to save ${providerKey}: ${error}`, "error");
} finally {
setSavingKey(false);
}
};
const saveModelAndDefaults = async () => {
if (!config) return;
const trimmedModel = modelValue.trim();
if (!trimmedModel) {
showToast("Model is required.", "error");
return;
}
const nextConfig = structuredClone(config);
nextConfig.model = trimmedModel;
const rawTerminal = nextConfig.terminal;
const terminal =
rawTerminal &&
typeof rawTerminal === "object" &&
!Array.isArray(rawTerminal)
? { ...(rawTerminal as Record<string, unknown>) }
: {};
terminal.backend = terminalBackend || "local";
nextConfig.terminal = terminal;
setSavingConfig(true);
try {
await api.saveConfig(nextConfig);
setConfig(nextConfig);
showToast("Saved model and runtime defaults.", "success");
window.dispatchEvent(new Event("hermes:setup-refresh"));
await load();
} catch (error) {
showToast(`Failed to save setup config: ${error}`, "error");
} finally {
setSavingConfig(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!setupState) {
return (
<div className="border border-destructive/30 bg-destructive/6 p-4 text-sm text-destructive">
Setup state unavailable. Reload the dashboard.
</div>
);
}
return (
<div className="flex flex-col gap-4">
<PluginSlot name="setup:top" />
<Toast toast={toast} />
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-muted-foreground" />
<CardTitle>Hermes GUI Setup</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-3 text-sm">
<div className="flex items-center gap-2">
<Badge variant={ready ? "success" : "outline"}>
{ready ? "Ready" : "Setup Required"}
</Badge>
<span className="text-muted-foreground">
{completeCount}/3 checks complete
</span>
</div>
{setupState.is_fresh_mode && (
<p className="text-xs text-success">
Fresh mode active. This GUI run is isolated from your default
install.
</p>
)}
<p className="text-xs text-muted-foreground">
Profile: <code>{setupState.profile}</code> · Home:{" "}
<code>{setupState.hermes_home}</code>
</p>
<div className="grid gap-1 text-xs">
<ChecklistItem
done={setupState.checklist.provider}
label="Provider credential connected"
/>
<ChecklistItem
done={setupState.checklist.model}
label="Model selected"
/>
<ChecklistItem
done={setupState.checklist.terminal}
label="Terminal backend configured"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<KeyRound className="h-5 w-5 text-muted-foreground" />
<CardTitle>1) Connect a provider</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-4">
<OAuthProvidersCard
onError={(msg) => showToast(msg, "error")}
onSuccess={(msg) => showToast(msg, "success")}
/>
<div className="grid gap-2 border border-border p-3">
<Label className="text-xs uppercase tracking-wide text-muted-foreground">
API Key (manual)
</Label>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto]">
<select
value={providerKey}
onChange={(e) => setProviderKey(e.target.value)}
className="h-9 border border-border bg-background px-2 text-xs"
>
{providerOptions.map((option) => (
<option key={option.key} value={option.key}>
{option.key}
{option.isSet ? " (set)" : ""}
</option>
))}
</select>
<Input
type="password"
value={providerValue}
onChange={(e) => setProviderValue(e.target.value)}
placeholder="Paste API key"
className="h-9 font-mono-ui text-xs"
/>
<Button
type="button"
size="sm"
className="h-9"
onClick={saveProviderKey}
disabled={savingKey || !providerKey || !providerValue.trim()}
>
{savingKey ? "Saving..." : "Save key"}
</Button>
</div>
{selectedProviderMeta?.description && (
<p className="text-xs text-muted-foreground">
{selectedProviderMeta.description}
</p>
)}
{selectedProviderMeta?.url && (
<a
href={selectedProviderMeta.url}
target="_blank"
rel="noreferrer"
className="text-xs text-primary hover:underline"
>
Get key
</a>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Settings2 className="h-5 w-5 text-muted-foreground" />
<CardTitle>2) Choose model + runtime defaults</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-3">
<div className="grid gap-2">
<Label className="text-xs uppercase tracking-wide text-muted-foreground">
Model
</Label>
<Input
value={modelValue}
onChange={(e) => setModelValue(e.target.value)}
placeholder="anthropic/claude-sonnet-4.6"
className="font-mono-ui text-xs"
/>
<div className="flex flex-wrap gap-1">
{MODEL_PRESETS.map((preset) => (
<button
key={preset}
type="button"
onClick={() => setModelValue(preset)}
className="border border-border px-2 py-1 text-[11px] hover:bg-secondary/40"
>
{preset}
</button>
))}
</div>
</div>
<div className="grid gap-2">
<Label className="text-xs uppercase tracking-wide text-muted-foreground">
Terminal backend
</Label>
<select
value={terminalBackend}
onChange={(e) => setTerminalBackend(e.target.value)}
className="h-9 border border-border bg-background px-2 text-xs"
>
<option value="local">local</option>
<option value="docker">docker</option>
<option value="ssh">ssh</option>
<option value="modal">modal</option>
<option value="daytona">daytona</option>
<option value="singularity">singularity</option>
</select>
</div>
<Button
type="button"
onClick={saveModelAndDefaults}
disabled={savingConfig || !modelValue.trim()}
className="w-fit"
>
{savingConfig ? "Saving..." : "Save setup defaults"}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>3) Continue</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2">
<Button
type="button"
onClick={() => navigate("/sessions", { replace: true })}
disabled={!ready}
className="gap-1.5"
>
Enter Hermes
<ArrowRight className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="outline"
onClick={() => navigate("/env")}
>
Advanced keys
</Button>
<Button
type="button"
variant="outline"
onClick={() => navigate("/config")}
>
Advanced config
</Button>
</CardContent>
</Card>
<PluginSlot name="setup:bottom" />
</div>
);
}
function ChecklistItem({ done, label }: { done: boolean; label: string }) {
return (
<div className="flex items-center gap-2">
{done ? (
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
) : (
<Circle className="h-3.5 w-3.5 text-muted-foreground/60" />
)}
<span className="text-muted-foreground">{label}</span>
</div>
);
}