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.
- roundtable/__init__.py +10 -0
- roundtable/agents.py +218 -0
- roundtable/cli.py +722 -0
- roundtable/config.py +265 -0
- roundtable/dashboard.py +533 -0
- roundtable/discovery.py +71 -0
- roundtable/engine.py +714 -0
- roundtable/errors.py +20 -0
- roundtable/insights.py +210 -0
- roundtable/llm.py +1048 -0
- roundtable/mcp.py +248 -0
- roundtable/modelpick.py +309 -0
- roundtable/models.py +202 -0
- roundtable/prompts.py +205 -0
- roundtable/runctl.py +212 -0
- roundtable/scan.py +187 -0
- roundtable/store.py +339 -0
- roundtable_cli-0.4.0.dist-info/METADATA +570 -0
- roundtable_cli-0.4.0.dist-info/RECORD +22 -0
- roundtable_cli-0.4.0.dist-info/WHEEL +4 -0
- roundtable_cli-0.4.0.dist-info/entry_points.txt +4 -0
- roundtable_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
roundtable/dashboard.py
ADDED
|
@@ -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 & 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 => ({"&":"&","<":"<",">":">",'"':"""}[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
|
+
"""
|
roundtable/discovery.py
ADDED
|
@@ -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())))
|