3f086b7849
- 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.
1160 lines
42 KiB
Python
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>")
|