From 5839bc47cefc1a036747a6619c01024719c75d71 Mon Sep 17 00:00:00 2001 From: Shawn Date: Tue, 2 Jun 2026 17:45:03 -0400 Subject: [PATCH] Add watchdog agent profile to command center - Add 'watchdog' to KNOWN_PROFILES (5th agent) - Custom status check via watchdog state file + kanban gateway fallback - New /api/watchdog/status endpoint with scripts, plugins, heartbeat info - Watchdog endpoint bypasses auth (public, non-sensitive) - Update frontend PROFILE_ICONS + cache buster --- server.py | 106 +++++++++++++++++++++++++++++++++++++++++++++- static/app.js | 2 +- static/index.html | 2 +- 3 files changed, 107 insertions(+), 3 deletions(-) diff --git a/server.py b/server.py index 50e42d0..c901942 100644 --- a/server.py +++ b/server.py @@ -40,13 +40,14 @@ _unauthorized = Response( # ── Agent Profiles ────────────────────────────────────────────────── HERMES_BIN = "/home/oplabs/.hermes/hermes-agent/venv/bin/hermes" -KNOWN_PROFILES = ["coder", "assistant", "artist", "kanban"] +KNOWN_PROFILES = ["coder", "assistant", "artist", "kanban", "watchdog"] PROFILE_LABELS = { "coder": "Cody (Coder)", "assistant": "Ashley (Assistant)", "artist": "Artie (Artist)", "kanban": "Kanban Manager", + "watchdog": "Watchdog (Infra)", } PROFILE_ICONS = { @@ -54,12 +55,37 @@ PROFILE_ICONS = { "assistant": "🤖", "artist": "🎨", "kanban": "📋", + "watchdog": "🛡️", } def _get_profile_status(profile: str) -> dict: """Check if a Hermes profile gateway process is running.""" try: + # Watchdog runs as a plugin inside the kanban gateway; check its state file + if profile == "watchdog": + watchdog_state = Path(os.path.expanduser("~/.hermes/workspace/watchdog-state.json")) + if watchdog_state.exists(): + try: + state = json.loads(watchdog_state.read_text()) + last_beat = state.get("last_heartbeat", 0) + now = time.time() + # Consider alive if heartbeat within the last 3 hours + if last_beat and (now - last_beat) < 10800: + return {"status": "online", "profile": profile, "last_heartbeat": last_beat} + except (json.JSONDecodeError, OSError): + pass + # Fallback: check if kanban gateway is running (watchdog rides on it) + result = subprocess.run( + ["ps", "aux"], + capture_output=True, text=True, timeout=10, + ) + for line in result.stdout.splitlines(): + if "--profile kanban" in line and "gateway run" in line: + return {"status": "online", "profile": profile, + "note": "running via kanban gateway (plugin)"} + return {"status": "offline", "profile": profile} + result = subprocess.run( ["ps", "aux"], capture_output=True, text=True, timeout=10, @@ -556,6 +582,8 @@ async def auth_middleware(request: Request, call_next): return await call_next(request) if request.url.path.startswith("/api/network/"): return await call_next(request) + if request.url.path.startswith("/api/watchdog/"): + 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")): @@ -715,6 +743,82 @@ async def vault_search(q: str = Query(..., min_length=1, description="Search que }) +# ── Watchdog API ───────────────────────────────────────────────────── + +WATCHDOG_STATE_PATH = Path(os.path.expanduser("~/.hermes/workspace/watchdog-state.json")) +WATCHDOG_SCRIPTS_DIR = Path(os.path.expanduser("~/.hermes/scripts")) + + +def _get_watchdog_scripts() -> list[dict]: + """List watchdog scripts and their basic info.""" + scripts = [] + try: + for f in sorted(WATCHDOG_SCRIPTS_DIR.glob("*watchdog*")): + if f.suffix in (".sh", ".py"): + scripts.append({ + "name": f.name, + "path": str(f), + "size": f.stat().st_size, + "modified": int(f.stat().st_mtime), + }) + except OSError: + pass + return scripts + + +def _get_watchdog_plugins() -> list[dict]: + """List watchdog-related plugins.""" + plugins = [] + plugin_dir = Path(os.path.expanduser("~/.hermes/plugins")) + try: + for p in sorted(plugin_dir.glob("*watchdog*")): + plugin_yaml = p / "plugin.yaml" + if plugin_yaml.exists(): + try: + meta = yaml.safe_load(plugin_yaml.read_text()) or {} + except Exception: + meta = {} + plugins.append({ + "name": p.name, + "description": meta.get("description", ""), + "version": meta.get("version", ""), + }) + except OSError: + pass + return plugins + + +@app.get("/api/watchdog/status") +async def watchdog_status(): + """Return detailed watchdog agent status (heartbeat, scripts, plugins).""" + state = {} + state_file = WATCHDOG_STATE_PATH + if state_file.exists(): + try: + state = json.loads(state_file.read_text()) + except (json.JSONDecodeError, OSError): + state = {} + + profile_status = _get_profile_status("watchdog") + + # Age of last heartbeat + last_heartbeat = state.get("last_heartbeat", 0) + if last_heartbeat: + age = time.time() - last_heartbeat + age_str = f"{age/60:.0f}m ago" if age < 3600 else f"{age/3600:.1f}h ago" + state["heartbeat_age_seconds"] = int(age) + state["heartbeat_age_str"] = age_str + + return JSONResponse({ + "status": profile_status["status"], + "note": profile_status.get("note", ""), + "state": state, + "scripts": _get_watchdog_scripts(), + "plugins": _get_watchdog_plugins(), + "cached_at": time.time(), + }) + + # ── Agent Chat API ────────────────────────────────────────────────── diff --git a/static/app.js b/static/app.js index 5c36582..a1b268e 100644 --- a/static/app.js +++ b/static/app.js @@ -279,7 +279,7 @@ let chatState = { sending: false, }; -const PROFILE_ICONS = { coder: '💻', assistant: '🤖', artist: '🎨', kanban: '📋' }; +const PROFILE_ICONS = { coder: '💻', assistant: '🤖', artist: '🎨', kanban: '📋', watchdog: '🛡️' }; async function loadAgentProfiles() { try { diff --git a/static/index.html b/static/index.html index 8dd9ad3..786f676 100644 --- a/static/index.html +++ b/static/index.html @@ -286,7 +286,7 @@ - +