Skip to content

Commit 186ffb3

Browse files
authored
Merge pull request #52 from chiruu12/feat/agentos-control-plane
AgentOS Phase 4: browser control-plane UI
2 parents 1e1e3ff + 4467f7e commit 186ffb3

7 files changed

Lines changed: 188 additions & 3 deletions

File tree

docs/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
each case once and score it with every evaluator; `Evaluator` protocol for custom
3131
checks. `Agent.observe_tools(...)` captures tool-call traces; `TaskResult` now
3232
carries `cost_usd`/`total_tokens`.
33+
- **Control-plane web UI**: a self-contained browser dashboard served at `/` by
34+
`hive serve` (no build step, no data egress) -- the pending-approval queue with
35+
approve/deny, a live agents list, and sessions, auto-refreshing. Tenant-aware via
36+
an `X-Hive-User` field.
3337

3438
## [0.6.1] -- 2026-06-03
3539

docs/guide/rest-api.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,21 @@ The server lives behind the optional `api` extra:
1818
```bash
1919
pip install 'hive-agent[api]'
2020
hive init
21-
hive serve # http://127.0.0.1:8000 (Swagger UI at /docs)
21+
hive serve # http://127.0.0.1:8000
2222
hive serve --port 9000 --with-daemon
2323
```
2424

25+
Open `http://127.0.0.1:8000/` for the **control plane** (a browser dashboard),
26+
`/docs` for the Swagger API explorer.
27+
28+
## Control plane
29+
30+
The page at `/` is a self-contained dashboard (no build step) that talks to the API
31+
in the same process -- your data never leaves your machine. It shows the pending
32+
**approval queue** (with approve/deny buttons), the **agents** list with live status,
33+
and **sessions**, auto-refreshing every few seconds. Set the tenant in the `user`
34+
field (sent as `X-Hive-User`).
35+
2536
| Flag | Default | Meaning |
2637
|------|---------|---------|
2738
| `--host` | `127.0.0.1` | Bind address (local-first by default) |

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,11 @@ line-length = 100
108108
[tool.ruff.lint]
109109
select = ["E", "F", "I", "N", "W", "UP"]
110110

111+
[tool.ruff.lint.per-file-ignores]
112+
# ui.py embeds a static HTML/CSS/JS page as a string literal; line length doesn't
113+
# apply to the markup.
114+
"src/hive/server/ui.py" = ["E501"]
115+
111116
[tool.mypy]
112117
python_version = "3.11"
113118
strict = true

src/hive/cli/main.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1300,7 +1300,10 @@ def serve(
13001300

13011301
raise MissingDependencyError("api") from e
13021302

1303-
console.print(f"[green]Hive AgentOS API[/green] on http://{host}:{port} (docs at /docs)")
1303+
console.print(
1304+
f"[green]Hive AgentOS[/green] on http://{host}:{port} "
1305+
f"(control plane at /, API docs at /docs)"
1306+
)
13041307
app_instance = create_app(root=Path.cwd(), with_daemon=with_daemon)
13051308
uvicorn.run(app_instance, host=host, port=port, reload=reload)
13061309

src/hive/server/app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from hive.config import load_config
1313
from hive.daemon.setup import ensure_hive_dirs
1414
from hive.memory.store import HiveStore
15+
from hive.server import ui
1516
from hive.server.deps import ServerContext, SessionService
1617
from hive.server.errors import register_error_handlers
1718
from hive.server.routes import agents, approvals, sessions, system, tasks
@@ -80,6 +81,6 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
8081
lifespan=lifespan,
8182
)
8283
register_error_handlers(app)
83-
for module in (agents, tasks, approvals, sessions, system):
84+
for module in (agents, tasks, approvals, sessions, system, ui):
8485
app.include_router(module.router)
8586
return app

