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 @@
-
+