feat(v9): Phase 4 — Agent-Cockpit (Status/Cron/Skills) im Dashboard
Macht das "geschenkte v9" sichtbar. Read-only Control-Plane-Reads:
- hermes_control.py: gateway_state.json + cron-Heartbeat (Status),
`hermes cron list` + `hermes skills list` via COLUMNS=400 (kein
Rich-Truncation) als kanonische Quelle, .usage.json fuer Nutzung.
TTL-Cache (10s) gegen Polling-Last.
- routers/hermes.py: GET /api/hermes/{agent,cron,skills}.
- HermesPanel: aufklappbares "Cockpit" — Status-Badges, Cron-Jobs,
Skills (sortiert nach Nutzung, aktiv/inaktiv).
- config.py: HERMES_HOME, HERMES_BIN.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,11 @@ def _read_hermes_api_key() -> str:
|
|||||||
|
|
||||||
|
|
||||||
HERMES_API_KEY = _read_hermes_api_key()
|
HERMES_API_KEY = _read_hermes_api_key()
|
||||||
|
# Control-Plane-Reads (Phase 4): Hermes legt Status/Cron/Skills lokal unter
|
||||||
|
# ~/.hermes ab; die `hermes`-CLI ist die kanonische Quelle fuer Cron/Skills.
|
||||||
|
# MC laeuft als hitonabi auf der Box -> direkter Lese-/Exec-Zugriff.
|
||||||
|
HERMES_HOME = Path(os.path.expanduser(os.environ.get("HERMES_HOME", "~/.hermes")))
|
||||||
|
HERMES_BIN = os.path.expanduser(os.environ.get("HERMES_BIN", "~/.local/bin/hermes"))
|
||||||
HERMES_WINDOWS_HOST = os.environ.get("HERMES_WINDOWS_HOST", "")
|
HERMES_WINDOWS_HOST = os.environ.get("HERMES_WINDOWS_HOST", "")
|
||||||
HERMES_WINDOWS_USER = os.environ.get("HERMES_WINDOWS_USER", "TobisPC")
|
HERMES_WINDOWS_USER = os.environ.get("HERMES_WINDOWS_USER", "TobisPC")
|
||||||
HERMES_SSH_KEY = Path(os.path.expanduser(os.environ.get("HERMES_SSH_KEY", "~/.ssh/id_ed25519_hermes_agent")))
|
HERMES_SSH_KEY = Path(os.path.expanduser(os.environ.get("HERMES_SSH_KEY", "~/.ssh/id_ed25519_hermes_agent")))
|
||||||
|
|||||||
@@ -19,6 +19,11 @@
|
|||||||
let recording = $state(false)
|
let recording = $state(false)
|
||||||
let status = $state<any>(null)
|
let status = $state<any>(null)
|
||||||
let setupOpen = $state(false)
|
let setupOpen = $state(false)
|
||||||
|
let cockpitOpen = $state(false)
|
||||||
|
let cockpitLoading = $state(false)
|
||||||
|
let agent = $state<any>(null)
|
||||||
|
let cronJobs = $state<any[]>([])
|
||||||
|
let skills = $state<any[]>([])
|
||||||
let pubkey = $state('')
|
let pubkey = $state('')
|
||||||
let ws = $state<WebSocket | null>(null)
|
let ws = $state<WebSocket | null>(null)
|
||||||
let currentBot = $state('') // aktuell gestreamter Hermes-Token
|
let currentBot = $state('') // aktuell gestreamter Hermes-Token
|
||||||
@@ -195,6 +200,29 @@
|
|||||||
} catch { pubkey = '' }
|
} catch { pubkey = '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Cockpit (Phase 4): Agent-Status, Cron, Skills ----
|
||||||
|
async function loadCockpit() {
|
||||||
|
cockpitLoading = true
|
||||||
|
try {
|
||||||
|
const [a, c, s] = await Promise.all([
|
||||||
|
api('/api/hermes/agent'),
|
||||||
|
api('/api/hermes/cron'),
|
||||||
|
api('/api/hermes/skills'),
|
||||||
|
])
|
||||||
|
agent = a
|
||||||
|
cronJobs = c.jobs || []
|
||||||
|
skills = s.skills || []
|
||||||
|
} catch { agent = null }
|
||||||
|
cockpitLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills sortiert: meistgenutzt zuerst, dann alphabetisch
|
||||||
|
let skillsSorted = $derived(
|
||||||
|
[...skills].sort((a, b) =>
|
||||||
|
(b.use_count || 0) - (a.use_count || 0) || (a.name || '').localeCompare(b.name || ''))
|
||||||
|
)
|
||||||
|
let skillsEnabled = $derived(skills.filter(s => (s.status || '').includes('enabled')).length)
|
||||||
|
|
||||||
// ---- Hilfsfunktionen ----
|
// ---- Hilfsfunktionen ----
|
||||||
function toolLabel(name: string) {
|
function toolLabel(name: string) {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
@@ -245,6 +273,11 @@
|
|||||||
onclick={() => voiceOn = !voiceOn}>
|
onclick={() => voiceOn = !voiceOn}>
|
||||||
🔊 Voice {voiceOn ? 'an' : 'aus'}
|
🔊 Voice {voiceOn ? 'an' : 'aus'}
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Cockpit (Phase 4) -->
|
||||||
|
<button class="ghost" style="padding:4px 10px;font-size:12px;{cockpitOpen ? 'border-color:var(--accent);color:var(--accent)' : ''}"
|
||||||
|
onclick={() => { cockpitOpen = !cockpitOpen; if (cockpitOpen) loadCockpit() }}>
|
||||||
|
📡 Cockpit
|
||||||
|
</button>
|
||||||
<!-- Setup -->
|
<!-- Setup -->
|
||||||
<button class="ghost" style="padding:4px 10px;font-size:12px"
|
<button class="ghost" style="padding:4px 10px;font-size:12px"
|
||||||
onclick={() => { setupOpen = !setupOpen; if (setupOpen) { loadStatus(); loadPubkey() } }}>
|
onclick={() => { setupOpen = !setupOpen; if (setupOpen) { loadStatus(); loadPubkey() } }}>
|
||||||
@@ -253,6 +286,97 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== COCKPIT (Phase 4) ==================== -->
|
||||||
|
{#if cockpitOpen}
|
||||||
|
<div class="card" style="border-color:rgba(45,212,191,.3)">
|
||||||
|
<div class="card-h" style="display:flex;justify-content:space-between;align-items:center">
|
||||||
|
<h3>Agent-Cockpit</h3>
|
||||||
|
<button class="ghost" style="font-size:11px;padding:2px 10px" onclick={loadCockpit}>
|
||||||
|
{cockpitLoading ? '…' : '↻ Aktualisieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agent-Status -->
|
||||||
|
<div class="tile" style="margin-bottom:10px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
|
||||||
|
<div>
|
||||||
|
<b>Status</b>
|
||||||
|
<div class="card-sub" style="font-size:12px">Hermes-Gateway-Dienst (:8642) auf dem Bosgame</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2" style="flex-wrap:wrap;align-items:center">
|
||||||
|
<span class="chip" style="font-size:11px;{agent?.gateway_state === 'running'
|
||||||
|
? 'color:#7ee29a;border-color:rgba(126,226,154,.4)' : 'color:#ff9b95;border-color:rgba(255,155,149,.4)'}">
|
||||||
|
● {agent?.gateway_state ?? 'offline'}
|
||||||
|
</span>
|
||||||
|
<span class="chip" style="font-size:11px;{agent?.cron_running
|
||||||
|
? 'color:var(--accent);border-color:var(--accent)' : 'color:var(--mut)'}">
|
||||||
|
⏱ Scheduler {agent?.cron_running ? 'läuft' : 'inaktiv'}
|
||||||
|
</span>
|
||||||
|
{#if agent?.heartbeat_age_s != null}
|
||||||
|
<span class="chip" style="font-size:11px;color:var(--mut)">Ticker vor {agent.heartbeat_age_s}s</span>
|
||||||
|
{/if}
|
||||||
|
{#if agent?.version}
|
||||||
|
<span class="chip" style="font-size:11px;color:var(--mut)">v{agent.version}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cron-Scheduler -->
|
||||||
|
<div class="tile" style="margin-bottom:10px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
||||||
|
<b>Geplante Aufgaben <span class="card-sub" style="font-size:11px">({cronJobs.length})</span></b>
|
||||||
|
</div>
|
||||||
|
{#if cronJobs.length === 0}
|
||||||
|
<div class="card-sub" style="font-size:12px">
|
||||||
|
Keine geplanten Jobs. Hermes kann selbst welche anlegen — sag ihm z. B.
|
||||||
|
„Erinnere mich jeden Morgen um 9 Uhr an die offenen Modell-Upgrades".
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px">
|
||||||
|
{#each cronJobs as job}
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;font-size:12.5px;
|
||||||
|
padding:6px 8px;background:rgba(255,255,255,.02);border-radius:6px">
|
||||||
|
{#each Object.entries(job) as [k, v]}
|
||||||
|
{#if v}<span style="color:var(--mut)"><span style="opacity:.6">{k}:</span> {v}</span>{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Skills -->
|
||||||
|
<div class="tile">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
||||||
|
<b>Skills <span class="card-sub" style="font-size:11px">({skillsEnabled} aktiv von {skills.length})</span></b>
|
||||||
|
</div>
|
||||||
|
{#if skills.length === 0}
|
||||||
|
<div class="card-sub" style="font-size:12px">Keine Skills geladen (oder Agent offline).</div>
|
||||||
|
{:else}
|
||||||
|
<div style="max-height:260px;overflow-y:auto;display:flex;flex-direction:column;gap:3px">
|
||||||
|
{#each skillsSorted as s}
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;font-size:12.5px;padding:4px 8px;
|
||||||
|
border-radius:6px;{(s.status || '').includes('enabled') ? '' : 'opacity:.45'}">
|
||||||
|
<span style="width:7px;height:7px;border-radius:50%;flex-shrink:0;
|
||||||
|
background:{(s.status || '').includes('enabled') ? 'var(--accent)' : 'var(--mut)'}"></span>
|
||||||
|
<span style="font-weight:500">{s.name}</span>
|
||||||
|
{#if s.category}<span class="card-sub" style="font-size:11px">{s.category}</span>{/if}
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
{#if s.use_count > 0}
|
||||||
|
<span class="chip" style="font-size:10px;color:var(--accent);border-color:rgba(45,212,191,.3)">
|
||||||
|
{s.use_count}× genutzt
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="card-sub" style="font-size:10px">{s.source}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- ==================== SETUP-WIZARD ==================== -->
|
<!-- ==================== SETUP-WIZARD ==================== -->
|
||||||
{#if setupOpen}
|
{#if setupOpen}
|
||||||
<div class="card" style="border-color:rgba(45,212,191,.3)">
|
<div class="card" style="border-color:rgba(45,212,191,.3)">
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""
|
||||||
|
Hermes-Agent Control-Plane — read-only Einblicke fuer das Dashboard (Phase 4).
|
||||||
|
|
||||||
|
Macht das "geschenkte v9" sichtbar: Agent-Status, Cron-Scheduler und Skills des
|
||||||
|
Nous-Hermes-Agent-Frameworks. Quellen (MC laeuft als hitonabi auf dem Bosgame,
|
||||||
|
also direkter Zugriff):
|
||||||
|
|
||||||
|
- Status : ~/.hermes/gateway_state.json + cron-Heartbeat-Datei (sauberes JSON,
|
||||||
|
kein Parsing). Liveness kommt aus dem Heartbeat, nicht aus dem evtl.
|
||||||
|
beim Start eingefrorenen gateway_state.json.
|
||||||
|
- Cron : `hermes cron list --all` (kanonisch; kein stabiles Storage-Schema).
|
||||||
|
- Skills : `hermes skills list` (kanonisch) + ~/.hermes/skills/.usage.json
|
||||||
|
(Nutzungszaehler). Rich-Tabellen werden mit COLUMNS=400 ohne
|
||||||
|
Truncation erzeugt und ueber die `│`/`┃`-Spalten geparst.
|
||||||
|
|
||||||
|
Alles mit kurzem TTL-Cache, damit Dashboard-Polling die CLI nicht haemmert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from config import HERMES_HOME, HERMES_BIN
|
||||||
|
|
||||||
|
_CACHE_TTL = 10.0 # Sekunden
|
||||||
|
_cache: dict[str, tuple[float, object]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _cached(key: str, fn: Callable[[], object]) -> object:
|
||||||
|
now = time.monotonic()
|
||||||
|
hit = _cache.get(key)
|
||||||
|
if hit and now - hit[0] < _CACHE_TTL:
|
||||||
|
return hit[1]
|
||||||
|
val = fn()
|
||||||
|
_cache[key] = (now, val)
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cli(args: list[str], timeout: float = 45.0) -> str:
|
||||||
|
"""`hermes <args>` ausfuehren. COLUMNS=400 verhindert Rich-Truncation."""
|
||||||
|
env = dict(os.environ, COLUMNS="400", NO_COLOR="1")
|
||||||
|
try:
|
||||||
|
p = subprocess.run(
|
||||||
|
[HERMES_BIN, *args],
|
||||||
|
capture_output=True, text=True, timeout=timeout, env=env,
|
||||||
|
)
|
||||||
|
return p.stdout or ""
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rich_table(text: str) -> list[dict]:
|
||||||
|
"""Eine Rich-Box-Tabelle in list[dict] verwandeln.
|
||||||
|
|
||||||
|
Erste Zeile mit Spaltentrennern (`│` oder `┃`) ist der Header, der Rest sind
|
||||||
|
Datenzeilen. Trenn-/Rahmenzeilen (┏━┓ usw.) enthalten keine Trenner -> ignoriert.
|
||||||
|
"""
|
||||||
|
header: list[str] | None = None
|
||||||
|
rows: list[list[str]] = []
|
||||||
|
for ln in text.splitlines():
|
||||||
|
if "│" not in ln and "┃" not in ln:
|
||||||
|
continue
|
||||||
|
cells = [c.strip() for c in re.split(r"[│┃]", ln)]
|
||||||
|
# fuehrende/abschliessende Rahmen-Leerzellen entfernen
|
||||||
|
while cells and cells[0] == "":
|
||||||
|
cells.pop(0)
|
||||||
|
while cells and cells[-1] == "":
|
||||||
|
cells.pop()
|
||||||
|
if not cells:
|
||||||
|
continue
|
||||||
|
if header is None:
|
||||||
|
header = [c.lower() for c in cells]
|
||||||
|
else:
|
||||||
|
rows.append(cells)
|
||||||
|
if not header:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
if len(r) != len(header):
|
||||||
|
continue
|
||||||
|
out.append(dict(zip(header, r)))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Oeffentliche Reads
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def agent_status() -> dict:
|
||||||
|
"""Live-Status des Hermes-Gateways + Cron-Tickers."""
|
||||||
|
def _load() -> dict:
|
||||||
|
st = {
|
||||||
|
"gateway_state": "unknown",
|
||||||
|
"pid": None,
|
||||||
|
"active_agents": None,
|
||||||
|
"api_server": "unknown",
|
||||||
|
"cron_running": False,
|
||||||
|
"heartbeat_age_s": None,
|
||||||
|
"version": None,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
d = json.loads((HERMES_HOME / "gateway_state.json").read_text())
|
||||||
|
st["gateway_state"] = d.get("gateway_state", "unknown")
|
||||||
|
st["pid"] = d.get("pid")
|
||||||
|
st["active_agents"] = d.get("active_agents")
|
||||||
|
api = (d.get("platforms") or {}).get("api_server") or {}
|
||||||
|
st["api_server"] = api.get("state", "unknown")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Liveness: Cron-Ticker-Heartbeat (Unix-Timestamp). < 120s = laeuft.
|
||||||
|
try:
|
||||||
|
ts = float((HERMES_HOME / "cron" / "ticker_heartbeat").read_text().strip())
|
||||||
|
age = max(0, int(time.time() - ts))
|
||||||
|
st["heartbeat_age_s"] = age
|
||||||
|
st["cron_running"] = age < 120
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
st["version"] = (HERMES_HOME / "PINNED_VERSION").read_text().strip() or None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return st
|
||||||
|
|
||||||
|
return _cached("agent", _load)
|
||||||
|
|
||||||
|
|
||||||
|
def cron_jobs() -> list[dict]:
|
||||||
|
"""Geplante Jobs (inkl. pausierter)."""
|
||||||
|
return _cached("cron", lambda: _parse_rich_table(_run_cli(["cron", "list", "--all"]))) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def skills() -> list[dict]:
|
||||||
|
"""Installierte Skills, angereichert um Nutzungszaehler aus .usage.json."""
|
||||||
|
def _load() -> list[dict]:
|
||||||
|
rows = _parse_rich_table(_run_cli(["skills", "list"]))
|
||||||
|
usage = {}
|
||||||
|
try:
|
||||||
|
usage = json.loads((HERMES_HOME / "skills" / ".usage.json").read_text())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for r in rows:
|
||||||
|
u = usage.get(r.get("name", ""), {})
|
||||||
|
r["use_count"] = u.get("use_count", 0)
|
||||||
|
r["last_used_at"] = u.get("last_used_at")
|
||||||
|
r["pinned"] = bool(u.get("pinned", False))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
return _cached("skills", _load) # type: ignore[return-value]
|
||||||
@@ -268,6 +268,27 @@ def hermes_status():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hermes/agent", dependencies=[Depends(auth)])
|
||||||
|
def hermes_agent_status():
|
||||||
|
"""Phase 4 — Live-Status des Hermes-Agent-Gateways + Cron-Tickers."""
|
||||||
|
from hermes_control import agent_status
|
||||||
|
return agent_status()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hermes/cron", dependencies=[Depends(auth)])
|
||||||
|
def hermes_cron():
|
||||||
|
"""Phase 4 — geplante Cron-Jobs des Hermes-Agenten (inkl. pausierter)."""
|
||||||
|
from hermes_control import cron_jobs
|
||||||
|
return {"jobs": cron_jobs()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hermes/skills", dependencies=[Depends(auth)])
|
||||||
|
def hermes_skills():
|
||||||
|
"""Phase 4 — installierte Skills des Hermes-Agenten + Nutzungszaehler."""
|
||||||
|
from hermes_control import skills
|
||||||
|
return {"skills": skills()}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hermes/pubkey", dependencies=[Depends(auth)])
|
@router.get("/hermes/pubkey", dependencies=[Depends(auth)])
|
||||||
def hermes_pubkey():
|
def hermes_pubkey():
|
||||||
"""Gibt den SSH-Public-Key des Hermes Agent zurueck (fuer Windows authorized_keys)."""
|
"""Gibt den SSH-Public-Key des Hermes Agent zurueck (fuer Windows authorized_keys)."""
|
||||||
|
|||||||
Vendored
+45
-41
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user