| 1 | """ |
| 2 | darwin-deck - Live recursive agent tree event bus |
| 3 | Ports: HTTP 9893, WebSocket 9892 |
| 4 | Source: Claude Code agent transcripts under /tmp/claude-0/-shared-projects/<session>/tasks/*.output |
| 5 | Contract: /shared/projects/darwin/deck/CONTRACTS.md §1 |
| 6 | """ |
| 7 | from __future__ import annotations |
| 8 | |
| 9 | import asyncio |
| 10 | import json |
| 11 | import os |
| 12 | import re |
| 13 | import time |
| 14 | import uuid |
| 15 | from datetime import datetime, timezone |
| 16 | from pathlib import Path |
| 17 | from typing import Any |
| 18 | |
| 19 | from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request |
| 20 | from fastapi.middleware.cors import CORSMiddleware |
| 21 | from fastapi.responses import JSONResponse |
| 22 | from pydantic import BaseModel, Field |
| 23 | |
| 24 | TASK_ROOTS = [Path("/tmp/claude-0/-shared-projects")] |
| 25 | SCAN_INTERVAL_S = 2.0 |
| 26 | MAX_AGENT_AGE_H = 48 |
| 27 | |
| 28 | LIVE_WINDOW_S = 180.0 |
| 29 | |
| 30 | STRUCTURAL_MODELS = {"system", "orchestrator", "host"} |
| 31 | |
| 32 | |
| 33 | def _parse_ts(ts: str | None) -> float: |
| 34 | """Parse an ISO-8601 timestamp to a UTC epoch. Returns 0.0 on failure.""" |
| 35 | if not ts: |
| 36 | return 0.0 |
| 37 | try: |
| 38 | s = ts.replace("Z", "+00:00") |
| 39 | dt = datetime.fromisoformat(s) |
| 40 | if dt.tzinfo is None: |
| 41 | dt = dt.replace(tzinfo=timezone.utc) |
| 42 | return dt.timestamp() |
| 43 | except Exception: |
| 44 | return 0.0 |
| 45 | |
| 46 | class DeckEvent(BaseModel): |
| 47 | ts: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) |
| 48 | agent_id: str |
| 49 | parent_id: str | None = None |
| 50 | type: str |
| 51 | label: str = "" |
| 52 | model: str = "sonnet" |
| 53 | status: str = "running" |
| 54 | tool: str | None = None |
| 55 | tokens: int = 0 |
| 56 | host: str = "ct215" |
| 57 | session: str = "" |
| 58 | |
| 59 | |
| 60 | class AgentState: |
| 61 | def __init__(self, agent_id: str, session: str): |
| 62 | self.agent_id = agent_id |
| 63 | self.session = session |
| 64 | self.parent_id: str | None = None |
| 65 | self.label: str = "" |
| 66 | self.model: str = "sonnet" |
| 67 | self.status: str = "running" |
| 68 | self.tokens: int = 0 |
| 69 | self.host: str = "ct215" |
| 70 | self.spawned_at: str = datetime.now(timezone.utc).isoformat() |
| 71 | self.last_activity: str = datetime.now(timezone.utc).isoformat() |
| 72 | self.current_tool: str | None = None |
| 73 | self.task_count: int = 0 |
| 74 | self.last_activity_ts: float = 0.0 |
| 75 | |
| 76 | def is_structural(self) -> bool: |
| 77 | """Synthetic grouping nodes (root / per-session / per-host) - kept in the |
| 78 | tree for layout but never counted as live agents.""" |
| 79 | return self.model in STRUCTURAL_MODELS or self.agent_id == "ct215-root" |
| 80 | |
| 81 | def effective_status(self, now: float | None = None) -> str: |
| 82 | """Real-time status. An agent is 'running' only if it has shown activity |
| 83 | within LIVE_WINDOW_S AND has not hit a terminal marker. Stale 'running' |
| 84 | agents (old transcripts that never wrote a clean return) collapse to |
| 85 | 'done'. Structural grouping nodes are never counted as active.""" |
| 86 | if self.is_structural(): |
| 87 | return self.status |
| 88 | if self.status in ("done", "error"): |
| 89 | return self.status |
| 90 | now = time.time() if now is None else now |
| 91 | if (now - self.last_activity_ts) > LIVE_WINDOW_S: |
| 92 | return "done" |
| 93 | return self.status |
| 94 | |
| 95 | def to_tree_node(self) -> dict: |
| 96 | return { |
| 97 | "agent_id": self.agent_id, |
| 98 | "parent_id": self.parent_id, |
| 99 | "label": self.label or self.agent_id[:8], |
| 100 | "model": self.model, |
| 101 | "status": self.effective_status(), |
| 102 | "tokens": self.tokens, |
| 103 | "host": self.host, |
| 104 | "session": self.session, |
| 105 | "spawned_at": self.spawned_at, |
| 106 | "last_activity": self.last_activity, |
| 107 | "current_tool": self.current_tool, |
| 108 | "task_count": self.task_count, |
| 109 | } |
| 110 | |
| 111 | |
| 112 | class TreeStore: |
| 113 | """In-memory agent tree. Thread-safe enough for single async loop.""" |
| 114 | |
| 115 | def __init__(self): |
| 116 | self.agents: dict[str, AgentState] = {} |
| 117 | root = AgentState("ct215-root", "system") |
| 118 | root.label = "DARWIN" |
| 119 | root.model = "system" |
| 120 | root.status = "running" |
| 121 | root.host = "ct215" |
| 122 | root.spawned_at = datetime.now(timezone.utc).isoformat() |
| 123 | root.last_activity = datetime.now(timezone.utc).isoformat() |
| 124 | self.agents["ct215-root"] = root |
| 125 | |
| 126 | def upsert(self, agent_id: str, session: str) -> AgentState: |
| 127 | if agent_id not in self.agents: |
| 128 | self.agents[agent_id] = AgentState(agent_id, session) |
| 129 | return self.agents[agent_id] |
| 130 | |
| 131 | def get_tree(self) -> dict: |
| 132 | now = time.time() |
| 133 | nodes = [a.to_tree_node() for a in self.agents.values()] |
| 134 | active = sum( |
| 135 | 1 for a in self.agents.values() |
| 136 | if not a.is_structural() and a.effective_status(now) == "running" |
| 137 | ) |
| 138 | total_tokens = sum(a.tokens for a in self.agents.values()) |
| 139 | return { |
| 140 | "ts": datetime.now(timezone.utc).isoformat(), |
| 141 | "nodes": nodes, |
| 142 | "active_count": active, |
| 143 | "total_tokens": total_tokens, |
| 144 | } |
| 145 | |
| 146 | def health_summary(self) -> dict: |
| 147 | now = time.time() |
| 148 | real = [a for a in self.agents.values() if not a.is_structural()] |
| 149 | eff = {a.agent_id: a.effective_status(now) for a in real} |
| 150 | active = [a for a in real if eff[a.agent_id] == "running"] |
| 151 | done = [a for a in real if eff[a.agent_id] == "done"] |
| 152 | errors = [a for a in real if eff[a.agent_id] == "error"] |
| 153 | return { |
| 154 | "active_agents": len(active), |
| 155 | "done_agents": len(done), |
| 156 | "error_agents": len(errors), |
| 157 | "total_agents": len(self.agents), |
| 158 | "total_tokens": sum(a.tokens for a in self.agents.values()), |
| 159 | "ok": True, |
| 160 | } |
| 161 | |
| 162 | |
| 163 | class ConnectionManager: |
| 164 | def __init__(self): |
| 165 | self._connections: list[WebSocket] = [] |
| 166 | |
| 167 | async def connect(self, ws: WebSocket): |
| 168 | await ws.accept() |
| 169 | self._connections.append(ws) |
| 170 | |
| 171 | def disconnect(self, ws: WebSocket): |
| 172 | self._connections.discard(ws) if hasattr(self._connections, 'discard') else None |
| 173 | if ws in self._connections: |
| 174 | self._connections.remove(ws) |
| 175 | |
| 176 | async def broadcast(self, event: dict): |
| 177 | msg = json.dumps(event) |
| 178 | dead: list[WebSocket] = [] |
| 179 | for ws in list(self._connections): |
| 180 | try: |
| 181 | await ws.send_text(msg) |
| 182 | except Exception: |
| 183 | dead.append(ws) |
| 184 | for ws in dead: |
| 185 | self.disconnect(ws) |
| 186 | |
| 187 | |
| 188 | class TranscriptParser: |
| 189 | """ |
| 190 | Tails .output JSONL files in task dirs. |
| 191 | Each line is a JSON object with keys: type, agentId, parentUuid, timestamp, |
| 192 | message.{role, content, usage}, sessionId. |
| 193 | |
| 194 | Normalises to DeckEvent per contract §1. |
| 195 | """ |
| 196 | |
| 197 | def __init__(self, store: TreeStore, manager: ConnectionManager): |
| 198 | self.store = store |
| 199 | self.manager = manager |
| 200 | self._offsets: dict[str, int] = {} |
| 201 | self._seen_agents: set[str] = set() |
| 202 | self._file_mtime: float = time.time() |
| 203 | |
| 204 | def _all_task_files(self) -> list[Path]: |
| 205 | files = [] |
| 206 | for root in TASK_ROOTS: |
| 207 | if not root.exists(): |
| 208 | continue |
| 209 | for session_dir in root.iterdir(): |
| 210 | if not session_dir.is_dir(): |
| 211 | continue |
| 212 | tasks_dir = session_dir / "tasks" |
| 213 | if tasks_dir.is_dir(): |
| 214 | for f in tasks_dir.glob("*.output"): |
| 215 | files.append(f) |
| 216 | return files |
| 217 | |
| 218 | async def poll(self): |
| 219 | """Called every SCAN_INTERVAL_S. Reads new lines from all transcript files.""" |
| 220 | events_to_broadcast: list[dict] = [] |
| 221 | |
| 222 | for fpath in self._all_task_files(): |
| 223 | offset = self._offsets.get(str(fpath), 0) |
| 224 | try: |
| 225 | st = fpath.stat() |
| 226 | size = st.st_size |
| 227 | fmtime = st.st_mtime |
| 228 | except OSError: |
| 229 | continue |
| 230 | if size <= offset: |
| 231 | continue |
| 232 | self._file_mtime = fmtime |
| 233 | |
| 234 | new_events = [] |
| 235 | try: |
| 236 | with fpath.open("rb") as f: |
| 237 | f.seek(offset) |
| 238 | chunk = f.read(size - offset) |
| 239 | new_offset = offset + len(chunk) |
| 240 | |
| 241 | for raw_line in chunk.split(b"\n"): |
| 242 | raw_line = raw_line.strip() |
| 243 | if not raw_line: |
| 244 | continue |
| 245 | try: |
| 246 | obj = json.loads(raw_line) |
| 247 | ev = self._parse_line(obj, fpath) |
| 248 | if ev: |
| 249 | new_events.append(ev) |
| 250 | except (json.JSONDecodeError, Exception): |
| 251 | continue |
| 252 | |
| 253 | self._offsets[str(fpath)] = new_offset |
| 254 | except OSError: |
| 255 | continue |
| 256 | |
| 257 | events_to_broadcast.extend(new_events) |
| 258 | |
| 259 | for ev in events_to_broadcast: |
| 260 | await self.manager.broadcast(ev) |
| 261 | |
| 262 | def _parse_line(self, obj: dict, fpath: Path) -> dict | None: |
| 263 | """Parse one JSONL line into a normalized Deck event dict.""" |
| 264 | line_type = obj.get("type", "") |
| 265 | agent_id = obj.get("agentId", "") |
| 266 | session_id = obj.get("sessionId", "") or fpath.parent.parent.name |
| 267 | |
| 268 | if not agent_id or line_type not in ("user", "assistant", "attachment"): |
| 269 | return None |
| 270 | |
| 271 | agent = self.store.upsert(agent_id, session_id) |
| 272 | agent.last_activity_ts = max(agent.last_activity_ts, getattr(self, "_file_mtime", time.time())) |
| 273 | |
| 274 | if agent_id not in self._seen_agents: |
| 275 | self._seen_agents.add(agent_id) |
| 276 | ts = obj.get("timestamp", datetime.now(timezone.utc).isoformat()) |
| 277 | |
| 278 | if line_type == "user": |
| 279 | content = obj.get("message", {}).get("content", "") |
| 280 | if isinstance(content, str): |
| 281 | label = content[:80].strip().replace("\n", " ") |
| 282 | elif isinstance(content, list) and content: |
| 283 | first = content[0] |
| 284 | if isinstance(first, dict): |
| 285 | label = str(first.get("text", ""))[:80].replace("\n", " ") |
| 286 | else: |
| 287 | label = str(first)[:80] |
| 288 | else: |
| 289 | label = "" |
| 290 | agent.label = label or agent_id[:12] |
| 291 | agent.spawned_at = ts |
| 292 | |
| 293 | agent.parent_id = f"session-{session_id[:8]}" |
| 294 | |
| 295 | self._ensure_session_node(session_id) |
| 296 | |
| 297 | agent.last_activity = ts |
| 298 | event = { |
| 299 | "ts": ts, |
| 300 | "agent_id": agent_id, |
| 301 | "parent_id": agent.parent_id, |
| 302 | "type": "spawned", |
| 303 | "label": agent.label, |
| 304 | "model": agent.model, |
| 305 | "status": "running", |
| 306 | "tokens": 0, |
| 307 | "host": "ct215", |
| 308 | "session": session_id, |
| 309 | } |
| 310 | return event |
| 311 | |
| 312 | if line_type == "assistant": |
| 313 | ts = obj.get("timestamp", datetime.now(timezone.utc).isoformat()) |
| 314 | msg = obj.get("message", {}) |
| 315 | usage = msg.get("usage", {}) |
| 316 | |
| 317 | total = ( |
| 318 | usage.get("input_tokens", 0) |
| 319 | + usage.get("cache_read_input_tokens", 0) |
| 320 | + usage.get("cache_creation_input_tokens", 0) |
| 321 | + usage.get("output_tokens", 0) |
| 322 | ) |
| 323 | agent.tokens = max(agent.tokens, total) |
| 324 | agent.last_activity = ts |
| 325 | |
| 326 | content = msg.get("content", []) |
| 327 | stop_reason = msg.get("stop_reason", "") |
| 328 | |
| 329 | if isinstance(content, list): |
| 330 | for c in content: |
| 331 | if not isinstance(c, dict): |
| 332 | continue |
| 333 | if c.get("type") == "tool_use": |
| 334 | tool_name = c.get("name", "") |
| 335 | agent.current_tool = tool_name |
| 336 | agent.task_count += 1 |
| 337 | |
| 338 | if tool_name == "Agent": |
| 339 | inp = c.get("input", {}) |
| 340 | child_desc = inp.get("description", inp.get("prompt", "")[:60]) |
| 341 | event = { |
| 342 | "ts": ts, |
| 343 | "agent_id": agent_id, |
| 344 | "parent_id": agent.parent_id, |
| 345 | "type": "child_spawned", |
| 346 | "label": agent.label, |
| 347 | "model": agent.model, |
| 348 | "status": "running", |
| 349 | "tool": f"Agent:{child_desc[:40]}", |
| 350 | "tokens": agent.tokens, |
| 351 | "host": "ct215", |
| 352 | "session": session_id, |
| 353 | } |
| 354 | return event |
| 355 | |
| 356 | event = { |
| 357 | "ts": ts, |
| 358 | "agent_id": agent_id, |
| 359 | "parent_id": agent.parent_id, |
| 360 | "type": "tool_call", |
| 361 | "label": agent.label, |
| 362 | "model": agent.model, |
| 363 | "status": "running", |
| 364 | "tool": tool_name, |
| 365 | "tokens": agent.tokens, |
| 366 | "host": "ct215", |
| 367 | "session": session_id, |
| 368 | } |
| 369 | return event |
| 370 | |
| 371 | if stop_reason == "end_turn": |
| 372 | agent.status = "done" |
| 373 | agent.current_tool = None |
| 374 | event = { |
| 375 | "ts": ts, |
| 376 | "agent_id": agent_id, |
| 377 | "parent_id": agent.parent_id, |
| 378 | "type": "returned", |
| 379 | "label": agent.label, |
| 380 | "model": agent.model, |
| 381 | "status": "done", |
| 382 | "tokens": agent.tokens, |
| 383 | "host": "ct215", |
| 384 | "session": session_id, |
| 385 | } |
| 386 | return event |
| 387 | |
| 388 | if agent.tokens > 0: |
| 389 | event = { |
| 390 | "ts": ts, |
| 391 | "agent_id": agent_id, |
| 392 | "parent_id": agent.parent_id, |
| 393 | "type": "token_tick", |
| 394 | "label": agent.label, |
| 395 | "model": agent.model, |
| 396 | "status": "running", |
| 397 | "tokens": agent.tokens, |
| 398 | "host": "ct215", |
| 399 | "session": session_id, |
| 400 | } |
| 401 | return event |
| 402 | |
| 403 | return None |
| 404 | |
| 405 | def _ensure_session_node(self, session_id: str): |
| 406 | """Create a session-level node that groups agents for this session.""" |
| 407 | node_id = f"session-{session_id[:8]}" |
| 408 | if node_id not in self.store.agents: |
| 409 | session_node = AgentState(node_id, session_id) |
| 410 | session_node.label = f"session:{session_id[:8]}" |
| 411 | session_node.model = "orchestrator" |
| 412 | session_node.status = "running" |
| 413 | session_node.parent_id = "ct215-root" |
| 414 | self.store.agents[node_id] = session_node |
| 415 | self.store.agents["ct215-root"].task_count += 1 |
| 416 | |
| 417 | |
| 418 | def ensure_host_node(store: "TreeStore", host: str) -> str: |
| 419 | """Create (once) a host-level grouping node under the root for non-ct215 hosts. |
| 420 | |
| 421 | Returns the host node id so cross-host agents can nest under it when they |
| 422 | don't carry an explicit parent_id. |
| 423 | """ |
| 424 | if not host or host == "ct215": |
| 425 | return "ct215-root" |
| 426 | node_id = f"host-{host}" |
| 427 | if node_id not in store.agents: |
| 428 | hn = AgentState(node_id, "system") |
| 429 | hn.label = host.upper() |
| 430 | hn.model = "host" |
| 431 | hn.status = "running" |
| 432 | hn.host = host |
| 433 | hn.parent_id = "ct215-root" |
| 434 | store.agents[node_id] = hn |
| 435 | store.agents["ct215-root"].task_count += 1 |
| 436 | return node_id |
| 437 | |
| 438 | |
| 439 | app = FastAPI(title="darwin-deck", version="1.0.0") |
| 440 | |
| 441 | app.add_middleware( |
| 442 | CORSMiddleware, |
| 443 | allow_origins=["*"], |
| 444 | allow_methods=["*"], |
| 445 | allow_headers=["*"], |
| 446 | ) |
| 447 | |
| 448 | store = TreeStore() |
| 449 | ws_manager = ConnectionManager() |
| 450 | parser = TranscriptParser(store, ws_manager) |
| 451 | |
| 452 | |
| 453 | @app.on_event("startup") |
| 454 | async def startup(): |
| 455 | asyncio.create_task(poll_loop()) |
| 456 | |
| 457 | |
| 458 | async def poll_loop(): |
| 459 | while True: |
| 460 | try: |
| 461 | await parser.poll() |
| 462 | except Exception as e: |
| 463 | print(f"[deck] poll error: {e}") |
| 464 | await asyncio.sleep(SCAN_INTERVAL_S) |
| 465 | |
| 466 | |
| 467 | @app.get("/health") |
| 468 | async def health(): |
| 469 | return store.health_summary() |
| 470 | |
| 471 | |
| 472 | @app.get("/tree") |
| 473 | async def get_tree(): |
| 474 | return store.get_tree() |
| 475 | |
| 476 | |
| 477 | @app.post("/event") |
| 478 | async def ingest_event(request: Request): |
| 479 | """Generic ingest - any agent or process can POST a contract-schema event.""" |
| 480 | try: |
| 481 | body = await request.json() |
| 482 | except Exception: |
| 483 | return JSONResponse({"error": "invalid json"}, status_code=400) |
| 484 | |
| 485 | agent_id = body.get("agent_id", "") |
| 486 | if not agent_id: |
| 487 | return JSONResponse({"error": "agent_id required"}, status_code=400) |
| 488 | |
| 489 | session = body.get("session", "external") |
| 490 | agent = store.upsert(agent_id, session) |
| 491 | |
| 492 | if body.get("label"): |
| 493 | agent.label = body["label"] |
| 494 | if body.get("model"): |
| 495 | agent.model = body["model"] |
| 496 | if body.get("status"): |
| 497 | agent.status = body["status"] |
| 498 | if body.get("tokens"): |
| 499 | agent.tokens = max(agent.tokens, int(body["tokens"])) |
| 500 | host = body.get("host") or agent.host |
| 501 | agent.host = host |
| 502 | if body.get("tool"): |
| 503 | agent.current_tool = body["tool"] |
| 504 | if body.get("type") in ("tool_call", "child_spawned"): |
| 505 | agent.task_count += 1 |
| 506 | |
| 507 | parent_id = body.get("parent_id") |
| 508 | if parent_id: |
| 509 | if parent_id not in store.agents: |
| 510 | stub = store.upsert(parent_id, session) |
| 511 | stub.host = host |
| 512 | stub.parent_id = ensure_host_node(store, host) |
| 513 | agent.parent_id = parent_id |
| 514 | elif not agent.parent_id: |
| 515 | agent.parent_id = ensure_host_node(store, host) |
| 516 | |
| 517 | ev_ts = body.get("ts", datetime.now(timezone.utc).isoformat()) |
| 518 | agent.last_activity = ev_ts |
| 519 | ev_epoch = _parse_ts(ev_ts) |
| 520 | if ev_epoch <= 0: |
| 521 | ev_epoch = time.time() |
| 522 | agent.last_activity_ts = max(agent.last_activity_ts, ev_epoch) |
| 523 | |
| 524 | await ws_manager.broadcast(body) |
| 525 | return {"ok": True, "agent_id": agent_id} |
| 526 | |
| 527 | |
| 528 | @app.websocket("/stream") |
| 529 | async def websocket_stream(ws: WebSocket): |
| 530 | await ws_manager.connect(ws) |
| 531 | try: |
| 532 | snapshot = store.get_tree() |
| 533 | await ws.send_text(json.dumps({"type": "snapshot", **snapshot})) |
| 534 | while True: |
| 535 | try: |
| 536 | await asyncio.wait_for(ws.receive_text(), timeout=30) |
| 537 | except asyncio.TimeoutError: |
| 538 | await ws.send_text(json.dumps({"type": "ping", "ts": datetime.now(timezone.utc).isoformat()})) |
| 539 | except WebSocketDisconnect: |
| 540 | pass |
| 541 | except Exception: |
| 542 | pass |
| 543 | finally: |
| 544 | ws_manager.disconnect(ws) |
| 545 | |
| 546 | |
| 547 | if __name__ == "__main__": |
| 548 | import uvicorn |
| 549 | uvicorn.run("deck:app", host="0.0.0.0", port=9893, log_level="info") |