Zion Boggan
repos/Darwin/deck/deck.py
zionboggan.com ↗
549 lines · python
History for this file →
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")