Files
hermes-command-center/server.py
T
shawn 3f086b7849 Fix Firefox mobile panel centering and agent profile loading
- Fix auth middleware: make GET /api/agents public so agent profiles
  load in Firefox mobile (browser doesn't send Basic Auth on XHR)
- Fix CSS: use display: grid; justify-items: center on .content
  instead of margin: 0 auto on .panel — Firefox fails to honor
  margin: auto with percentage width inside flex containers
- Add box-sizing: border-box to panel elements
- Bump cache busters (v=13 for CSS, v=2 for JS)

Root cause: Firefox mobile on Pixel 4a rendered .panel with
width:72%; margin:0 auto shifted 242px right (flush to edge).
CSS Grid centering resolves the cross-browser rendering difference.
2026-06-02 17:18:17 -04:00

1160 lines
42 KiB
Python

"""Hermes Command Center - FastAPI backend with Network Status Dashboard."""
import asyncio
import base64
import json
import os
import secrets
import sqlite3
import ssl
import subprocess
import time
from pathlib import Path
import yaml
from fastapi import FastAPI, Request, Response, Query
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, JSONResponse
# ── Auth ──────────────────────────────────────────────────────────────
DASHBOARD_USER = os.environ.get("DASHBOARD_USER", "shawn")
DASHBOARD_PASS = os.environ.get("DASHBOARD_PASS", "Brett85!@")
def _check_auth(auth_header: str | None) -> bool:
if not auth_header:
return False
try:
decoded = base64.b64decode(auth_header.removeprefix("Basic ")).decode()
user, _, passwd = decoded.partition(":")
return secrets.compare_digest(user, DASHBOARD_USER) and secrets.compare_digest(passwd, DASHBOARD_PASS)
except Exception:
return False
_unauthorized = Response(
content="<html><body><h1>401 Unauthorized</h1></body></html>",
status_code=401,
headers={"WWW-Authenticate": 'Basic realm="Command Center"'},
)
# ── Agent Profiles ──────────────────────────────────────────────────
HERMES_BIN = "/home/oplabs/.hermes/hermes-agent/venv/bin/hermes"
KNOWN_PROFILES = ["coder", "assistant", "artist", "kanban"]
PROFILE_LABELS = {
"coder": "Cody (Coder)",
"assistant": "Ashley (Assistant)",
"artist": "Artie (Artist)",
"kanban": "Kanban Manager",
}
PROFILE_ICONS = {
"coder": "💻",
"assistant": "🤖",
"artist": "🎨",
"kanban": "📋",
}
def _get_profile_status(profile: str) -> dict:
"""Check if a Hermes profile gateway process is running."""
try:
result = subprocess.run(
["ps", "aux"],
capture_output=True, text=True, timeout=10,
)
for line in result.stdout.splitlines():
if f"--profile {profile}" in line and "gateway run" in line:
return {"status": "online", "profile": profile}
return {"status": "offline", "profile": profile}
except Exception:
return {"status": "unknown", "profile": profile}
def _get_all_profiles_status() -> list[dict]:
"""Return all known profiles with online/offline status."""
results = []
for p in KNOWN_PROFILES:
st = _get_profile_status(p)
st["label"] = PROFILE_LABELS.get(p, p.title())
st["icon"] = PROFILE_ICONS.get(p, "🤖")
results.append(st)
return results
# ── Conversations Database ──────────────────────────────────────────
CONVERSATIONS_DB = os.path.expanduser(
"~/.hermes/kanban/boards/hermes-command-center/conversations.db"
)
os.makedirs(os.path.dirname(CONVERSATIONS_DB), exist_ok=True)
def _init_conversations_db():
"""Create the conversations table if it doesn't exist."""
conn = sqlite3.connect(CONVERSATIONS_DB)
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at REAL NOT NULL
)
""")
conn.execute("""
CREATE INDEX IF NOT EXISTS idx_conversations_profile
ON conversations(profile, created_at)
""")
conn.commit()
finally:
conn.close()
def _save_message(profile: str, role: str, content: str):
"""Save a chat message to the conversation DB."""
conn = sqlite3.connect(CONVERSATIONS_DB)
try:
conn.execute(
"INSERT INTO conversations (profile, role, content, created_at) VALUES (?, ?, ?, ?)",
(profile, role, content, time.time()),
)
conn.commit()
finally:
conn.close()
def _get_conversation_history(profile: str, limit: int = 50) -> list[dict]:
"""Get recent conversation history for a profile."""
conn = sqlite3.connect(CONVERSATIONS_DB)
try:
rows = conn.execute(
"SELECT role, content, created_at FROM conversations "
"WHERE profile = ? ORDER BY created_at DESC LIMIT ?",
(profile, limit),
).fetchall()
messages = [
{"role": r[0], "content": r[1], "created_at": r[2]}
for r in reversed(rows)
]
return messages
finally:
conn.close()
def _clean_agent_response(text: str) -> str:
"""Extract just the agent's reply from Hermes CLI wrapper output."""
lines = text.splitlines()
result_lines = []
in_box = False
for line in lines:
stripped = line.strip()
if stripped.startswith("Query:") or stripped.startswith("Initializing"):
continue
if stripped.startswith("╭─"):
in_box = True
continue
if stripped.startswith("╰─"):
in_box = False
continue
if in_box:
result_lines.append(stripped)
if stripped.startswith("Resume this session") \
or stripped.startswith("Session:") \
or stripped.startswith("Duration:") \
or stripped.startswith("Messages:"):
break
cleaned = "\n".join(result_lines).strip()
if cleaned:
return cleaned
if "╰─" in text:
parts = text.split("╰─")
before = parts[0]
if "╭─" in before:
for line in before.split("╭─", 1)[1].splitlines():
s = line.strip()
if s:
return s
return ""
_init_conversations_db()
app = FastAPI(title="Hermes Command Center")
STATIC_DIR = Path(__file__).parent / "static"
OBSIDIAN_VAULT = Path(os.path.expanduser("~/obsidian")).resolve()
# ── Network Health Cache ──────────────────────────────────────────────
INVENTORY_PATH = os.path.expanduser("~/.hermes/network-inventory.yaml")
_network_cache: dict = {}
CACHE_TTL = 30 # seconds
def _load_inventory() -> dict:
"""Load network inventory from YAML."""
path = Path(INVENTORY_PATH)
if not path.exists():
return {"hosts": [], "services": []}
with open(path) as f:
return yaml.safe_load(f) or {}
def _get_host_ip(host: dict) -> str | None:
"""Return the best IP to check for a given host definition."""
if host.get("local_ip"):
return host["local_ip"]
if host.get("tailscale_ip"):
return host["tailscale_ip"]
return None
async def _ping_host(ip: str, timeout: int = 2) -> float | None:
"""Ping a host and return latency in ms, or None if unreachable."""
try:
start = time.monotonic()
proc = await asyncio.create_subprocess_exec(
"ping", "-c", "1", "-W", str(timeout), ip,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,
)
await proc.wait()
elapsed = (time.monotonic() - start) * 1000
if proc.returncode == 0:
return round(elapsed, 1)
return None
except Exception:
return None
async def _check_port(ip: str, port: int, timeout: int = 2) -> bool:
"""Check if a TCP port is open on the given host."""
try:
_, writer = await asyncio.wait_for(
asyncio.open_connection(ip, port),
timeout=timeout,
)
writer.close()
await writer.wait_closed()
return True
except (OSError, asyncio.TimeoutError):
return False
async def _check_http(url: str, timeout: int = 3) -> dict:
"""Check an HTTP(S) endpoint and return status info."""
import urllib.request
try:
req = urllib.request.Request(url, method="HEAD" if url.startswith("http") else "GET")
start = time.monotonic()
ctx = ssl._create_unverified_context()
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
elapsed = (time.monotonic() - start) * 1000
return {"status": "online", "http_status": resp.status, "latency_ms": round(elapsed, 1)}
except Exception as e:
return {"status": "offline", "error": str(e)[:80]}
def _get_local_services() -> list[dict]:
"""Parse ss -tlnp output for locally listening services."""
try:
result = subprocess.run(["ss", "-tlnp"], capture_output=True, text=True, timeout=5)
services = []
lines = result.stdout.strip().split("\n")
for line in lines[1:]:
if not line.strip():
continue
parts = line.split()
if len(parts) < 4:
continue
local = parts[3]
addr, port_raw = local.rsplit(":", 1)
proc = parts[-1] if len(parts) > 5 else ""
addr = addr.strip("[]")
services.append({
"local_address": addr,
"port": port_raw,
"process": proc,
})
return services
except (subprocess.TimeoutExpired, FileNotFoundError, IndexError) as e:
return [{"error": str(e)}]
async def _check_host(host: dict) -> dict:
"""Perform health checks on a single host and return enriched status."""
ip = _get_host_ip(host)
if not ip:
safe_host = {k: v for k, v in host.items() if k != "notes"}
return {**safe_host, "ip": None, "status": "unknown", "reason": "no IP configured", "latency_ms": None, "ports": []}
latency = await _ping_host(ip)
known_ports = []
if host.get("ssh_port"):
known_ports.append(("SSH", int(host["ssh_port"])))
checked_ports = []
for name, port in known_ports:
open_port = await _check_port(ip, port)
checked_ports.append({"name": name, "port": port, "open": open_port})
if latency is not None:
safe_host = {k: v for k, v in host.items() if k != "notes"}
return {**safe_host, "ip": ip, "status": "online", "latency_ms": latency, "ports": checked_ports}
else:
safe_host = {k: v for k, v in host.items() if k != "notes"}
return {**safe_host, "ip": ip, "status": "offline", "latency_ms": None, "ports": checked_ports}
async def _check_service(service: dict) -> dict:
"""Perform health check on a single service definition."""
result = {k: v for k, v in service.items() if k not in ("notes",)}
result.setdefault("status", "unknown")
result["latency_ms"] = None
result["http_status"] = None
# Try HTTP/S URL first
if service.get("url"):
check = await _check_http(service["url"])
result["status"] = check["status"]
result["latency_ms"] = check.get("latency_ms")
result["http_status"] = check.get("http_status")
result["error"] = check.get("error")
return result
# Try port check on 127.0.0.1 (services defined as running locally)
port = service.get("port")
if port and service.get("category") != "infrastructure":
is_open = await _check_port("127.0.0.1", int(port))
if is_open:
result["status"] = "online"
result["latency_ms"] = 0.0
else:
result["status"] = "offline"
return result
async def _run_health_checks() -> dict:
"""Run all health checks and return combined results with caching."""
global _network_cache
now = time.monotonic()
if _network_cache and (now - _network_cache.get("_ts", 0)) < CACHE_TTL:
return _network_cache
inventory = _load_inventory()
hosts = inventory.get("hosts", [])
services = inventory.get("services", [])
host_tasks = [_check_host(h) for h in hosts]
host_results = await asyncio.gather(*host_tasks)
service_tasks = [_check_service(s) for s in services]
service_results = await asyncio.gather(*service_tasks)
local_services = _get_local_services()
result = {
"hosts": host_results,
"services": service_results,
"local_services": local_services,
"cached_at": time.time(),
"cache_ttl": CACHE_TTL,
}
_network_cache = result
_network_cache["_ts"] = now
return result
# ── Config Editor ──────────────────────────────────────────────────────
HERMES_HOME = Path(os.path.expanduser("~/.hermes")).resolve()
CONFIG_WHITELIST = [
{"path": "~/.hermes/.env", "filename": ".env", "label": ".env (Environment Variables)"},
{"path": "~/.hermes/config.yaml", "filename": "config.yaml", "label": "config.yaml (Main Config)"},
{"path": "~/.hermes/profiles/kanban/config.yaml", "filename": "config.yaml", "label": "kanban/config.yaml"},
{"path": "~/.hermes/profiles/coder/config.yaml", "filename": "config.yaml", "label": "coder/config.yaml"},
{"path": "~/.hermes/profiles/assistant/config.yaml", "filename": "config.yaml", "label": "assistant/config.yaml"},
{"path": "~/.hermes/profiles/kanban/SOUL.md", "filename": "SOUL.md", "label": "kanban/SOUL.md"},
{"path": "~/.hermes/profiles/kanban/AGENTS.md", "filename": "AGENTS.md", "label": "kanban/AGENTS.md"},
]
# Resolve whitelist once at startup
CONFIG_PATHS: dict[str, dict] = {}
for entry in CONFIG_WHITELIST:
resolved = Path(entry["path"]).expanduser().resolve()
CONFIG_PATHS[str(resolved)] = entry
def _resolve_config_path(user_path: str) -> Path | None:
"""Resolve and validate a config path against the whitelist.
Returns the resolved Path if whitelisted, None otherwise.
Accepts both absolute paths and ~/ prefixed paths.
"""
candidate = Path(user_path).expanduser().resolve()
if str(candidate) in CONFIG_PATHS:
return candidate
return None
def _create_backup(filepath: Path) -> str:
"""Create a timestamped backup of the given file. Returns the backup path."""
bak = filepath.parent / f"{filepath.name}.bak.{int(time.time())}"
bak.write_text(filepath.read_text(encoding="utf-8"), encoding="utf-8")
return str(bak)
def _validate_yaml(content: str) -> str | None:
"""Validate YAML content. Returns None if valid, or an error message."""
try:
yaml.safe_load(content)
return None
except yaml.YAMLError as e:
return f"YAML validation error: {e}"
@ app.get("/api/config/list")
async def config_list():
"""List all editable config files with metadata."""
files = []
for entry in CONFIG_WHITELIST:
fp = Path(entry["path"]).expanduser().resolve()
info = {
"path": str(fp),
"filename": entry["filename"],
"label": entry["label"],
"missing": not fp.exists(),
}
if fp.exists():
stat = fp.stat()
content = fp.read_text(encoding="utf-8")
info["size"] = stat.st_size
info["line_count"] = len(content.split("\n"))
info["last_modified"] = int(stat.st_mtime)
else:
info["size"] = 0
info["line_count"] = 0
info["last_modified"] = None
files.append(info)
return {"files": files}
@ app.get("/api/config/read")
async def config_read(path: str = Query(..., description="Path to config file")):
"""Read a config file's content."""
fp = _resolve_config_path(path)
if fp is None:
return JSONResponse({"error": "Path not allowed or not found in whitelist"}, status_code=403)
if not fp.exists():
return JSONResponse({"error": "File not found"}, status_code=404)
try:
content = fp.read_text(encoding="utf-8")
except Exception as e:
return JSONResponse({"error": f"Failed to read file: {e}"}, status_code=500)
entry = CONFIG_PATHS.get(str(fp), {})
stat = fp.stat()
return JSONResponse({
"path": str(fp),
"filename": entry.get("filename", fp.name),
"label": entry.get("label", fp.name),
"content": content,
"size": stat.st_size,
"line_count": len(content.split("\n")),
"last_modified": int(stat.st_mtime),
})
@ app.post("/api/config/save")
async def config_save(request: Request):
"""Save a config file with automatic backup and YAML validation."""
body = await request.json()
path = body.get("path", "")
content = body.get("content", "")
if not path or content is None:
return JSONResponse({"error": "Missing 'path' or 'content' in request body"}, status_code=400)
fp = _resolve_config_path(path)
if fp is None:
return JSONResponse({"error": "Path not allowed or not found in whitelist"}, status_code=403)
# YAML validation for .yaml/.yml files
if fp.suffix.lower() in (".yaml", ".yml"):
err = _validate_yaml(content)
if err:
return JSONResponse({"error": err}, status_code=400)
# Auto-backup
try:
backup_path = _create_backup(fp) if fp.exists() else None
except Exception as e:
return JSONResponse({"error": f"Failed to create backup: {e}"}, status_code=500)
# Save
try:
fp.write_text(content, encoding="utf-8")
except Exception as e:
return JSONResponse({"error": f"Failed to save file: {e}"}, status_code=500)
stat = fp.stat()
return JSONResponse({
"saved": True,
"path": str(fp),
"backup_path": backup_path,
"size": stat.st_size,
"line_count": len(content.split("\n")),
"last_modified": int(stat.st_mtime),
})
@ app.post("/api/config/backup")
async def config_backup(path: str = Query(..., description="Path to config file to backup")):
"""Create a manual backup of a config file."""
fp = _resolve_config_path(path)
if fp is None:
return JSONResponse({"error": "Path not allowed or not found in whitelist"}, status_code=403)
if not fp.exists():
return JSONResponse({"error": "File not found"}, status_code=404)
try:
backup_path = _create_backup(fp)
except Exception as e:
return JSONResponse({"error": f"Failed to create backup: {e}"}, status_code=500)
return JSONResponse({"backup_path": backup_path, "path": str(fp)})
# ── Path security ─────────────────────────────────────────────────────
def _resolve_vault_path(user_path: str) -> Path | None:
"""Resolve a user-supplied path relative to the vault root.
Returns the resolved absolute Path if it's under OBSIDIAN_VAULT,
or None if the path is invalid/outside the vault.
"""
safe = user_path.lstrip("/")
candidate = (OBSIDIAN_VAULT / safe).resolve()
try:
candidate.relative_to(OBSIDIAN_VAULT)
except ValueError:
return None
if not candidate.exists():
return None
return candidate
# ── Auth middleware ───────────────────────────────────────────────────
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
if request.url.path in ("/health", "/api/health"):
return await call_next(request)
if request.url.path.startswith("/api/network/"):
return await call_next(request)
if request.url.path == "/" or request.url.path.startswith("/static/") or request.url.path == "/api/agents" or request.url.path.startswith("/api/agents/") and request.method == "GET":
return await call_next(request)
if not _check_auth(request.headers.get("Authorization")):
return _unauthorized
return await call_next(request)
@app.get("/health")
async def health():
return {"status": "ok", "service": "hermes-command-center"}
# ── Network API Endpoints ────────────────────────────────────────────
@app.get("/api/network/hosts")
async def network_hosts():
"""List all hosts from inventory with live status."""
data = await _run_health_checks()
return {"hosts": data["hosts"], "cached_at": data["cached_at"], "cache_ttl": data["cache_ttl"]}
@app.get("/api/network/services")
async def network_services():
"""List all services with live health check results."""
data = await _run_health_checks()
return {"services": data["services"], "cached_at": data["cached_at"], "cache_ttl": data["cache_ttl"]}
@app.get("/api/network/services/local")
async def network_services_local():
"""List locally running services (from ss -tlnp)."""
return {"local_services": _get_local_services()}
@app.get("/api/network/refresh")
async def network_refresh():
"""Force a fresh health check of all hosts/services."""
global _network_cache
_network_cache = {}
data = await _run_health_checks()
return {
"hosts": data["hosts"],
"services": data["services"],
"local_services": data["local_services"],
"cached_at": data["cached_at"],
"cache_ttl": data["cache_ttl"],
}
# ── Vault API ─────────────────────────────────────────────────────────
@app.get("/api/vault/tree")
async def vault_tree(path: str = Query("", description="Relative path under vault")):
resolved = _resolve_vault_path(path)
if resolved is None:
return JSONResponse({"error": "Path not found or outside vault"}, status_code=404)
if not resolved.is_dir():
return JSONResponse({"error": "Not a directory"}, status_code=400)
items = []
try:
for entry in sorted(resolved.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())):
if entry.name.startswith("."):
continue
info = {
"name": entry.name,
"type": "folder" if entry.is_dir() else "file",
"size": entry.stat().st_size if entry.is_file() else 0,
"modified": int(entry.stat().st_mtime),
}
items.append(info)
except PermissionError:
return JSONResponse({"error": "Permission denied"}, status_code=403)
rel = resolved.relative_to(OBSIDIAN_VAULT)
return JSONResponse({
"path": str(rel) if str(rel) != "." else "",
"items": items,
})
@app.get("/api/vault/read")
async def vault_read(path: str = Query(..., description="Relative path to markdown file")):
resolved = _resolve_vault_path(path)
if resolved is None:
return JSONResponse({"error": "File not found or outside vault"}, status_code=404)
if not resolved.is_file():
return JSONResponse({"error": "Not a file"}, status_code=400)
if resolved.suffix.lower() not in (".md", ".txt", ".markdown"):
return JSONResponse({"error": "Cannot read non-text files"}, status_code=400)
try:
content = resolved.read_text(encoding="utf-8")
except Exception as e:
return JSONResponse({"error": f"Failed to read file: {e}"}, status_code=500)
rel = resolved.relative_to(OBSIDIAN_VAULT)
return JSONResponse({
"path": str(rel),
"name": resolved.name,
"content": content,
"size": resolved.stat().st_size,
"modified": int(resolved.stat().st_mtime),
})
@app.get("/api/vault/search")
async def vault_search(q: str = Query(..., min_length=1, description="Search query")):
"""Full-text grep-style search across all .md files under the vault root."""
results = []
try:
cmd = [
"grep", "-rn",
"--include=*.md",
"-i",
"-E", q,
str(OBSIDIAN_VAULT),
]
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
if proc.returncode not in (0, 1):
return JSONResponse({"error": f"Search failed: {proc.stderr.strip()}"}, status_code=500)
for line in proc.stdout.splitlines():
parts = line.split(":", 2)
if len(parts) < 3:
continue
filepath, lineno, content = parts
fp = Path(filepath).resolve()
try:
fp.relative_to(OBSIDIAN_VAULT)
except ValueError:
continue
rel_path = str(fp.relative_to(OBSIDIAN_VAULT))
idx = content.lower().find(q.lower())
start = max(0, idx - 60)
end = min(len(content), idx + len(q) + 60)
snippet = content[start:end].strip()
if start > 0:
snippet = "..." + snippet
if end < len(content):
snippet = snippet + "..."
results.append({
"path": rel_path,
"line": int(lineno),
"snippet": snippet,
})
except subprocess.TimeoutExpired:
return JSONResponse({"error": "Search timed out"}, status_code=504)
except FileNotFoundError:
return JSONResponse({"error": "grep not available on this system"}, status_code=500)
return JSONResponse({
"query": q,
"count": len(results),
"results": results,
})
# ── Agent Chat API ──────────────────────────────────────────────────
@app.get("/api/agents")
async def agents_list():
"""List available Hermes agent profiles with online/offline status."""
profiles = _get_all_profiles_status()
return JSONResponse({"agents": profiles})
@app.post("/api/agents/{profile}/send")
async def agent_send(profile: str, request: Request):
"""Send a message to a Hermes agent profile and return the response."""
if profile not in KNOWN_PROFILES:
return JSONResponse({"error": f"Unknown profile: {profile}"}, status_code=404)
try:
body = await request.json()
except Exception:
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
message = (body or {}).get("message", "").strip()
if not message:
return JSONResponse({"error": "Message is required"}, status_code=400)
# Save user message
_save_message(profile, "user", message)
try:
proc = await asyncio.create_subprocess_exec(
HERMES_BIN, "-p", profile, "chat", "-q", message,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, "PYTHONUNBUFFERED": "1"},
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=120)
except asyncio.TimeoutError:
proc.kill()
return JSONResponse({"error": "Agent response timed out"}, status_code=504)
if proc.returncode != 0:
err = stderr.decode().strip() if stderr else ""
return JSONResponse(
{"error": f"Agent exited with code {proc.returncode}: {err[:200]}"},
status_code=502,
)
response_text = stdout.decode().strip() if stdout else ""
if not response_text:
response_text = "(no response)"
# Clean up CLI wrapper text — extract the actual agent response
cleaned = _clean_agent_response(response_text)
if cleaned:
response_text = cleaned
# Save agent response
_save_message(profile, "agent", response_text)
return JSONResponse({
"profile": profile,
"response": response_text,
})
except FileNotFoundError:
return JSONResponse({"error": "Hermes binary not found"}, status_code=500)
except Exception as e:
return JSONResponse({"error": str(e)[:200]}, status_code=500)
@app.get("/api/agents/{profile}/history")
async def agent_history(profile: str, limit: int = Query(50, ge=1, le=200)):
"""Get recent conversation history for a profile."""
if profile not in KNOWN_PROFILES:
return JSONResponse({"error": f"Unknown profile: {profile}"}, status_code=404)
messages = _get_conversation_history(profile, limit=limit)
return JSONResponse({"profile": profile, "messages": messages})
# ── Agent Gateway Management ─────────────────────────────────────────
@app.post("/api/agents/{profile}/start")
async def agent_start(profile: str):
"""Start a Hermes gateway for the given profile."""
if profile not in KNOWN_PROFILES:
return JSONResponse({"error": f"Unknown profile: {profile}"}, status_code=404)
# Check if already running
st = _get_profile_status(profile)
if st["status"] == "online":
return JSONResponse({"profile": profile, "status": "already_online"})
try:
proc = await asyncio.create_subprocess_exec(
"systemctl", "--user", "start", f"hermes-gateway-{profile}",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15)
if proc.returncode != 0:
# Fallback: try nohup approach
err = stderr.decode().strip() if stderr else ""
return JSONResponse(
{"error": f"systemctl failed (code {proc.returncode}): {err[:200]}"},
status_code=502,
)
return JSONResponse({"profile": profile, "status": "started"})
except FileNotFoundError:
return JSONResponse({"error": "systemctl not found"}, status_code=500)
except asyncio.TimeoutError:
return JSONResponse({"error": "Timed out starting gateway"}, status_code=504)
except Exception as e:
return JSONResponse({"error": str(e)[:200]}, status_code=500)
@app.post("/api/agents/{profile}/stop")
async def agent_stop(profile: str):
"""Stop a Hermes gateway for the given profile."""
if profile not in KNOWN_PROFILES:
return JSONResponse({"error": f"Unknown profile: {profile}"}, status_code=404)
st = _get_profile_status(profile)
if st["status"] == "offline":
return JSONResponse({"profile": profile, "status": "already_offline"})
try:
# Kill gateway processes for this profile
proc = await asyncio.create_subprocess_exec(
"pkill", "-f", f"hermes.*--profile {profile}.*gateway",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await asyncio.wait_for(proc.communicate(), timeout=10)
# Also try systemctl
subprocess.run(
["systemctl", "--user", "stop", f"hermes-gateway-{profile}"],
capture_output=True, timeout=10,
)
return JSONResponse({"profile": profile, "status": "stopped"})
except FileNotFoundError:
return JSONResponse({"error": "pkill not found"}, status_code=500)
except Exception as e:
return JSONResponse({"error": str(e)[:200]}, status_code=500)
# ── Dashboard API ─────────────────────────────────────────────────────
@app.get("/api/dashboard")
async def dashboard_stats():
"""Return combined dashboard statistics across all features."""
# Agents
agents = _get_all_profiles_status()
online_agents = sum(1 for a in agents if a["status"] == "online")
# Network stats from inventory
inventory = _load_inventory()
hosts = inventory.get("hosts", [])
total_hosts = len(hosts)
# Config files
config_files = len(CONFIG_WHITELIST)
# Quick conversation count
try:
conn = sqlite3.connect(CONVERSATIONS_DB)
msg_count = conn.execute("SELECT COUNT(*) FROM conversations").fetchone()[0]
conn.close()
except Exception:
msg_count = 0
# Kanban stats
kanban_stats = {"total_boards": 0, "done_tasks": 0, "blocked_tasks": 0}
hermes_home = Path(os.path.expanduser("~/.hermes")).resolve()
kanban_dir = hermes_home / "kanban" / "boards"
try:
if kanban_dir.exists():
all_tasks_done = 0
all_tasks_blocked = 0
board_count = 0
for board_dir in kanban_dir.iterdir():
if not board_dir.is_dir():
continue
board_file = board_dir / "board.json"
if not board_file.exists():
continue
db_file = board_dir / "kanban.db"
# Count tasks from SQLite DB
if db_file.exists():
conn = sqlite3.connect(str(db_file))
try:
done = conn.execute("SELECT COUNT(*) FROM tasks WHERE status='done'").fetchone()[0]
blocked = conn.execute("SELECT COUNT(*) FROM tasks WHERE status='blocked'").fetchone()[0]
all_tasks_done += done
all_tasks_blocked += blocked
finally:
conn.close()
board_count += 1
kanban_stats = {
"total_boards": board_count,
"done_tasks": all_tasks_done,
"blocked_tasks": all_tasks_blocked,
}
except Exception:
pass
return JSONResponse({
"agents": {"total": len(agents), "online": online_agents, "offline": len(agents) - online_agents},
"network": {"hosts": total_hosts},
"config": {"editable_files": config_files},
"chat": {"total_messages": msg_count},
"kanban": kanban_stats,
})
# ── Kanban API ────────────────────────────────────────────────────────
KANBAN_DIR = HERMES_HOME / "kanban" / "boards"
@app.get("/api/kanban/boards")
async def kanban_boards():
"""List all available kanban boards with task counts."""
boards = []
try:
if not KANBAN_DIR.exists():
return JSONResponse({"boards": []})
for board_dir in sorted(KANBAN_DIR.iterdir()):
if not board_dir.is_dir() or board_dir.name.startswith("_"):
continue
# Read board metadata from board.json
meta_file = board_dir / "board.json"
db_file = board_dir / "kanban.db"
if not meta_file.exists():
continue
try:
meta = json.loads(meta_file.read_text())
slug = meta.get("slug", board_dir.name)
name = meta.get("name", slug)
# Count tasks from SQLite if it exists
counts = {"done": 0, "blocked": 0, "ready": 0, "todo": 0}
task_count = 0
if db_file.exists():
conn = sqlite3.connect(str(db_file))
try:
rows = conn.execute(
"SELECT status, COUNT(*) as cnt FROM tasks GROUP BY status"
).fetchall()
for status, cnt in rows:
if status in counts:
counts[status] = cnt
task_count += cnt
finally:
conn.close()
boards.append({
"slug": slug,
"name": name,
"task_count": task_count,
"counts": counts,
})
except (json.JSONDecodeError, OSError, sqlite3.Error):
continue
except OSError:
pass
return JSONResponse({"boards": boards})
@app.get("/api/kanban/board/{slug}")
async def kanban_board(slug: str):
"""Show tasks in a specific kanban board."""
board_dir = KANBAN_DIR / slug
meta_file = board_dir / "board.json"
db_file = board_dir / "kanban.db"
if not meta_file.exists():
return JSONResponse({"error": f"Board '{slug}' not found"}, status_code=404)
try:
meta = json.loads(meta_file.read_text())
tasks = []
if db_file.exists():
conn = sqlite3.connect(str(db_file))
try:
rows = conn.execute(
"SELECT id, title, assignee, status, body FROM tasks ORDER BY priority, created_at"
).fetchall()
for row in rows:
tid, title, assignee, status, body = row
tasks.append({
"id": tid,
"title": title,
"status": status or "todo",
"assignee": assignee or "",
"body": (body or "")[:200],
})
finally:
conn.close()
return JSONResponse({
"slug": slug,
"name": meta.get("name", slug),
"tasks": tasks,
})
except (json.JSONDecodeError, OSError, sqlite3.Error) as e:
return JSONResponse({"error": str(e)}, status_code=500)
@app.post("/api/kanban/task/{task_id}/complete")
async def kanban_task_complete(task_id: str, request: Request):
"""Complete a kanban task across all boards (find by ID)."""
body = await request.json() or {}
summary = body.get("summary", "Completed via dashboard")
try:
if not KANBAN_DIR.exists():
return JSONResponse({"error": "No boards found"}, status_code=404)
for board_dir in KANBAN_DIR.iterdir():
if not board_dir.is_dir() or board_dir.name.startswith("_"):
continue
db_file = board_dir / "kanban.db"
if not db_file.exists():
continue
conn = sqlite3.connect(str(db_file))
try:
existing = conn.execute(
"SELECT id FROM tasks WHERE id=?", (task_id,)
).fetchone()
if existing:
conn.execute(
"UPDATE tasks SET status='done', result=? WHERE id=?",
(summary, task_id),
)
conn.commit()
return JSONResponse({
"completed": True,
"task_id": task_id,
"board": board_dir.name,
})
finally:
conn.close()
return JSONResponse({"error": f"Task '{task_id}' not found"}, status_code=404)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
# ── System Services API ───────────────────────────────────────────────
@app.get("/api/services")
async def list_services():
"""List Hermes-related systemd services and their status."""
try:
result = subprocess.run(
["systemctl", "list-units", "--type=service", "--all", "--no-pager"],
capture_output=True, text=True, timeout=10,
)
services = []
for line in result.stdout.splitlines():
if "hermes" in line.lower():
parts = line.split()
if len(parts) >= 3:
services.append({
"name": parts[0],
"load": parts[1],
"active": parts[2],
"sub": parts[3] if len(parts) > 3 else "",
})
# Also get user services
user_result = subprocess.run(
["systemctl", "--user", "list-units", "--type=service", "--all", "--no-pager"],
capture_output=True, text=True, timeout=10,
)
for line in user_result.stdout.splitlines():
if "hermes" in line.lower():
parts = line.split()
if len(parts) >= 3:
services.append({
"name": parts[0],
"load": parts[1],
"active": parts[2],
"sub": parts[3] if len(parts) > 3 else "",
"scope": "user",
})
return JSONResponse({"services": services})
except FileNotFoundError:
return JSONResponse({"error": "systemctl not available"}, status_code=500)
except subprocess.TimeoutExpired:
return JSONResponse({"error": "Timed out querying services"}, status_code=504)
@app.post("/api/services/{service_name}/action")
async def service_action(service_name: str, request: Request):
"""Start, stop, or restart a systemd service."""
body = await request.json() or {}
action = body.get("action", "restart")
if action not in ("start", "stop", "restart", "enable", "disable"):
return JSONResponse({"error": f"Invalid action: {action}"}, status_code=400)
try:
proc = await asyncio.create_subprocess_exec(
"sudo", "systemctl", action, service_name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
if proc.returncode != 0:
err = stderr.decode().strip() if stderr else ""
# Try without sudo
proc2 = await asyncio.create_subprocess_exec(
"systemctl", "--user", action, service_name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout2, stderr2 = await asyncio.wait_for(proc2.communicate(), timeout=30)
if proc2.returncode != 0:
err2 = stderr2.decode().strip() if stderr2 else ""
return JSONResponse(
{"error": f"Failed: {err[:100]}; user failed: {err2[:100]}"},
status_code=502,
)
return JSONResponse({"service": service_name, "action": action, "result": "ok"})
return JSONResponse({"service": service_name, "action": action, "result": "ok"})
except FileNotFoundError:
return JSONResponse({"error": "systemctl not found"}, status_code=500)
except asyncio.TimeoutError:
return JSONResponse({"error": f"Timed out running {action}"}, status_code=504)
except Exception as e:
return JSONResponse({"error": str(e)[:200]}, status_code=500)
# ── Serve frontend ────────────────────────────────────────────────────
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
@app.get("/", response_class=HTMLResponse)
async def index():
index_path = STATIC_DIR / "index.html"
if index_path.exists():
return HTMLResponse(content=index_path.read_text())
return HTMLResponse(content="<h1>Command Center - Loading...</h1>")