#!/usr/bin/env python3 """Swarm Tab — Native GTK3 view for Agent Teams. Reads directly from ~/.claude/teams/ and ~/.claude/tasks/ — no WebKit, no Flask server needed. Provides build_swarm_tab() and refresh_swarm() with the same API as the old WebKit-based version. """ import json import threading import time from datetime import datetime, timezone from pathlib import Path import gi gi.require_version("Gtk", "3.0") gi.require_version("WebKit2", "4.1") from gi.repository import Gtk, GLib, Gio, Pango, WebKit2 from theme import get_palette from monitor import ( get_anthropic_session_cost, get_provider_costs, get_active_sessions, get_sidecar_status, ) from swarm_visual import generate_comm_graph SWARM_HTML = Path.home() / ".agent" / "diagrams" / "agent-swarm-live.html" TEAMS_DIR = Path.home() / ".claude" / "teams" TASKS_DIR = Path.home() / ".claude" / "tasks" EVENTS_DIR = Path.home() / ".claude" / "events" # Events file offset tracking (like event_tab.py) _events_file_offset: int = 0 _events_current_day: str = "" # Gio.FileMonitor ref (prevent GC) _event_file_monitor: Gio.FileMonitor | None = None # Module-level refs for refresh _webview_ready: bool = False _team_flow: Gtk.FlowBox | None = None _task_list: Gtk.ListBox | None = None _msg_list: Gtk.ListBox | None = None _team_selector: Gtk.ComboBoxText | None = None _status_label: Gtk.Label | None = None _selected_team: str | None = None _refreshing: bool = False _stack: Gtk.Stack | None = None _webview: WebKit2.WebView | None = None # --------------------------------------------------------------------------- # Data layer # --------------------------------------------------------------------------- def _load_teams() -> list[dict]: """Load all team configs from ~/.claude/teams/*/config.json.""" teams = [] if not TEAMS_DIR.exists(): return teams for team_dir in sorted(TEAMS_DIR.iterdir()): if not team_dir.is_dir(): continue config = team_dir / "config.json" if not config.exists(): continue try: data = json.loads(config.read_text(encoding="utf-8")) name = data.get("name", team_dir.name) members = data.get("members", []) # Count tasks task_dir = TASKS_DIR / team_dir.name task_count = 0 active_tasks = 0 if task_dir.exists(): for tf in task_dir.glob("*.json"): try: td = json.loads(tf.read_text(encoding="utf-8")) if td.get("metadata", {}).get("_internal"): continue task_count += 1 if td.get("status") == "in_progress": mtime = tf.stat().st_mtime if (time.time() - mtime) < 7200: # 2h active_tasks += 1 except (json.JSONDecodeError, OSError): pass teams.append({ "name": name, "dir_name": team_dir.name, "members": len(members), "member_names": [m.get("name", "?") for m in members], "tasks": task_count, "active_tasks": active_tasks, "active": active_tasks > 0, "created": data.get("createdAt", 0), }) except (json.JSONDecodeError, OSError): continue return teams def _load_tasks(team_name: str) -> list[dict]: """Load tasks for a specific team.""" tasks = [] task_dir = TASKS_DIR / team_name if not task_dir.exists(): return tasks for tf in sorted(task_dir.glob("*.json"), key=lambda f: f.stem.zfill(10)): try: data = json.loads(tf.read_text(encoding="utf-8")) # Skip internal task-assignment tasks if data.get("metadata", {}).get("_internal"): continue tasks.append({ "id": data.get("id", tf.stem), "subject": data.get("subject", "(kein Titel)"), "description": data.get("description", "")[:120], "status": data.get("status", "pending"), "owner": data.get("owner", "(unassigned)"), }) except (json.JSONDecodeError, OSError): continue # Sort: in_progress first, then pending, then completed order = {"in_progress": 0, "pending": 1, "completed": 2} tasks.sort(key=lambda t: order.get(t["status"], 9)) return tasks def _collect_comm_data(team_name: str) -> dict: """Collect per-member status and new messages for live updates.""" team_dir = TEAMS_DIR / team_name config_path = team_dir / "config.json" if not config_path.exists(): return {"members": [], "tasks": []} config = json.loads(config_path.read_text(encoding="utf-8")) members = [] for m in config.get("members", []): name = m.get("name", "?") inbox = team_dir / "inboxes" / f"{name}.json" msg_count = 0 last_msg_time = "" if inbox.exists(): try: msgs = json.loads(inbox.read_text(encoding="utf-8")) if isinstance(msgs, list): # Filter internal messages real = [x for x in msgs if not any( k in x.get("text", "") for k in ['"type":"task_assignment"', '"type":"shutdown_request"', '"type":"idle_notification"'])] msg_count = len(real) if real: last_msg_time = real[-1].get("timestamp", "") except (json.JSONDecodeError, OSError): pass is_recent = False if last_msg_time: try: ts = datetime.fromisoformat(last_msg_time.replace("Z", "+00:00")) is_recent = (datetime.now(ts.tzinfo) - ts).total_seconds() < 120 except (ValueError, AttributeError): pass members.append({ "name": name, "status": "working" if is_recent else "idle", "msg_count": msg_count, "last_msg": last_msg_time, }) tasks = [] for t in _load_tasks(team_name): tasks.append({ "id": t["id"], "subject": t["subject"], "status": t["status"], "owner": t.get("owner", ""), }) # Collect new messages for the log all_msgs = [] inbox_dir = team_dir / "inboxes" if inbox_dir.exists(): for mf in inbox_dir.glob("*.json"): try: data = json.loads(mf.read_text(encoding="utf-8")) if not isinstance(data, list): continue recipient = mf.stem for msg in data: text = msg.get("text", "") if any(k in text for k in ['"type":"task_assignment"', '"type":"shutdown_request"', '"type":"idle_notification"']): continue ts_str = msg.get("timestamp", "") try: ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) time_fmt = ts.strftime("%H:%M") except (ValueError, AttributeError): time_fmt = "??:??" all_msgs.append({ "time": time_fmt, "sender": msg.get("from", "?"), "recipient": recipient, "text": text[:90].replace("\n", " "), "timestamp": ts_str, }) except (json.JSONDecodeError, OSError): continue all_msgs.sort(key=lambda m: m.get("timestamp", ""), reverse=True) return {"members": members, "tasks": tasks, "new_messages": all_msgs[:5], "events": []} def _load_messages(team_name: str, limit: int = 10) -> list[dict]: """Load recent messages from team inboxes.""" messages = [] inbox_dir = TEAMS_DIR / team_name / "inboxes" if not inbox_dir.exists(): return messages for mf in inbox_dir.glob("*.json"): try: data = json.loads(mf.read_text(encoding="utf-8")) if not isinstance(data, list): continue recipient = mf.stem for msg in data: # Skip internal task assignments and shutdown requests text = msg.get("text", "") if '"type":"task_assignment"' in text or '"type":"shutdown_request"' in text: continue ts_str = msg.get("timestamp", "") try: ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) time_fmt = ts.strftime("%H:%M") sort_key = ts.timestamp() except (ValueError, AttributeError): time_fmt = "??:??" sort_key = 0 sender = msg.get("from", "?") # Truncate text for preview preview = text[:100].replace("\n", " ") if len(text) > 100: preview += "..." messages.append({ "time": time_fmt, "sort_key": sort_key, "sender": sender, "recipient": recipient, "preview": preview, }) except (json.JSONDecodeError, OSError): continue messages.sort(key=lambda m: m["sort_key"], reverse=True) return messages[:limit] # --------------------------------------------------------------------------- # Event feed — reads hook events from ~/.claude/events/YYYY-MM-DD.jsonl # --------------------------------------------------------------------------- _INTERESTING_EVENTS = { "PostToolUse", "PreToolUse", "UserPromptSubmit", "SubagentStart", "SubagentStop", "SubagentComplete", } def _read_recent_events(limit: int = 20) -> list[dict]: """Read recent interesting events from today's event log. Uses file offset tracking to only read new data (like event_tab.py). """ global _events_file_offset, _events_current_day today = datetime.now().strftime("%Y-%m-%d") event_file = EVENTS_DIR / f"{today}.jsonl" if not event_file.exists(): return [] # Day rollover — seek near end instead of reading from byte 0 if today != _events_current_day: file_size = event_file.stat().st_size _events_file_offset = max(0, file_size - 50_000) _events_current_day = today new_lines = [] try: with open(event_file, "r", encoding="utf-8") as f: f.seek(_events_file_offset) new_lines = f.readlines() _events_file_offset = f.tell() except OSError: pass # Cap at last 100 lines to avoid processing stale history on day rollover if len(new_lines) > 100: new_lines = new_lines[-100:] events = [] for line in new_lines: line = line.strip() if not line: continue try: ev = json.loads(line) et = ev.get("event_type") or ev.get("hook_event_name", "") if et in _INTERESTING_EVENTS: events.append({ "type": et, "tool": ev.get("tool_name", ""), "time": ev.get("logged_iso", ""), "session": ev.get("session_id", "")[:8], "agent": ev.get("tool_input", {}).get("agentName", ""), }) except json.JSONDecodeError: pass # Return only the last `limit` events return events[-limit:] def _update_events_ui(events: list[dict]) -> bool: """Push events to the WebKit view on the GTK main thread. idle_add callback.""" if not (_stack and _stack.get_visible_child_name() == "visual" and _webview and _webview_ready): return False js = f"if(typeof updateEvents==='function')updateEvents({json.dumps(events, ensure_ascii=True)})" _webview.run_javascript(js, None, None, None) return False # one-shot: do not repeat def _read_events_async() -> None: """Read events in background thread, update UI via idle_add.""" def _worker(): events = _read_recent_events(20) if events: GLib.idle_add(_update_events_ui, events) thread = threading.Thread(target=_worker, daemon=True) thread.start() # --------------------------------------------------------------------------- # Live data collection for WebKit visual view # --------------------------------------------------------------------------- def _collect_live_data() -> dict: """Collect live data from monitor.py + local team/task files. Returns a dict matching the JSON structure that updateSwarm() expects. """ cost_data = get_anthropic_session_cost() provider_costs = get_provider_costs() active_sessions = get_active_sessions() sidecar = get_sidecar_status() teams = _load_teams() # Cost bars (percentage relative to reasonable daily max) OPUS_MAX = 5.0 SONNET_MAX = 10.0 HAIKU_MAX = 2.0 GEMINI_MAX = 1.0 models = cost_data.get("models", {}) opus_cost = models.get("opus", {}).get("cost_usd", 0.0) sonnet_cost = models.get("sonnet", {}).get("cost_usd", 0.0) haiku_cost = models.get("haiku", {}).get("cost_usd", 0.0) gemini_cost = provider_costs.get("gemini", 0.0) # Node statuses active_task_count = sum(t["active_tasks"] for t in teams) sidecar_firing = any( d.get("active") for d in sidecar.get("detectors", {}).values() ) # Build tasks list from all teams all_tasks = [] for team in teams: for task in _load_tasks(team["dir_name"]): task["team"] = team["dir_name"] all_tasks.append(task) return { "agents": { "opus": { "status": "working" if any( s.get("model", "").startswith("claude-opus") for s in active_sessions ) else "idle", "cost_usd": opus_cost, "cost_pct": min(100, round(opus_cost / OPUS_MAX * 100, 1)), }, "sonnet": { "status": "working" if any( s.get("model", "").startswith("claude-sonnet") for s in active_sessions ) else "idle", "cost_usd": sonnet_cost, "cost_pct": min(100, round(sonnet_cost / SONNET_MAX * 100, 1)), }, "haiku": { "status": "working" if any( s.get("model", "").startswith("claude-haiku") for s in active_sessions ) else "idle", "cost_usd": haiku_cost, "cost_pct": min(100, round(haiku_cost / HAIKU_MAX * 100, 1)), }, }, "gemini": { "status": "working" if gemini_cost > 0 else "idle", "cost_usd": gemini_cost, "cost_pct": min(100, round(gemini_cost / GEMINI_MAX * 100, 1)), }, "sidecar": { "status": "working" if sidecar_firing else "idle", "overall_severity": sidecar.get("overall_severity", "none"), "active_detectors": [ name for name, d in sidecar.get("detectors", {}).items() if d.get("active") ], "hook_events": sidecar.get("hook_events", 0), }, "teams": [ { "name": t["name"], "dir_name": t["dir_name"], "members": t["member_names"], "status": "working" if t["active_tasks"] > 0 else "idle", "tasks_total": t["tasks"], "tasks_active": t["active_tasks"], } for t in teams ], "tasks": [ { "id": task["id"], "subject": task["subject"], "status": task["status"], "owner": task.get("owner", ""), "team": task.get("team", ""), } for task in all_tasks ], "total_cost_usd": cost_data.get("cost_usd", 0.0), "active_agent_count": len(active_sessions), "events": [], } # --------------------------------------------------------------------------- # UI builders # --------------------------------------------------------------------------- def _build_team_card(team: dict) -> Gtk.Box: """Build a single team card widget.""" card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) card.get_style_context().add_class("sc-card") card.set_size_request(180, -1) # Team name name_label = Gtk.Label(label=team["name"]) name_label.set_halign(Gtk.Align.START) name_label.get_style_context().add_class("session-project") card.pack_start(name_label, False, False, 0) # Stats line stats = f"{team['members']} Agents | {team['tasks']} Tasks" stats_label = Gtk.Label(label=stats) stats_label.set_halign(Gtk.Align.START) stats_label.get_style_context().add_class("session-meta") card.pack_start(stats_label, False, False, 0) # Active indicator if team["active"]: dot = Gtk.Label(label=f"\u25cf Aktiv ({team['active_tasks']} laufen)") dot.get_style_context().add_class("swarm-status-connected") else: dot = Gtk.Label(label="\u25cb Idle") dot.get_style_context().add_class("swarm-status-disconnected") dot.set_halign(Gtk.Align.START) card.pack_start(dot, False, False, 0) return card def _build_task_row(task: dict) -> Gtk.ListBoxRow: """Build a single task row.""" row = Gtk.ListBoxRow() row.get_style_context().add_class("swarm-task-row") hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) hbox.set_margin_top(4) hbox.set_margin_bottom(4) hbox.set_margin_start(8) hbox.set_margin_end(8) row.add(hbox) # Status icon status = task["status"] if status == "in_progress": icon_text = "\u25cf" badge_class = "swarm-badge-progress" elif status == "completed": icon_text = "\u2713" badge_class = "swarm-badge-completed" else: icon_text = "\u25cb" badge_class = "swarm-badge-pending" icon = Gtk.Label(label=icon_text) icon.get_style_context().add_class(badge_class) icon.set_size_request(20, -1) hbox.pack_start(icon, False, False, 0) # Task subject subject = Gtk.Label(label=task["subject"]) subject.set_halign(Gtk.Align.START) subject.set_ellipsize(Pango.EllipsizeMode.END) subject.set_max_width_chars(50) subject.set_hexpand(True) subject.get_style_context().add_class("session-preview") hbox.pack_start(subject, True, True, 0) # Status badge text status_text = status.upper().replace("_", " ") badge = Gtk.Label(label=status_text) badge.get_style_context().add_class(badge_class) badge.set_halign(Gtk.Align.END) hbox.pack_start(badge, False, False, 0) row.show_all() return row def _build_message_row(msg: dict) -> Gtk.ListBoxRow: """Build a single message row.""" row = Gtk.ListBoxRow() row.get_style_context().add_class("swarm-message") hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) hbox.set_margin_top(2) hbox.set_margin_bottom(2) hbox.set_margin_start(8) hbox.set_margin_end(8) row.add(hbox) # Timestamp time_label = Gtk.Label(label=msg["time"]) time_label.get_style_context().add_class("log-time") time_label.set_size_request(40, -1) hbox.pack_start(time_label, False, False, 0) # Sender -> Recipient route = Gtk.Label(label=f"{msg['sender']} \u2192 {msg['recipient']}") route.get_style_context().add_class("session-meta") route.set_size_request(160, -1) route.set_halign(Gtk.Align.START) route.set_ellipsize(Pango.EllipsizeMode.END) hbox.pack_start(route, False, False, 0) # Preview preview = Gtk.Label(label=msg["preview"]) preview.set_halign(Gtk.Align.START) preview.set_ellipsize(Pango.EllipsizeMode.END) preview.set_hexpand(True) preview.get_style_context().add_class("session-preview") hbox.pack_start(preview, True, True, 0) row.show_all() return row # --------------------------------------------------------------------------- # Refresh logic # --------------------------------------------------------------------------- def _refresh_content() -> None: """Refresh all swarm content based on selected team.""" global _selected_team, _refreshing if _refreshing: return _refreshing = True try: _refresh_content_inner() finally: _refreshing = False def _refresh_content_inner() -> None: """Actual refresh logic — guarded by _refreshing flag.""" global _selected_team teams = _load_teams() # Update status if _status_label: total = len(teams) active = sum(1 for t in teams if t["active"]) _status_label.set_text(f"{total} Teams, {active} aktiv") # Update team cards if _team_flow: for child in _team_flow.get_children(): _team_flow.remove(child) for team in teams: card = _build_team_card(team) flow_child = Gtk.FlowBoxChild() flow_child.add(card) flow_child.show_all() _team_flow.add(flow_child) _team_flow.show_all() # Update selector if _team_selector: current = _team_selector.get_active_text() _team_selector.remove_all() _team_selector.append_text("Alle Teams") for team in teams: _team_selector.append_text(team["dir_name"]) # Restore selection if current: model = _team_selector.get_model() for i, row in enumerate(model): if row[0] == current: _team_selector.set_active(i) break else: _team_selector.set_active(0) else: _team_selector.set_active(0) active_text = _team_selector.get_active_text() if _team_selector else None if active_text and active_text != "Alle Teams": _selected_team = active_text else: _selected_team = None # Update tasks if _task_list: for child in _task_list.get_children(): _task_list.remove(child) if _selected_team: tasks = _load_tasks(_selected_team) else: # All teams tasks = [] for team in teams: for t in _load_tasks(team["dir_name"]): t["_team"] = team["dir_name"] tasks.append(t) order = {"in_progress": 0, "pending": 1, "completed": 2} tasks.sort(key=lambda t: order.get(t["status"], 9)) for task in tasks: _task_list.add(_build_task_row(task)) _task_list.show_all() # Update messages if _msg_list: for child in _msg_list.get_children(): _msg_list.remove(child) if _selected_team: messages = _load_messages(_selected_team) else: messages = [] for team in teams: messages.extend(_load_messages(team["dir_name"], limit=5)) messages.sort(key=lambda m: m["sort_key"], reverse=True) messages = messages[:10] for msg in messages: _msg_list.add(_build_message_row(msg)) _msg_list.show_all() # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- def _update_visual_for_team() -> None: """Load comm graph HTML for the selected team, or fallback HTML for all.""" if not _stack or _stack.get_visible_child_name() != "visual" or not _webview: return active_text = _team_selector.get_active_text() if _team_selector else None if active_text and active_text != "Alle Teams": html = generate_comm_graph(active_text) _webview.load_html(html, None) elif SWARM_HTML.exists(): _webview.load_uri(SWARM_HTML.as_uri()) def _on_load_changed(webview, event): """Track when WebKit page has finished loading.""" global _webview_ready if event == WebKit2.LoadEvent.FINISHED: _webview_ready = True def _build_visual_view() -> Gtk.Widget: """Build the WebKit visual view loading agent-swarm-live.html.""" global _webview _webview = WebKit2.WebView() settings = _webview.get_settings() settings.set_enable_javascript(True) settings.set_enable_developer_extras(False) settings.set_hardware_acceleration_policy( WebKit2.HardwareAccelerationPolicy.NEVER ) _webview.connect("load-changed", _on_load_changed) if SWARM_HTML.exists(): _webview.load_uri(SWARM_HTML.as_uri()) else: p = get_palette() _webview.load_html( f'
' f'