| 1 | """ |
| 2 | darwin-collect - cross-host session collector for the Darwin Deck (v2). |
| 3 | |
| 4 | Runs centrally on CT215. Over root SSH it reads the *newest* session log on |
| 5 | each remote agent host, normalizes new activity into contract-§1 events, and |
| 6 | POSTs them to the Deck `/event`. Change-only: per (host,session) byte offset is |
| 7 | persisted on local ext4 so each run only ships new records (idempotent upserts |
| 8 | on the Deck side make re-sends harmless anyway). |
| 9 | |
| 10 | Collected hosts: |
| 11 | - ct247 (REDACTED-IP) - OpenCode / GLM, ~/.local/share/opencode/log/opencode.log |
| 12 | (key=value text log; model glm-5.2, session.id=ses_*) |
| 13 | - ct241 (REDACTED-IP) - Codex CLI, newest ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl |
| 14 | (rollout JSONL; model from turn_context, tokens from token_count) |
| 15 | |
| 16 | State (local ext4, NOT CIFS): /var/tmp/darwin-dev/collectors/state/<host>.json |
| 17 | Deck: http://REDACTED-IP:9893/event (override DARWIN_DECK_URL) |
| 18 | |
| 19 | Designed to be invoked by a systemd timer (darwin-collect.timer) every 30s, or |
| 20 | looped. Each invocation is a single pass and exits. Never raises on a dead host |
| 21 | or dead Deck - the bus must survive. |
| 22 | """ |
| 23 | from __future__ import annotations |
| 24 | |
| 25 | import json |
| 26 | import os |
| 27 | import re |
| 28 | import subprocess |
| 29 | import sys |
| 30 | import urllib.request |
| 31 | from datetime import datetime, timezone |
| 32 | from pathlib import Path |
| 33 | |
| 34 | DECK_URL = os.environ.get("DARWIN_DECK_URL", "http://REDACTED-IP:9893").rstrip("/") |
| 35 | STATE_DIR = Path(os.environ.get("DARWIN_STATE_DIR", "/var/tmp/darwin-dev/collectors/state")) |
| 36 | SSH = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=6", "-o", "StrictHostKeyChecking=no"] |
| 37 | |
| 38 | HOSTS = { |
| 39 | "ct247": "REDACTED-IP", |
| 40 | "ct241": "REDACTED-IP", |
| 41 | } |
| 42 | |
| 43 | |
| 44 | def _now() -> str: |
| 45 | return datetime.now(timezone.utc).isoformat() |
| 46 | |
| 47 | |
| 48 | def _post(event: dict) -> bool: |
| 49 | data = json.dumps(event).encode() |
| 50 | req = urllib.request.Request( |
| 51 | DECK_URL + "/event", data=data, |
| 52 | headers={"Content-Type": "application/json"}, method="POST", |
| 53 | ) |
| 54 | try: |
| 55 | urllib.request.urlopen(req, timeout=5).read() |
| 56 | return True |
| 57 | except Exception as e: |
| 58 | sys.stderr.write(f"[collect] POST failed: {e}\n") |
| 59 | return False |
| 60 | |
| 61 | |
| 62 | def _ssh(ip: str, cmd: str, timeout: int = 25) -> str | None: |
| 63 | try: |
| 64 | out = subprocess.run( |
| 65 | SSH + [f"root@{ip}", cmd], |
| 66 | capture_output=True, text=True, timeout=timeout, |
| 67 | ) |
| 68 | if out.returncode != 0: |
| 69 | return None |
| 70 | return out.stdout |
| 71 | except Exception: |
| 72 | return None |
| 73 | |
| 74 | |
| 75 | def _load_state(host: str) -> dict: |
| 76 | f = STATE_DIR / f"{host}.json" |
| 77 | if f.exists(): |
| 78 | try: |
| 79 | return json.loads(f.read_text()) |
| 80 | except Exception: |
| 81 | return {} |
| 82 | return {} |
| 83 | |
| 84 | |
| 85 | def _save_state(host: str, state: dict) -> None: |
| 86 | STATE_DIR.mkdir(parents=True, exist_ok=True) |
| 87 | (STATE_DIR / f"{host}.json").write_text(json.dumps(state)) |
| 88 | |
| 89 | |
| 90 | _KV = re.compile(r'(\w[\w.]*)=("(?:[^"\\]|\\.)*"|\S+)') |
| 91 | LOG_247 = "/root/.local/share/opencode/log/opencode.log" |
| 92 | |
| 93 | |
| 94 | def _kv(line: str) -> dict: |
| 95 | d = {} |
| 96 | for k, v in _KV.findall(line): |
| 97 | if v and v[0] == '"': |
| 98 | v = v[1:-1] |
| 99 | d[k] = v |
| 100 | return d |
| 101 | |
| 102 | |
| 103 | def collect_opencode(host: str, ip: str) -> int: |
| 104 | state = _load_state(host) |
| 105 | offset = int(state.get("offset", 0)) |
| 106 | size_out = _ssh(ip, f"stat -c %s {LOG_247} 2>/dev/null") |
| 107 | if not size_out or not size_out.strip().isdigit(): |
| 108 | return 0 |
| 109 | size = int(size_out.strip()) |
| 110 | if size < offset: |
| 111 | offset = 0 |
| 112 | if size <= offset: |
| 113 | return 0 |
| 114 | chunk = _ssh(ip, f"tail -c +{offset + 1} {LOG_247} 2>/dev/null") |
| 115 | if chunk is None: |
| 116 | return 0 |
| 117 | |
| 118 | sessions = state.get("sessions", {}) |
| 119 | posted = 0 |
| 120 | for line in chunk.splitlines(): |
| 121 | if "session.id=" not in line and "run=" not in line: |
| 122 | continue |
| 123 | kv = _kv(line) |
| 124 | sid = kv.get("session.id") |
| 125 | msg = kv.get("message", "") |
| 126 | model = kv.get("llm.model", "glm-5.2") |
| 127 | ts = kv.get("timestamp", _now()) |
| 128 | if not sid: |
| 129 | continue |
| 130 | s = sessions.setdefault(sid, {"model": model, "started": False}) |
| 131 | if model and model != "": |
| 132 | s["model"] = model |
| 133 | aid = f"opencode-{sid}" |
| 134 | base = { |
| 135 | "ts": ts, "agent_id": aid, "parent_id": None, |
| 136 | "label": f"GLM {sid[:12]}", "model": s["model"], |
| 137 | "host": host, "session": sid, |
| 138 | } |
| 139 | if not s["started"]: |
| 140 | s["started"] = True |
| 141 | ev = dict(base, type="spawned", status="running") |
| 142 | if _post(ev): |
| 143 | posted += 1 |
| 144 | if "loop" in msg or "shell tool" in msg or "creating" in msg: |
| 145 | ev = dict(base, type="tool_call", status="running", |
| 146 | tool=kv.get("step") and f"step:{kv['step']}" or "loop") |
| 147 | if _post(ev): |
| 148 | posted += 1 |
| 149 | if "exiting loop" in msg or "disposing instance" in msg: |
| 150 | ev = dict(base, type="returned", status="done") |
| 151 | if _post(ev): |
| 152 | posted += 1 |
| 153 | state["offset"] = size |
| 154 | state["sessions"] = sessions |
| 155 | _save_state(host, state) |
| 156 | return posted |
| 157 | |
| 158 | |
| 159 | def collect_codex(host: str, ip: str) -> int: |
| 160 | newest = _ssh( |
| 161 | ip, |
| 162 | "ls -t /root/.codex/sessions/*/*/*/rollout-*.jsonl " |
| 163 | "/home/zion/.codex/sessions/*/*/*/rollout-*.jsonl 2>/dev/null | head -1", |
| 164 | ) |
| 165 | if not newest or not newest.strip(): |
| 166 | return 0 |
| 167 | path = newest.strip().splitlines()[0] |
| 168 | |
| 169 | state = _load_state(host) |
| 170 | sess = state.get("path") == path and state or {"path": path, "offset": 0} |
| 171 | offset = int(sess.get("offset", 0)) |
| 172 | |
| 173 | size_out = _ssh(ip, f"stat -c %s '{path}' 2>/dev/null") |
| 174 | if not size_out or not size_out.strip().isdigit(): |
| 175 | return 0 |
| 176 | size = int(size_out.strip()) |
| 177 | if size < offset: |
| 178 | offset = 0 |
| 179 | if size <= offset: |
| 180 | return 0 |
| 181 | chunk = _ssh(ip, f"tail -c +{offset + 1} '{path}' 2>/dev/null") |
| 182 | if chunk is None: |
| 183 | return 0 |
| 184 | |
| 185 | m = re.search(r"rollout-[\dT-]+-([0-9a-f-]+)\.jsonl", path) |
| 186 | sid = m.group(1) if m else Path(path).stem |
| 187 | aid = f"codex-{sid}" |
| 188 | model = sess.get("model", "gpt-5.5") |
| 189 | tokens = int(sess.get("tokens", 0)) |
| 190 | started = sess.get("started", False) |
| 191 | |
| 192 | posted = 0 |
| 193 | for raw in chunk.splitlines(): |
| 194 | raw = raw.strip() |
| 195 | if not raw: |
| 196 | continue |
| 197 | try: |
| 198 | o = json.loads(raw) |
| 199 | except Exception: |
| 200 | continue |
| 201 | t = o.get("type") |
| 202 | p = o.get("payload", {}) |
| 203 | ts = o.get("timestamp", _now()) |
| 204 | base = { |
| 205 | "agent_id": aid, "parent_id": None, "host": host, "session": sid, |
| 206 | } |
| 207 | if t == "session_meta": |
| 208 | sid2 = p.get("id", sid) |
| 209 | base["session"] = sid2 |
| 210 | if not started: |
| 211 | started = True |
| 212 | ev = dict(base, ts=ts, type="spawned", status="running", |
| 213 | label=f"Codex {sid2[:12]}", model=model) |
| 214 | if _post(ev): |
| 215 | posted += 1 |
| 216 | elif t == "turn_context": |
| 217 | model = p.get("model", model) |
| 218 | elif t == "event_msg": |
| 219 | st = p.get("type") |
| 220 | if st == "token_count": |
| 221 | tu = p.get("info", {}).get("total_token_usage", {}) |
| 222 | tokens = max(tokens, int(tu.get("total_tokens", tokens))) |
| 223 | ev = dict(base, ts=ts, type="token_tick", status="running", |
| 224 | label=f"Codex {sid[:12]}", model=model, tokens=tokens) |
| 225 | if _post(ev): |
| 226 | posted += 1 |
| 227 | elif st in ("function_call", "mcp_tool_call_end", "patch_apply_end", "agent_message"): |
| 228 | if not started: |
| 229 | started = True |
| 230 | _post(dict(base, ts=ts, type="spawned", status="running", |
| 231 | label=f"Codex {sid[:12]}", model=model)) |
| 232 | ev = dict(base, ts=ts, type="tool_call", status="running", |
| 233 | label=f"Codex {sid[:12]}", model=model, |
| 234 | tokens=tokens, tool=st) |
| 235 | if _post(ev): |
| 236 | posted += 1 |
| 237 | elif st == "task_complete": |
| 238 | ev = dict(base, ts=ts, type="returned", status="done", |
| 239 | label=f"Codex {sid[:12]}", model=model, tokens=tokens) |
| 240 | if _post(ev): |
| 241 | posted += 1 |
| 242 | |
| 243 | _save_state(host, {"path": path, "offset": size, "model": model, |
| 244 | "tokens": tokens, "started": started}) |
| 245 | return posted |
| 246 | |
| 247 | |
| 248 | def main() -> int: |
| 249 | total = 0 |
| 250 | for host, ip in HOSTS.items(): |
| 251 | try: |
| 252 | if host == "ct247": |
| 253 | total += collect_opencode(host, ip) |
| 254 | elif host == "ct241": |
| 255 | total += collect_codex(host, ip) |
| 256 | except Exception as e: |
| 257 | sys.stderr.write(f"[collect] {host} error: {e}\n") |
| 258 | print(f"[collect] posted {total} events at {_now()}") |
| 259 | return 0 |
| 260 | |
| 261 | |
| 262 | if __name__ == "__main__": |
| 263 | sys.exit(main()) |