src/hive/server/ui.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""Self-contained control-plane web UI served at ``/``.
2+
3+
A single static page (no build step, no dependencies) that talks to the REST API
4+
in this same process. Read-only views of agents, sessions, and goals, plus the
5+
pending-approval queue with approve/deny actions -- the browser-based control plane.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from fastapi import APIRouter
11+
from fastapi.responses import HTMLResponse
12+
13+
router = APIRouter(tags=["ui"])
14+
15+
16+
_HTML = """<!DOCTYPE html>
17+
<html lang="en">
18+
<head>
19+
<meta charset="utf-8">
20+
<meta name="viewport" content="width=device-width, initial-scale=1">
21+
<title>Hive AgentOS</title>
22+
<style>
23+
:root { --bg:#0d1117; --panel:#161b22; --border:#30363d; --fg:#e6edf3; --dim:#8b949e;
24+
--accent:#58a6ff; --green:#3fb950; --yellow:#d29922; --magenta:#bc8cff; --red:#f85149; }
25+
* { box-sizing: border-box; }
26+
body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.5 -apple-system,
27+
BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; }
28+
header { display:flex; align-items:center; gap:16px; padding:14px 20px;
29+
border-bottom:1px solid var(--border); background:var(--panel); position:sticky; top:0; }
30+
header h1 { font-size:16px; margin:0; font-weight:600; }
31+
header h1 span { color:var(--accent); }
32+
header .meta { margin-left:auto; color:var(--dim); font-size:12px; display:flex; gap:12px; align-items:center; }
33+
input { background:var(--bg); border:1px solid var(--border); color:var(--fg);
34+
border-radius:6px; padding:4px 8px; font:inherit; }
35+
main { padding:20px; max-width:1100px; margin:0 auto; }
36+
section { background:var(--panel); border:1px solid var(--border); border-radius:8px;
37+
margin-bottom:20px; overflow:hidden; }
38+
h2 { font-size:13px; text-transform:uppercase; letter-spacing:.04em; color:var(--dim);
39+
margin:0; padding:12px 16px; border-bottom:1px solid var(--border); }
40+
table { width:100%; border-collapse:collapse; }
41+
td, th { text-align:left; padding:9px 16px; border-bottom:1px solid var(--border); font-size:13px; }
42+
th { color:var(--dim); font-weight:500; }
43+
tr:last-child td { border-bottom:none; }
44+
.empty { padding:16px; color:var(--dim); font-style:italic; }
45+
.badge { padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; }
46+
.idle { color:var(--dim); } .working { color:var(--yellow); }
47+
.waiting_approval { color:var(--magenta); } .error,.dead { color:var(--red); }
48+
code { background:var(--bg); padding:1px 5px; border-radius:4px; color:var(--dim); font-size:12px; }
49+
button { background:var(--accent); color:#fff; border:none; border-radius:6px; padding:4px 12px;
50+
font:inherit; cursor:pointer; }
51+
button.deny { background:var(--red); }
52+
button:hover { opacity:.9; }
53+
</style>
54+
</head>
55+
<body>
56+
<header>
57+
<h1>Hive <span>AgentOS</span></h1>
58+
<div class="meta">
59+
<label>user <input id="user" value="default" size="8"></label>
60+
<span id="clock">--</span>
61+
</div>
62+
</header>
63+
<main>
64+
<section><h2>Pending Approvals</h2><div id="approvals"></div></section>
65+
<section><h2>Agents</h2><div id="agents"></div></section>
66+
<section><h2>Sessions</h2><div id="sessions"></div></section>
67+
</main>
68+
<script>
69+
const $ = id => document.getElementById(id);
70+
const userHdr = () => ({ "X-Hive-User": $("user").value || "default" });
71+
// Escape for HTML text AND attribute contexts (includes quotes), so untrusted
72+
// fields (ids, status, args) can't break out of an attribute or inject markup.
73+
const ESC = {"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"};
74+
const esc = s => String(s ?? "").replace(/[&<>"']/g, c => ESC[c]);
75+
76+
async function api(path, opts = {}) {
77+
// Merge headers so a caller-supplied `headers` can't drop X-Hive-User.
78+
const { headers, ...rest } = opts;
79+
const r = await fetch(path, { headers: { ...userHdr(), ...headers }, ...rest });
80+
if (!r.ok) throw new Error(r.status + " " + path);
81+
return r.status === 204 ? null : r.json();
82+
}
83+
84+
function table(rows, cols) {
85+
if (!rows.length) return '<div class="empty">none</div>';
86+
const head = "<tr>" + cols.map(c => "<th>" + c.h + "</th>").join("") + "</tr>";
87+
const body = rows.map(r => "<tr>" + cols.map(c => "<td>" + c.f(r) + "</td>").join("") + "</tr>").join("");
88+
return "<table>" + head + body + "</table>";
89+
}
90+
91+
async function decide(agentId, approvalId, decision) {
92+
try {
93+
await api(`/agents/${encodeURIComponent(agentId)}/approvals/${encodeURIComponent(approvalId)}`,
94+
{ method: "POST", headers: { "Content-Type": "application/json" },
95+
body: JSON.stringify({ decision }) });
96+
refresh();
97+
} catch (e) { alert("Failed: " + e.message); }
98+
}
99+
100+
// Delegated click handler: ids come from dataset (never from interpolated markup).
101+
$("approvals").addEventListener("click", e => {
102+
const btn = e.target.closest("button.act");
103+
if (btn) decide(btn.dataset.agent, btn.dataset.id, btn.dataset.decision);
104+
});
105+
106+
async function refresh() {
107+
try {
108+
const [agents, approvals, sessions] = await Promise.all([
109+
api("/agents"), api("/approvals"), api("/sessions"),
110+
]);
111+
$("agents").innerHTML = table(agents, [
112+
{ h: "Name", f: a => esc(a.name) },
113+
{ h: "Role", f: a => esc(a.role) },
114+
{ h: "Model", f: a => "<code>" + esc(a.model) + "</code>" },
115+
{ h: "Status", f: a => `<span class="badge ${esc(a.status)}">${esc(a.status)}</span>` },
116+
{ h: "Goal", f: a => esc(a.goal) || "<span class='empty'>-</span>" },
117+
]);
118+
// Buttons carry ids in escaped data-* attributes; a single delegated listener
119+
// (below) handles clicks, so no untrusted value is ever interpolated into JS.
120+
$("approvals").innerHTML = table(approvals, [
121+
{ h: "Tool", f: a => "<code>" + esc(a.tool_name) + "</code>" },
122+
{ h: "Agent", f: a => esc(a.agent_id) },
123+
{ h: "Arguments", f: a => "<code>" + esc((a.arguments || "").slice(0, 80)) + "</code>" },
124+
{ h: "", f: a => {
125+
const attrs = `data-agent="${esc(a.agent_id)}" data-id="${esc(a.approval_id)}"`;
126+
return `<button class="act" data-decision="approve" ${attrs}>Approve</button>
127+
<button class="act deny" data-decision="deny" ${attrs}>Deny</button>`;
128+
} },
129+
]);
130+
$("sessions").innerHTML = table(sessions, [
131+
{ h: "Session", f: s => "<code>" + esc(s.session_id) + "</code>" },
132+
{ h: "Agent", f: s => esc(s.agent_id) },
133+
{ h: "Status", f: s => esc(s.status) },
134+
{ h: "Task", f: s => esc((s.task || "").slice(0, 60)) },
135+
]);
136+
$("clock").textContent = new Date().toLocaleTimeString();
137+
} catch (e) { $("clock").textContent = "error: " + e.message; }
138+
}
139+
140+
$("user").addEventListener("change", refresh);
141+
refresh();
142+
setInterval(refresh, 3000);
143+
</script>
144+
</body>
145+
</html>"""
146+
147+
148+
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
149+
async def control_plane() -> str:
150+
"""Serve the browser control plane."""
151+
return _HTML

tests/server/test_rest_api.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ def test_healthz(client: TestClient) -> None:
3535
assert resp.json()["database"] is True
3636

3737

38+
def test_control_plane_ui_served(client: TestClient) -> None:
39+
resp = client.get("/")
40+
assert resp.status_code == 200
41+
assert "text/html" in resp.headers["content-type"]
42+
assert "Hive" in resp.text and "Pending Approvals" in resp.text
43+
# XSS hardening: ids are not interpolated into inline JS; clicks are delegated.
44+
assert 'onclick="decide(' not in resp.text
45+
assert "data-decision" in resp.text
46+
47+
3848
def test_spawn_list_get_kill(client: TestClient) -> None:
3949
resp = client.post("/agents", json={"preset": "coder"})
4050
assert resp.status_code == 201

0 commit comments

Comments
 (0)