roundtable-cli 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,533 @@
1
+ """Zero-dependency web dashboard + local REST control API for a roundtable project.
2
+
3
+ A stdlib ``http.server`` serves one self-contained page (no build step, no JS
4
+ framework) that polls ``/api/state`` ~1s and renders live progress, plus a JSON
5
+ control API used by the page's buttons and by the desktop app:
6
+
7
+ GET /api/state live run state snapshot (insights.build_state)
8
+ GET /api/project project root + what exists (plan/config)
9
+ GET /api/plan plan.json parsed
10
+ PUT /api/plan save an edited plan (validates; resets approval)
11
+ POST /api/plan/generate spawn a detached `roundtable plan` (goal/prd/plan_file)
12
+ GET /api/plan/generate poll a detached plan generation (running/log tail)
13
+ POST /api/approve validate runners + set approved
14
+ POST /api/run spawn a detached `roundtable run` (guarded by run.pid)
15
+ POST /api/stop SIGTERM the recorded run pid
16
+ POST /api/resume approve a waiting HITL task {"task": "p1-t2"}
17
+ POST /api/init scaffold .roundtable/ + default config
18
+ GET /api/config roundtable.config.yaml text
19
+ PUT /api/config save config text (validated as YAML + schema)
20
+ GET /api/agents probe configured CLIs + their models (?timeout=s)
21
+ GET /api/usage provider usage snapshots from the event log
22
+
23
+ The server only binds to loopback hosts. State-changing requests from browsers
24
+ are restricted to localhost / Tauri origins; non-browser local clients send no
25
+ Origin header and pass. Reads come fresh from disk per request, so the server
26
+ tracks runs started elsewhere.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import asyncio
32
+ import json
33
+ from dataclasses import asdict
34
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
35
+ from typing import Any
36
+ from urllib.parse import parse_qs, urlparse
37
+
38
+ import yaml
39
+ from pydantic import ValidationError
40
+
41
+ from . import runctl
42
+ from .config import CONFIG_FILENAME, Config, load_config, write_default_config
43
+ from .discovery import discover
44
+ from .engine import validate_runners
45
+ from .errors import RoundtableError
46
+ from .insights import build_state
47
+ from .models import Plan
48
+ from .store import Store
49
+
50
+
51
+ def _origin_allowed(origin: str) -> bool:
52
+ """Browser origins that may drive the control API.
53
+
54
+ Local pages (the dashboard itself, a dev server) and the Tauri desktop app
55
+ are allowed; arbitrary web sites are not. Requests without an Origin header
56
+ (curl, the desktop app's Rust side, same-origin GETs) never reach this.
57
+ """
58
+ if origin in ("tauri://localhost", "http://tauri.localhost"):
59
+ return True
60
+ try:
61
+ host = urlparse(origin).hostname or ""
62
+ except ValueError:
63
+ return False
64
+ return host in ("localhost", "127.0.0.1", "::1")
65
+
66
+
67
+ def _is_loopback_bind(host: str) -> bool:
68
+ return host in ("localhost", "127.0.0.1", "::1")
69
+
70
+
71
+ def make_server(store: Store, *, host: str = "127.0.0.1", port: int = 8787) -> tuple[ThreadingHTTPServer, str]:
72
+ """Build (but do not start) the dashboard/API server; returns (server, url)."""
73
+ if not _is_loopback_bind(host):
74
+ raise RoundtableError(
75
+ "dashboard/API only binds to localhost for safety; use "
76
+ "--host 127.0.0.1 or --host localhost"
77
+ )
78
+ page = PAGE.encode("utf-8")
79
+
80
+ class Handler(BaseHTTPRequestHandler):
81
+ def log_message(self, *args: Any) -> None: # keep the console quiet
82
+ pass
83
+
84
+ # ---- plumbing ---------------------------------------------------- #
85
+ def _send(self, code: int, ctype: str, body: bytes) -> None:
86
+ self.send_response(code)
87
+ self.send_header("Content-Type", ctype)
88
+ self.send_header("Content-Length", str(len(body)))
89
+ self.send_header("Cache-Control", "no-store")
90
+ origin = self.headers.get("Origin", "")
91
+ if origin and _origin_allowed(origin):
92
+ self.send_header("Access-Control-Allow-Origin", origin)
93
+ self.send_header("Vary", "Origin")
94
+ self.end_headers()
95
+ if self.command != "HEAD":
96
+ self.wfile.write(body)
97
+
98
+ def _json(self, code: int, obj: Any) -> None:
99
+ self._send(code, "application/json; charset=utf-8", json.dumps(obj).encode("utf-8"))
100
+
101
+ def _read_body(self) -> dict[str, Any]:
102
+ length = int(self.headers.get("Content-Length") or 0)
103
+ if not length:
104
+ return {}
105
+ raw = self.rfile.read(length)
106
+ try:
107
+ data = json.loads(raw)
108
+ except json.JSONDecodeError as e:
109
+ raise RoundtableError(f"request body is not valid JSON: {e}") from e
110
+ if not isinstance(data, dict):
111
+ raise RoundtableError("request body must be a JSON object")
112
+ return data
113
+
114
+ def _guard_origin(self) -> bool:
115
+ origin = self.headers.get("Origin", "")
116
+ if origin and not _origin_allowed(origin):
117
+ self._json(403, {"ok": False, "error": f"origin {origin!r} not allowed"})
118
+ return False
119
+ return True
120
+
121
+ def do_OPTIONS(self) -> None: # CORS preflight
122
+ origin = self.headers.get("Origin", "")
123
+ self.send_response(204)
124
+ if origin and _origin_allowed(origin):
125
+ self.send_header("Access-Control-Allow-Origin", origin)
126
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
127
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
128
+ self.send_header("Vary", "Origin")
129
+ self.end_headers()
130
+
131
+ def _dispatch(self, method: str) -> None:
132
+ path = urlparse(self.path).path
133
+ query = {k: v[-1] for k, v in parse_qs(urlparse(self.path).query).items()}
134
+ try:
135
+ handler = _ROUTES.get((method, path))
136
+ if handler is None:
137
+ if method == "GET" and path in ("/", "/index.html"):
138
+ self._send(200, "text/html; charset=utf-8", page)
139
+ else:
140
+ self._json(404, {"ok": False, "error": f"no route {method} {path}"})
141
+ return
142
+ body = self._read_body() if method in ("POST", "PUT") else {}
143
+ code, obj = handler(store, body, query)
144
+ self._json(code, obj)
145
+ except (RoundtableError, ValidationError, yaml.YAMLError) as e:
146
+ self._json(400, {"ok": False, "error": str(e)})
147
+ except FileNotFoundError as e:
148
+ self._json(404, {"ok": False, "error": str(e)})
149
+ except Exception as e: # keep the server alive; report the failure
150
+ self._json(500, {"ok": False, "error": f"internal error: {e}"})
151
+
152
+ def do_GET(self) -> None:
153
+ self._dispatch("GET")
154
+
155
+ def do_HEAD(self) -> None:
156
+ self._dispatch("GET")
157
+
158
+ def do_POST(self) -> None:
159
+ if self._guard_origin():
160
+ self._dispatch("POST")
161
+
162
+ def do_PUT(self) -> None:
163
+ if self._guard_origin():
164
+ self._dispatch("PUT")
165
+
166
+ try:
167
+ httpd = ThreadingHTTPServer((host, port), Handler)
168
+ except OSError as e:
169
+ raise RoundtableError(
170
+ f"could not start the dashboard on {host}:{port} ({e.strerror or e}); "
171
+ f"the port is likely in use by another app — retry with "
172
+ f"`roundtable dashboard --port <other>` (e.g. 8899)"
173
+ ) from e
174
+ bound_host, bound_port = httpd.server_address[0], httpd.server_address[1]
175
+ # Show the explicit IPv4 loopback rather than "localhost": on dual-stack hosts
176
+ # "localhost" can resolve to ::1 first and miss this IPv4-bound server.
177
+ shown = "127.0.0.1" if bound_host in ("0.0.0.0", "127.0.0.1") else bound_host
178
+ return httpd, f"http://{shown}:{bound_port}"
179
+
180
+
181
+ # --------------------------------------------------------------------------- #
182
+ # API handlers: (store, body, query) -> (status_code, json_payload)
183
+ # --------------------------------------------------------------------------- #
184
+ Payload = tuple[int, dict[str, Any]]
185
+
186
+
187
+ def _ensure_no_live_run(store: Store) -> None:
188
+ pid = runctl.current_run_pid(store)
189
+ if pid is not None:
190
+ raise RoundtableError(
191
+ f"cannot modify plan/config while a run is in progress (pid={pid}); "
192
+ "stop it or wait for it to finish"
193
+ )
194
+
195
+
196
+ def _api_state(store: Store, body: dict, query: dict) -> Payload:
197
+ return 200, build_state(store)
198
+
199
+
200
+ def _api_project(store: Store, body: dict, query: dict) -> Payload:
201
+ return 200, {
202
+ "root": str(store.root),
203
+ "name": store.root.name,
204
+ "has_plan": store.has_plan(),
205
+ "has_config": (store.root / CONFIG_FILENAME).exists(),
206
+ "run_pid": runctl.current_run_pid(store),
207
+ "waiting": store.list_waiting_checkpoints(),
208
+ }
209
+
210
+
211
+ def _api_plan_get(store: Store, body: dict, query: dict) -> Payload:
212
+ if not store.has_plan():
213
+ return 200, {"exists": False, "plan": None}
214
+ return 200, {"exists": True, "plan": json.loads(store.manifest_path.read_text())}
215
+
216
+
217
+ def _api_plan_put(store: Store, body: dict, query: dict) -> Payload:
218
+ _ensure_no_live_run(store)
219
+ plan_data = body.get("plan", body)
220
+ plan = Plan.model_validate(plan_data)
221
+ plan.approved = False # editing a plan always resets the human gate
222
+ store.save_plan(plan)
223
+ store.write_plan_md(plan)
224
+ store.record_event("plan_edited", message="plan edited via API (approval reset)")
225
+ return 200, {"ok": True, "message": "plan saved (approval reset — re-approve to run)"}
226
+
227
+
228
+ def _api_plan_generate_post(store: Store, body: dict, query: dict) -> Payload:
229
+ _ensure_no_live_run(store)
230
+ pid, msg = runctl.start_plan(
231
+ store,
232
+ goal=body.get("goal"),
233
+ prd=body.get("prd"),
234
+ plan_file=body.get("plan_file"),
235
+ model=body.get("model"),
236
+ )
237
+ if pid is None:
238
+ return 409, {"ok": False, "error": msg}
239
+ return 200, {"ok": True, "pid": pid, "message": msg}
240
+
241
+
242
+ def _api_plan_generate_get(store: Store, body: dict, query: dict) -> Payload:
243
+ return 200, runctl.plan_status(store)
244
+
245
+
246
+ def _api_approve(store: Store, body: dict, query: dict) -> Payload:
247
+ if not store.has_plan():
248
+ return 404, {"ok": False, "error": "no plan to approve; generate one first"}
249
+ plan = store.load_plan()
250
+ config = load_config(store.root)
251
+ validate_runners(plan, config) # RoundtableError -> 400 with the problem list
252
+ plan.approved = True
253
+ store.save_plan(plan)
254
+ store.record_event("plan_approved", message="plan approved")
255
+ return 200, {"ok": True, "message": "plan approved"}
256
+
257
+
258
+ def _api_run(store: Store, body: dict, query: dict) -> Payload:
259
+ if not store.has_plan():
260
+ return 404, {"ok": False, "error": "no plan to run; generate and approve one first"}
261
+ pid, msg = runctl.start_run(store, approve=bool(body.get("approve")))
262
+ if pid is None:
263
+ return 409, {"ok": False, "error": msg}
264
+ return 200, {"ok": True, "pid": pid, "message": msg}
265
+
266
+
267
+ def _api_stop(store: Store, body: dict, query: dict) -> Payload:
268
+ stopped, msg = runctl.stop_run(store)
269
+ return 200, {"ok": stopped, "message": msg}
270
+
271
+
272
+ def _api_resume(store: Store, body: dict, query: dict) -> Payload:
273
+ task_id = body.get("task")
274
+ if not task_id:
275
+ raise RoundtableError("missing 'task' in request body")
276
+ msg = runctl.approve_hitl(store, str(task_id))
277
+ return 200, {"ok": True, "message": msg}
278
+
279
+
280
+ def _api_init(store: Store, body: dict, query: dict) -> Payload:
281
+ _ensure_no_live_run(store)
282
+ store.scaffold()
283
+ cfg = store.root / CONFIG_FILENAME
284
+ created = False
285
+ if not cfg.exists():
286
+ write_default_config(store.root)
287
+ created = True
288
+ return 200, {
289
+ "ok": True,
290
+ "created_config": created,
291
+ "message": f"initialized roundtable in {store.root}"
292
+ + ("" if created else " (config already existed)"),
293
+ }
294
+
295
+
296
+ def _api_config_get(store: Store, body: dict, query: dict) -> Payload:
297
+ path = store.root / CONFIG_FILENAME
298
+ return 200, {
299
+ "path": str(path),
300
+ "exists": path.exists(),
301
+ "text": path.read_text() if path.exists() else "",
302
+ }
303
+
304
+
305
+ def _api_config_put(store: Store, body: dict, query: dict) -> Payload:
306
+ _ensure_no_live_run(store)
307
+ text = body.get("text")
308
+ if not isinstance(text, str):
309
+ raise RoundtableError("missing 'text' (the YAML config content) in request body")
310
+ Config.model_validate(yaml.safe_load(text) or {}) # reject invalid configs
311
+ (store.root / CONFIG_FILENAME).write_text(text)
312
+ return 200, {"ok": True, "message": f"wrote {CONFIG_FILENAME}"}
313
+
314
+
315
+ def _api_agents(store: Store, body: dict, query: dict) -> Payload:
316
+ config = load_config(store.root)
317
+ try:
318
+ timeout = float(query.get("timeout", 10.0))
319
+ except ValueError:
320
+ timeout = 10.0
321
+ statuses = asyncio.run(discover(config.agents, timeout=timeout))
322
+ return 200, {
323
+ "provider": config.provider,
324
+ "models": {k: str(v) for k, v in {
325
+ "planner": config.models.planner, "main": config.models.main,
326
+ "phase": config.models.phase, "task": config.models.task,
327
+ }.items()},
328
+ "agents": [asdict(s) for s in statuses],
329
+ }
330
+
331
+
332
+ def _api_usage(store: Store, body: dict, query: dict) -> Payload:
333
+ snapshots = [
334
+ {k: v for k, v in e.items() if k not in ("type", "msg")}
335
+ for e in store.read_events()
336
+ if e.get("type") == "usage"
337
+ ]
338
+ return 200, {"latest": snapshots[-1] if snapshots else None, "history": snapshots}
339
+
340
+
341
+ _ROUTES: dict[tuple[str, str], Any] = {
342
+ ("GET", "/api/state"): _api_state,
343
+ ("GET", "/api/project"): _api_project,
344
+ ("GET", "/api/plan"): _api_plan_get,
345
+ ("PUT", "/api/plan"): _api_plan_put,
346
+ ("POST", "/api/plan/generate"): _api_plan_generate_post,
347
+ ("GET", "/api/plan/generate"): _api_plan_generate_get,
348
+ ("POST", "/api/approve"): _api_approve,
349
+ ("POST", "/api/run"): _api_run,
350
+ ("POST", "/api/stop"): _api_stop,
351
+ ("POST", "/api/resume"): _api_resume,
352
+ ("POST", "/api/init"): _api_init,
353
+ ("GET", "/api/config"): _api_config_get,
354
+ ("PUT", "/api/config"): _api_config_put,
355
+ ("GET", "/api/agents"): _api_agents,
356
+ ("GET", "/api/usage"): _api_usage,
357
+ }
358
+
359
+
360
+ PAGE = r"""<!doctype html>
361
+ <html lang="en">
362
+ <head>
363
+ <meta charset="utf-8">
364
+ <meta name="viewport" content="width=device-width, initial-scale=1">
365
+ <title>Roundtable dashboard</title>
366
+ <style>
367
+ :root { color-scheme: dark; }
368
+ * { box-sizing: border-box; }
369
+ body { margin: 0; font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
370
+ background: #0d1117; color: #c9d1d9; }
371
+ header { padding: 18px 22px; border-bottom: 1px solid #21262d; position: sticky; top: 0;
372
+ background: #0d1117; }
373
+ h1 { font-size: 15px; margin: 0 0 6px; letter-spacing: .04em; color: #8b949e; font-weight: 600; }
374
+ .goal { color: #e6edf3; font-size: 16px; margin: 2px 0 12px; }
375
+ .badge { padding: 2px 9px; border-radius: 999px; font-size: 12px; margin-left: 8px; }
376
+ .running { background: #1f6feb33; color: #58a6ff; }
377
+ .done { background: #23863633; color: #3fb950; }
378
+ .failed { background: #f8514933; color: #f85149; }
379
+ .pending { background: #6e768133; color: #8b949e; }
380
+ .bar { height: 8px; border-radius: 5px; background: #21262d; overflow: hidden; }
381
+ .bar > i { display: block; height: 100%; background: linear-gradient(90deg,#1f6feb,#3fb950); transition: width .4s; }
382
+ .meta { color: #8b949e; font-size: 12px; margin-top: 6px; }
383
+ main { display: grid; grid-template-columns: 1.5fr 1fr; gap: 16px; padding: 16px 22px; align-items: start; }
384
+ @media (max-width: 860px){ main { grid-template-columns: 1fr; } }
385
+ .card { background: #161b22; border: 1px solid #21262d; border-radius: 10px; padding: 14px 16px; }
386
+ .card h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .08em; color: #8b949e;
387
+ margin: 0 0 10px; }
388
+ .now { border-color: #1f6feb55; }
389
+ .now .row { display: flex; justify-content: space-between; gap: 10px; padding: 6px 0; }
390
+ .now .t { color: #e6edf3; } .now .r { color: #58a6ff; } .now .e { color: #8b949e; }
391
+ .idle { color: #6e7681; }
392
+ .phase { margin-bottom: 12px; }
393
+ .phase .ph { display: flex; justify-content: space-between; color: #e6edf3; margin-bottom: 4px; }
394
+ .ph .runner { color: #8b949e; font-size: 12px; }
395
+ .tasks { list-style: none; margin: 0; padding: 0 0 0 2px; }
396
+ .tasks li { display: flex; align-items: center; gap: 8px; padding: 2px 0; color: #adbac7; }
397
+ .dot { width: 9px; height: 9px; border-radius: 50%; flex: none; background: #30363d; }
398
+ .s-done .dot { background: #3fb950; } .s-in_progress .dot { background: #58a6ff; animation: pulse 1s infinite; }
399
+ .s-failed .dot { background: #f85149; } .s-skipped .dot { background: #6e7681; opacity: .5; }
400
+ .s-waiting .dot { background: #d29922; animation: pulse 1.5s infinite; }
401
+ @keyframes pulse { 50% { opacity: .35; } }
402
+ .tasks .id { color: #6e7681; } .tasks .meta2 { margin-left: auto; color: #6e7681; font-size: 12px; }
403
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; }
404
+ .chip { background: #21262d; border-radius: 6px; padding: 3px 8px; font-size: 12px; }
405
+ .chip b { color: #e6edf3; }
406
+ .ev { list-style: none; margin: 0; padding: 0; max-height: 320px; overflow: auto; }
407
+ .ev li { display: flex; gap: 8px; padding: 3px 0; border-bottom: 1px solid #1b1f24; font-size: 12.5px; }
408
+ .ev .ts { color: #6e7681; flex: none; } .ev .ty { color: #58a6ff; flex: none; width: 110px; }
409
+ .ev .ms { color: #adbac7; }
410
+ #conn { font-size: 12px; color: #6e7681; }
411
+ #conn.off { color: #f85149; }
412
+ #controls { margin-top: 10px; display: flex; align-items: center; gap: 8px; }
413
+ button { font: inherit; font-size: 12px; padding: 4px 12px; border-radius: 6px; cursor: pointer;
414
+ background: #21262d; color: #c9d1d9; border: 1px solid #30363d; }
415
+ button:hover { background: #30363d; }
416
+ button.primary { background: #1f6feb; border-color: #1f6feb; color: #fff; }
417
+ button.primary:hover { background: #388bfd; }
418
+ button.danger { color: #f85149; border-color: #f8514966; }
419
+ button.mini { padding: 0 8px; font-size: 11px; margin-left: 6px; color: #d29922; border-color: #d2992266; }
420
+ #actmsg { font-size: 12px; color: #8b949e; }
421
+ </style>
422
+ </head>
423
+ <body>
424
+ <header>
425
+ <h1>LLM-ROUNDTABLE <span id="conn">connecting…</span></h1>
426
+ <div class="goal" id="goal">—</div>
427
+ <div>status <span id="badge" class="badge pending">—</span>
428
+ <span id="counts" class="meta"></span></div>
429
+ <div class="bar" style="margin-top:8px"><i id="fill" style="width:0%"></i></div>
430
+ <div id="controls">
431
+ <button id="btn-approve" class="primary" style="display:none">approve plan</button>
432
+ <button id="btn-run" class="primary" style="display:none">▶ run</button>
433
+ <button id="btn-stop" class="danger" style="display:none">■ stop</button>
434
+ <span id="actmsg"></span>
435
+ </div>
436
+ </header>
437
+ <main>
438
+ <div>
439
+ <div class="card now" id="nowcard"><h2>Now running</h2><div id="now"></div></div>
440
+ <div class="card" style="margin-top:16px"><h2>Phases &amp; tasks</h2><div id="phases"></div></div>
441
+ </div>
442
+ <div>
443
+ <div class="card"><h2>Insights</h2><div id="insights"></div></div>
444
+ <div class="card" style="margin-top:16px"><h2>Events</h2><ul class="ev" id="events"></ul></div>
445
+ </div>
446
+ </main>
447
+ <script>
448
+ const $ = id => document.getElementById(id);
449
+ const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
450
+ const dur = s => { if (s==null) return "—"; s=Math.max(0,Math.floor(s)); const h=(s/3600|0), m=((s%3600)/60|0), x=s%60;
451
+ const p=n=>String(n).padStart(2,"0"); return h?`${h}:${p(m)}:${p(x)}`:`${m}:${p(x)}`; };
452
+
453
+ async function post(path, body) {
454
+ try {
455
+ const r = await fetch(path, {method:"POST", headers:{"Content-Type":"application/json"},
456
+ body: JSON.stringify(body || {})});
457
+ const j = await r.json();
458
+ $("actmsg").textContent = j.message || j.error || "";
459
+ } catch (e) { $("actmsg").textContent = String(e); }
460
+ clearTimeout(post._t); post._t = setTimeout(() => { $("actmsg").textContent = ""; }, 8000);
461
+ tick();
462
+ }
463
+ $("btn-approve").onclick = () => post("/api/approve");
464
+ $("btn-run").onclick = () => post("/api/run");
465
+ $("btn-stop").onclick = () => { if (confirm("Stop the in-progress run?")) post("/api/stop"); };
466
+ $("phases").onclick = e => {
467
+ const t = e.target.closest("button[data-task]");
468
+ if (t) post("/api/resume", {task: t.dataset.task});
469
+ };
470
+
471
+ async function tick() {
472
+ let st;
473
+ try { st = await (await fetch("/api/state", {cache:"no-store"})).json(); }
474
+ catch (e) { $("conn").textContent = "● disconnected"; $("conn").className = "off"; return; }
475
+ $("conn").textContent = "● live"; $("conn").className = "";
476
+ if (!st.exists) { $("goal").textContent = "no plan yet — run `roundtable plan`"; return; }
477
+
478
+ $("goal").textContent = st.goal;
479
+ const t = st.totals;
480
+ $("badge").textContent = st.status; $("badge").className = "badge " + st.status;
481
+ $("counts").textContent = `${t.done}/${t.tasks} tasks · ${t.percent}% · ${t.phases} phases`
482
+ + (t.in_progress?` · ${t.in_progress} running`:"") + (t.failed?` · ${t.failed} failed`:"");
483
+ $("fill").style.width = t.percent + "%";
484
+
485
+ // controls
486
+ $("btn-approve").style.display = !st.approved ? "" : "none";
487
+ $("btn-run").style.display = st.approved && st.status !== "running" ? "" : "none";
488
+ $("btn-stop").style.display = st.status === "running" ? "" : "none";
489
+
490
+ // now
491
+ $("nowcard").style.display = "";
492
+ if (st.now.length) {
493
+ $("now").innerHTML = st.now.map(n => `<div class="row">
494
+ <span><span class="t">${esc(n.title)}</span> <span class="id">${esc(n.id)}</span></span>
495
+ <span class="r">${esc(n.runner)}</span><span class="e">${dur(n.elapsed_s)}</span></div>`).join("");
496
+ } else {
497
+ $("now").innerHTML = `<div class="idle">${st.status==="done"?"run complete":"idle — nothing running"}</div>`;
498
+ }
499
+
500
+ // phases
501
+ $("phases").innerHTML = st.phases.map(p => `<div class="phase">
502
+ <div class="ph"><span>[${p.index}] ${esc(p.title)} <span class="runner">${esc(p.runner)}</span></span>
503
+ <span class="meta">${p.done}/${p.total}</span></div>
504
+ <ul class="tasks">${p.tasks.map(tk => `<li class="s-${tk.status}">
505
+ <span class="dot"></span><span class="id">${esc(tk.id)}</span> ${esc(tk.title)}
506
+ ${tk.status==="waiting"?`<button class="mini" data-task="${esc(tk.id)}">approve task</button>`:""}
507
+ <span class="meta2">${esc(tk.runner)}${tk.duration_s!=null?` · ${dur(tk.duration_s)}`:""}</span></li>`).join("")}</ul>
508
+ </div>`).join("");
509
+
510
+ // insights
511
+ const ba = Object.entries(st.by_agent).sort((a,b)=>b[1].tasks-a[1].tasks)
512
+ .map(([k,v]) => `<span class="chip"><b>${esc(k)}</b> ${v.tasks} task${v.tasks===1?"":"s"}${v.total_s?` · ${dur(v.total_s)}`:""}</span>`).join("");
513
+ const tm = st.timings;
514
+ $("insights").innerHTML =
515
+ `<div class="chips">${ba || '<span class="idle">no agent activity yet</span>'}</div>`
516
+ + (tm.avg_task_s!=null ? `<div class="meta" style="margin-top:10px">avg task ${dur(tm.avg_task_s)}`
517
+ + (tm.slowest?` · slowest ${esc(tm.slowest.task_id)} ${dur(tm.slowest.duration_s)}`:"") + `</div>` : "")
518
+ + (st.usage ? `<div class="meta" style="margin-top:10px">usage: <b>${st.usage.total_tokens.toLocaleString()}</b> tokens `
519
+ + `(${st.usage.prompt_tokens.toLocaleString()} in / ${st.usage.completion_tokens.toLocaleString()} out) · ${st.usage.calls} calls`
520
+ + (st.usage.cost_usd ? ` · <b>$${st.usage.cost_usd.toFixed(4)}</b>` : "")
521
+ + (st.usage.estimated ? ` <span class="idle">(est)</span>` : "") + `</div>` : "");
522
+
523
+ // events
524
+ $("events").innerHTML = st.events.map(e => `<li>
525
+ <span class="ts">${esc((e.ts||"").slice(11,19))}</span>
526
+ <span class="ty">${esc(e.type||"")}</span>
527
+ <span class="ms">${esc(e.msg || e.task_id || "")}</span></li>`).join("");
528
+ }
529
+ tick(); setInterval(tick, 1000);
530
+ </script>
531
+ </body>
532
+ </html>
533
+ """
@@ -0,0 +1,71 @@
1
+ """Discover which configured CLI agents are installed and which models they offer.
2
+
3
+ CLI detection is reliable (a PATH lookup). Model listing is best-effort: only
4
+ agents with a ``models_command`` can be enumerated, and those commands often hit
5
+ the network/auth and can be slow — so each runs with a bounded timeout and any
6
+ failure degrades to a note instead of raising. Used by ``roundtable init`` and
7
+ ``roundtable agents`` to show what you can assign to roles.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import shutil
14
+ from dataclasses import dataclass, field
15
+
16
+ from .config import AgentSpec
17
+
18
+
19
+ @dataclass
20
+ class AgentStatus:
21
+ name: str
22
+ binary: str
23
+ installed: bool
24
+ models: list[str] = field(default_factory=list)
25
+ note: str = "" # why models is empty (no command / timed out / error), if so
26
+
27
+
28
+ async def _list_models(spec: AgentSpec, timeout: float) -> tuple[list[str], str]:
29
+ argv = list(spec.models_command or [])
30
+ exe = shutil.which(argv[0])
31
+ if exe is None:
32
+ return [], f"{argv[0]!r} not found"
33
+ argv[0] = exe
34
+ try:
35
+ proc = await asyncio.create_subprocess_exec(
36
+ *argv,
37
+ stdout=asyncio.subprocess.PIPE,
38
+ stderr=asyncio.subprocess.PIPE,
39
+ )
40
+ except OSError as e: # pragma: no cover - exec failure is environment-specific
41
+ return [], str(e)
42
+ try:
43
+ out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
44
+ except asyncio.TimeoutError:
45
+ proc.kill()
46
+ await proc.wait()
47
+ return [], f"timed out after {timeout:g}s"
48
+ if proc.returncode != 0:
49
+ tail = err.decode("utf-8", "replace").strip().splitlines()
50
+ return [], f"exited {proc.returncode}" + (f": {tail[-1]}" if tail else "")
51
+ lines = [ln.strip() for ln in out.decode("utf-8", "replace").splitlines() if ln.strip()]
52
+ return lines, "" if lines else "no models reported"
53
+
54
+
55
+ async def discover(agents: dict[str, AgentSpec], *, timeout: float = 15.0) -> list[AgentStatus]:
56
+ """Probe every agent concurrently; total time ~= the slowest single probe."""
57
+
58
+ async def one(name: str, spec: AgentSpec) -> AgentStatus:
59
+ binary = spec.command[0] if spec.command else ""
60
+ installed = shutil.which(binary) is not None if binary else False
61
+ st = AgentStatus(name=name, binary=binary, installed=installed)
62
+ if not installed:
63
+ st.note = "not on PATH"
64
+ return st
65
+ if spec.models_command:
66
+ st.models, st.note = await _list_models(spec, timeout)
67
+ else:
68
+ st.note = "no models_command"
69
+ return st
70
+
71
+ return list(await asyncio.gather(*(one(n, s) for n, s in agents.items())))