🚀 Initial commit: Rose's custom WebUI with modernization + agent attribution

This commit is contained in:
Rose
2026-04-20 10:36:59 +02:00
parent 3bdf430413
commit 99dd1f57ae
118 changed files with 41900 additions and 0 deletions

150
api/agents.py Normal file
View File

@@ -0,0 +1,150 @@
"""
Rose Agents Panel API — Data layer for Hermes WebUI Agents extension.
Provides Rose + Tier-2 agent status, inbox management, and configuration.
"""
import json
import os
import subprocess
import threading
import time
from pathlib import Path
from typing import Any
from api.helpers import j
# ── Paths ──────────────────────────────────────────────────────────────────────
_HERMES_DIR = Path.home() / ".hermes"
_AGENTS_DIR = _HERMES_DIR / "agents"
_INBOX_BUS = _HERMES_DIR / "scripts" / "message_bus.py"
# ── Tier-2 Agent Registry ──────────────────────────────────────────────────────
TIER2_AGENTS = {
"lotus": {"name": "Lotus", "emoji": "🪷", "domain": "Health, Fitness & Recovery", "color": "#e91e63"},
"forget-me-not": {"name": "Forget-me-not", "emoji": "🌼", "domain": "Calendar, Time & Social", "color": "#ff9800"},
"sunflower": {"name": "Sunflower", "emoji": "🌻", "domain": "Finance, Wealth & Subscriptions","color": "#ffeb3b"},
"iris": {"name": "Iris", "emoji": "⚜️", "domain": "Career, Learning & Focus", "color": "#9c27b0"},
"ivy": {"name": "Ivy", "emoji": "🌿", "domain": "Smart Home & Environment", "color": "#4caf50"},
"dandelion": {"name": "Dandelion", "emoji": "🛡️", "domain": "Communication Triage", "color": "#03a9f4"},
"root": {"name": "Root", "emoji": "🌳", "domain": "DevOps, Logs & System Health", "color": "#795548"},
}
ROSE_META = {
"name": "Rose",
"emoji": "🌹",
"domain": "Orchestrator & Main Interface",
"color": "#f44336",
}
# ── Helpers ───────────────────────────────────────────────────────────────────
def _get_process_status(agent_name: str) -> dict:
"""Check if an agent process is running via ps."""
try:
result = subprocess.run(
["pgrep", "-f", f"hermes.*--agent\\s+{agent_name}|message_bus.*--agent\\s+{agent_name}"],
capture_output=True, text=True
)
running = bool(result.stdout.strip())
pid = int(result.stdout.strip().split()[0]) if running else None
return {"running": running, "pid": pid}
except Exception:
return {"running": False, "pid": None}
def _get_inbox_count(agent_name: str) -> int:
"""Count messages in agent inbox via message_bus.py."""
try:
result = subprocess.run(
["/usr/bin/python3", str(_INBOX_BUS), "check", "--agent", agent_name],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
data = json.loads(result.stdout)
return data.get("pending", 0)
except Exception:
pass
return 0
def _read_inbox(agent_name: str, limit: int = 20) -> list[dict]:
"""Read messages from agent inbox."""
inbox_path = _AGENTS_DIR / agent_name / "inbox.json"
if not inbox_path.exists():
return []
try:
with open(inbox_path, "r") as f:
data = json.load(f)
messages = data if isinstance(data, list) else data.get("messages", [])
return messages[-limit:]
except (json.JSONDecodeError, IOError):
return []
# ── API Functions ─────────────────────────────────────────────────────────────
def list_agents() -> dict:
"""Return status for Rose + all Tier-2 agents."""
agents = []
# Rose (the orchestrator)
rose_running = True # Rose IS the gateway/webui
rose_inbox_count = _get_inbox_count("rose")
agents.append({
"id": "rose",
"name": ROSE_META["name"],
"emoji": ROSE_META["emoji"],
"domain": ROSE_META["domain"],
"color": ROSE_META["color"],
"tier": "orchestrator",
"running": rose_running,
"pid": None,
"inbox_count": rose_inbox_count,
})
# Tier-2 agents
for agent_id, meta in TIER2_AGENTS.items():
status = _get_process_status(agent_id)
inbox_count = _get_inbox_count(agent_id) if status["running"] else 0
agents.append({
"id": agent_id,
"name": meta["name"],
"emoji": meta["emoji"],
"domain": meta["domain"],
"color": meta["color"],
"tier": "tier2",
"running": status["running"],
"pid": status["pid"],
"inbox_count": inbox_count,
})
return {"agents": agents}
def get_agent_inbox(agent_id: str, limit: int = 20) -> dict:
"""Return inbox messages for a specific agent."""
if agent_id not in TIER2_AGENTS and agent_id != "rose":
return {"error": f"Unknown agent: {agent_id}"}
messages = _read_inbox(agent_id, limit)
return {
"agent_id": agent_id,
"agent_name": TIER2_AGENTS.get(agent_id, {}).get("name", "Rose"),
"messages": messages,
}
def get_agent_config(agent_id: str) -> dict:
"""Return configuration for a specific agent (soul.md path, etc)."""
if agent_id == "rose":
return {
"id": "rose",
"name": "Rose",
"soul_path": str(_HERMES_DIR / "rose.md"),
"memory_path": str(_HERMES_DIR / "memory.json"),
}
elif agent_id in TIER2_AGENTS:
soul_path = _AGENTS_DIR / agent_id / "soul.md"
inbox_path = _AGENTS_DIR / agent_id / "inbox.json"
return {
"id": agent_id,
"name": TIER2_AGENTS[agent_id]["name"],
"soul_path": str(soul_path) if soul_path.exists() else None,
"inbox_path": str(inbox_path),
}
return {"error": f"Unknown agent: {agent_id}"}

265
api/gateways.py Normal file
View File

@@ -0,0 +1,265 @@
"""
Gateway management API for Hermes WebUI.
Provides endpoints to list, start, stop, restart, and add gateway connections
like Telegram, OpenClaw, and other Hermes gateway types.
"""
import os
import re
import subprocess
import threading
import time
from pathlib import Path
from typing import Optional
# In-memory gateway registry (gateway_name -> info)
_gateways: dict[str, dict] = {}
_gateways_lock = threading.Lock()
# Track running gateway processes (gateway_name -> PID)
_gateway_pids: dict[str, int] = {}
def _get_hermes_home() -> Path:
"""Get the Hermes home directory."""
hermes_home = os.environ.get("HERMES_HOME", "")
if hermes_home and str(Path(hermes_home).parent) != "profiles":
return Path(hermes_home)
return Path.home() / ".hermes"
def _get_gateway_pid(name: str) -> Optional[int]:
"""Get PID of a running gateway process by name."""
try:
result = subprocess.run(
["pgrep", "-f", f"hermes.*gateway.*{name}"],
capture_output=True, text=True
)
if result.returncode == 0 and result.stdout.strip():
pids = result.stdout.strip().split("\n")
return int(pids[0]) if pids else None
except Exception:
pass
return _gateway_pids.get(name)
def _is_gateway_running(name: str) -> bool:
"""Check if a gateway process is running."""
pid = _get_gateway_pid(name)
if pid:
try:
os.kill(pid, 0)
return True
except OSError:
pass
return False
def _get_gateway_info(name: str) -> str:
"""Get additional info about a gateway."""
pid = _get_gateway_pid(name)
if pid:
try:
result = subprocess.run(
["ps", "-p", str(pid), "-o", "etime=", "-o", "args="],
capture_output=True, text=True
)
if result.returncode == 0:
parts = result.stdout.strip().split(None, 1)
if len(parts) >= 2:
elapsed = parts[0]
return f"PID {pid} · running {elapsed}"
except Exception:
pass
return f"PID {pid}"
return ""
def _detect_telegram_gateway() -> dict:
"""Detect if Telegram gateway is configured and running."""
hermes_home = _get_hermes_home()
gateway_running = False
info = ""
# Check if there's a telegram gateway config
config_paths = [
hermes_home / "gateways" / "telegram",
hermes_home / "gateway" / "telegram",
hermes_home / ".env",
]
has_config = False
for p in config_paths:
if p.exists():
has_config = True
break
if has_config:
gateway_running = _is_gateway_running("telegram")
if gateway_running:
info = _get_gateway_info("telegram")
return {
"name": "telegram",
"type": "telegram",
"running": gateway_running,
"info": info,
"has_config": has_config,
}
def _detect_openclaw_gateway() -> dict:
"""Detect if OpenClaw gateway is configured and running."""
hermes_home = _get_hermes_home()
gateway_running = False
info = ""
config_paths = [
hermes_home / "gateways" / "openclaw",
hermes_home / "gateway" / "openclaw",
]
has_config = False
for p in config_paths:
if p.exists():
has_config = True
break
if has_config:
gateway_running = _is_gateway_running("openclaw")
if gateway_running:
info = _get_gateway_info("openclaw")
return {
"name": "openclaw",
"type": "openclaw",
"running": gateway_running,
"info": info,
"has_config": has_config,
}
def _discover_gateways() -> list[dict]:
"""Discover all available and configured gateways."""
gateways = []
# Always show telegram if detected
telegram = _detect_telegram_gateway()
gateways.append(telegram)
# Check for openclaw
openclaw = _detect_openclaw_gateway()
gateways.append(openclaw)
# Add any manually registered gateways
with _gateways_lock:
for name, info in _gateways.items():
if not any(g["name"] == name for g in gateways):
running = _is_gateway_running(name)
gw_info = _get_gateway_info(name) if running else ""
gateways.append({
"name": name,
"type": info.get("type", "unknown"),
"running": running,
"info": gw_info,
"has_config": True,
})
return gateways
def list_gateways_api() -> list[dict]:
"""List all gateways with their status."""
return _discover_gateways()
def start_gateway_api(name: str) -> dict:
"""Start a gateway by name."""
# Check if already running
if _is_gateway_running(name):
raise RuntimeError(f"Gateway '{name}' is already running")
hermes_home = _get_hermes_home()
# Determine the gateway type and command
if name == "telegram":
cmd = ["hermes", "gateway", "run", "--type", "telegram"]
elif name == "openclaw":
cmd = ["hermes", "gateway", "run", "--type", "openclaw"]
else:
cmd = ["hermes", "gateway", "run", "--name", name]
# Start the gateway process
try:
proc = subprocess.Popen(
cmd,
cwd=str(hermes_home),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True
)
_gateway_pids[name] = proc.pid
# Give it a moment to start
time.sleep(1)
if proc.poll() is not None:
# Process already terminated
stdout, stderr = proc.communicate()
raise RuntimeError(f"Gateway failed to start: {stderr.decode()[:200]}")
return {"ok": True, "message": f"Gateway '{name}' started", "pid": proc.pid}
except FileNotFoundError:
raise RuntimeError("hermes CLI not found in PATH. Is Hermes installed?")
except Exception as e:
raise RuntimeError(f"Failed to start gateway: {e}")
def stop_gateway_api(name: str) -> dict:
"""Stop a gateway by name."""
pid = _get_gateway_pid(name)
if not pid:
raise RuntimeError(f"Gateway '{name}' is not running")
try:
os.kill(pid, 9) # SIGKILL
time.sleep(0.5)
if name in _gateway_pids:
del _gateway_pids[name]
return {"ok": True, "message": f"Gateway '{name}' stopped"}
except OSError as e:
raise RuntimeError(f"Failed to stop gateway: {e}")
def restart_gateway_api(name: str) -> dict:
"""Restart a gateway by name."""
# Check if running first
if not _is_gateway_running(name):
raise RuntimeError(f"Gateway '{name}' is not running")
# Stop it
stop_gateway_api(name)
time.sleep(1)
# Start it again
return start_gateway_api(name)
def add_gateway_api(name: str, gw_type: str = "telegram") -> dict:
"""Register a new gateway."""
# Validate name
if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$", name):
raise ValueError("Invalid gateway name")
# Check if already exists
for g in _discover_gateways():
if g["name"] == name:
raise FileExistsError(f"Gateway '{name}' already exists")
with _gateways_lock:
_gateways[name] = {"type": gw_type, "registered_at": time.time()}
return {"ok": True, "message": f"Gateway '{name}' added as {gw_type}"}

218
api/mc.py Normal file
View File

@@ -0,0 +1,218 @@
"""
Mission Control API — Data layer for Hermes WebUI Mission Control extension.
Provides priorities, tasks, feed, and dashboard status management.
"""
import json
import threading
import time
from pathlib import Path
from typing import Any
from api.helpers import j
# ── State file ────────────────────────────────────────────────────────────────
_MC_DATA_FILE = Path.home() / ".hermes" / "data" / "mc-data.json"
_MC_LOCK = threading.RLock()
# ── Default structure ─────────────────────────────────────────────────────────
DEFAULT_MC_DATA = {
"priorities": [],
"tasks": [],
"feed": [],
}
def _load_mc_data() -> dict:
"""Load Mission Control data from disk."""
with _MC_LOCK:
if not _MC_DATA_FILE.exists():
return DEFAULT_MC_DATA.copy()
try:
with open(_MC_DATA_FILE, "r") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return DEFAULT_MC_DATA.copy()
def _save_mc_data(data: dict) -> None:
"""Save Mission Control data to disk."""
with _MC_LOCK:
_MC_DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(_MC_DATA_FILE, "w") as f:
json.dump(data, f, indent=2)
# ── Priority helpers ──────────────────────────────────────────────────────────
def get_priorities() -> list[dict]:
"""Return all priorities sorted by id."""
data = _load_mc_data()
return sorted(data.get("priorities", []), key=lambda p: p.get("id", 0))
def create_priority(name: str, color: str = "#808080") -> dict:
"""Add a new priority. Returns the created priority."""
data = _load_mc_data()
priorities = data.get("priorities", [])
new_id = max([p.get("id", 0) for p in priorities], default=0) + 1
priority = {"id": new_id, "name": name, "color": color}
priorities.append(priority)
data["priorities"] = priorities
_save_mc_data(data)
_add_feed_event(f"Priority created: {name}")
return priority
def update_priority(priority_id: int, name: str = None, color: str = None, done: bool = None) -> dict | None:
"""Update an existing priority. Returns updated priority or None if not found."""
data = _load_mc_data()
priorities = data.get("priorities", [])
for p in priorities:
if p.get("id") == priority_id:
if name is not None:
p["name"] = name
if color is not None:
p["color"] = color
if done is not None:
p["done"] = done
if done:
_add_feed_event(f"Priority completed: {p['name']}")
data["priorities"] = priorities
_save_mc_data(data)
return p
return None
def delete_priority(priority_id: int) -> bool:
"""Delete a priority. Returns True if found and deleted."""
data = _load_mc_data()
priorities = data.get("priorities", [])
original_len = len(priorities)
priorities = [p for p in priorities if p.get("id") != priority_id]
if len(priorities) < original_len:
data["priorities"] = priorities
_save_mc_data(data)
return True
return False
# ── Task helpers ──────────────────────────────────────────────────────────────
def get_tasks() -> list[dict]:
"""Return all tasks sorted by priority then id."""
data = _load_mc_data()
return sorted(data.get("tasks", []), key=lambda t: (t.get("priority", 999), t.get("id", 0)))
def create_task(title: str, priority: int = 1, status: str = "backlog") -> dict:
"""Create a new task. Returns the created task."""
data = _load_mc_data()
tasks = data.get("tasks", [])
new_id = max([t.get("id", 0) for t in tasks], default=0) + 1
task = {"id": new_id, "title": title, "priority": priority, "status": status}
tasks.append(task)
data["tasks"] = tasks
_save_mc_data(data)
_add_feed_event(f"Task created: {title}")
return task
def update_task(task_id: int, **kwargs) -> dict | None:
"""Update a task by id. kwargs: title, priority, status. Returns updated task or None."""
data = _load_mc_data()
tasks = data.get("tasks", [])
for t in tasks:
if t.get("id") == task_id:
old_status = t.get("status")
for key in ("title", "priority", "status"):
if key in kwargs:
t[key] = kwargs[key]
new_status = t.get("status")
# Feed events for status transitions
if old_status != new_status:
if new_status == "done":
_add_feed_event(f"Task completed: {t['title']}")
elif new_status == "progress":
_add_feed_event(f"Task started: {t['title']}")
data["tasks"] = tasks
_save_mc_data(data)
return t
return None
def delete_task(task_id: int) -> bool:
"""Delete a task. Returns True if found and deleted."""
data = _load_mc_data()
tasks = data.get("tasks", [])
original_len = len(tasks)
tasks = [t for t in tasks if t.get("id") != task_id]
if len(tasks) < original_len:
data["tasks"] = tasks
_save_mc_data(data)
return True
return False
# ── Feed helpers ──────────────────────────────────────────────────────────────
def get_feed(limit: int = 50) -> list[dict]:
"""Return recent feed events, newest first."""
data = _load_mc_data()
feed = data.get("feed", [])
return sorted(feed, key=lambda f: f.get("timestamp", ""), reverse=True)[:limit]
def _add_feed_event(event: str) -> None:
"""Add a timestamped feed event."""
data = _load_mc_data()
feed = data.get("feed", [])
feed.append({
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"event": event,
})
# Keep only last 200 events
data["feed"] = feed[-200:]
_save_mc_data(data)
# ── Dashboard status ──────────────────────────────────────────────────────────
def get_dashboard_status() -> dict:
"""Return aggregated dashboard status for Mission Control."""
data = _load_mc_data()
priorities = data.get("priorities", [])
tasks = data.get("tasks", [])
priorities_total = len(priorities)
priorities_done = sum(1 for p in priorities if p.get("done"))
tasks_backlog = sum(1 for t in tasks if t.get("status") == "backlog")
tasks_progress = sum(1 for t in tasks if t.get("status") == "progress")
tasks_done = sum(1 for t in tasks if t.get("status") == "done")
feed = get_feed(limit=5)
latest_event = feed[0]["event"] if feed else "No recent activity"
# Health assessment
if tasks_done == 0 and tasks_backlog == 0 and tasks_progress == 0:
health = "empty"
elif tasks_progress > 0 and tasks_done > 0:
health = "healthy"
elif tasks_progress > 0:
health = "active"
elif tasks_backlog > 0:
health = "warning"
else:
health = "ok"
return {
"priorities_total": priorities_total,
"priorities_done": priorities_done,
"tasks_backlog": tasks_backlog,
"tasks_progress": tasks_progress,
"tasks_done": tasks_done,
"latest_feed_event": latest_event,
"dashboard_health": health,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}

View File

@@ -54,6 +54,7 @@ from api.helpers import (
_redact_text,
)
from api import mc as _mc
from api import agents as _agents
# ── CSRF: validate Origin/Referer on POST ────────────────────────────────────
import re as _re
@@ -770,6 +771,18 @@ def handle_get(handler, parsed) -> bool:
limit = int(parse_qs(parsed.query).get("limit", ["50"])[0])
return j(handler, {"feed": _mc.get_feed(limit=limit)})
# ── Agents API (Rose + Tier-2) ──
if parsed.path == "/api/agents":
return j(handler, _agents.list_agents())
if parsed.path.startswith("/api/agents/inbox/"):
agent_id = parsed.path.split("/")[-1]
return j(handler, _agents.get_agent_inbox(agent_id))
if parsed.path.startswith("/api/agents/config/"):
agent_id = parsed.path.split("/")[-1]
return j(handler, _agents.get_agent_config(agent_id))
# ── Profile API (GET) ──
if parsed.path == "/api/profiles":
from api.profiles import list_profiles_api, get_active_profile_name