From 63cff3d84809ae68bd3ebcca77f5d8764bfee195 Mon Sep 17 00:00:00 2001 From: tjb-tech <1193992557@qq.com> Date: Wed, 18 Mar 2026 06:09:24 +0000 Subject: [PATCH 1/4] Add gource and ACPX context integration from backup Includes Gource visualization, ACPX spawn backend/transport, and git context layer (conflicts, context). Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 97 ++++------ clawteam/board/collector.py | 18 ++ clawteam/board/gource.py | 302 ++++++++++++++++++++++++++++++++ clawteam/board/renderer.py | 37 ++++ clawteam/cli/commands.py | 262 ++++++++++++++++++++++++++- clawteam/config.py | 15 +- clawteam/spawn/__init__.py | 8 +- clawteam/spawn/acpx_backend.py | 228 ++++++++++++++++++++++++ clawteam/spawn/prompt.py | 35 +++- clawteam/spawn/registry.py | 2 +- clawteam/spawn/tmux_backend.py | 2 + clawteam/team/mailbox.py | 3 + clawteam/transport/__init__.py | 4 + clawteam/transport/acpx.py | 155 ++++++++++++++++ clawteam/workspace/conflicts.py | 271 ++++++++++++++++++++++++++++ clawteam/workspace/context.py | 286 ++++++++++++++++++++++++++++++ 16 files changed, 1653 insertions(+), 72 deletions(-) create mode 100644 clawteam/board/gource.py create mode 100644 clawteam/spawn/acpx_backend.py create mode 100644 clawteam/transport/acpx.py create mode 100644 clawteam/workspace/conflicts.py create mode 100644 clawteam/workspace/context.py diff --git a/README.md b/README.md index 255759a1..7a2d7406 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,9 @@ WeChat

-**One Command Line: Full Automation.** — agents spawn swarms, delegate tasks, and deliver results. +**One Command Line: Set Your Goal** — agents spawn swarms, delegate tasks, and deliver results automatically. -Human provides the goal. The Agent Team orchestrates everything else. - -Full compatibility with [Claude Code](https://claude.ai/claude-code), [Codex](https://openai.com/codex), [OpenClaw](https://github.com/nicepkg/OpenClaw), [nanobot](https://github.com/AbanteAI/nanobot), [Cursor](https://cursor.com), and any CLI agent.  [**中文文档**](README_CN.md) | [**한국어**](README_KR.md) +Full compatibility with [Claude Code](https://claude.ai/claude-code), [Codex](https://openai.com/codex), [OpenClaw](https://github.com/nicepkg/OpenClaw), [nanobot](https://github.com/AbanteAI/nanobot), [Cursor](https://cursor.com), and any CLI agent.  [**中文文档**](README_CN.md)

ClawTeam - AI agents orchestrating themselves @@ -47,13 +45,13 @@ Full compatibility with [Claude Code](https://claude.ai/claude-code), [Codex](ht -

• Large-Scale Automated ML Experimentation

+

Large-Scale Automated ML Experimentation

-

• AI Model Training & Optimization

+

AI Model Training & Optimization

-

• AI-Driven Hypothesis Generation & Validation

+

AI-Driven Hypothesis Generation & Validation

-

• Self-Improving Model Architectures

+

Self-Improving Model Architectures

@@ -66,13 +64,9 @@ Full compatibility with [Claude Code](https://claude.ai/claude-code), [Codex](ht -

• Autonomous Full-Stack Development

- -

• Self-Evolving Software

- -

• Collaborative Open Source Development

+

Parallel Software Development

-

• Real-Time System Integration

+

Agents split work into API, backend, frontend, tests — each on its own git branch, auto-merging on completion

@@ -85,13 +79,9 @@ Full compatibility with [Claude Code](https://claude.ai/claude-code), [Codex](ht -

• Automated Market Research & Data Mining

+

Multi-Analyst Signal Fusion

-

• Multi-Strategy Portfolio Optimization

- -

• Real-Time Risk Assessment

- -

• Algorithmic Trading Execution & Monitoring

+

7 analyst agents (value, growth, technical, fundamentals, sentiment) + risk manager converge on investment decisions

@@ -104,13 +94,9 @@ Full compatibility with [Claude Code](https://claude.ai/claude-code), [Codex](ht -

• Custom Scientific Research Teams

- -

• Personalized Investment Committees

+

One-Command Team Launch

-

• Business Operations Teams

- -

• Content Production Studios

+

Define any team archetype as a TOML template — roles, tasks, prompts — and launch with clawteam launch

@@ -120,32 +106,25 @@ Full compatibility with [Claude Code](https://claude.ai/claude-code), [Codex](ht https://github.com/user-attachments/assets/f6f0b220-9a5e-4d0a-a25d-f80753d3639b -☝️ Intelligent leader agent orchestrates 8 specialized sub-agents across 8 H100 GPUs, autonomously designing experiments and dynamically reallocating resources based on real-time performance. - -🧠 The system synthesizes breakthroughs across teams and evolves strategies independently — achieving full research automation without human intervention. +*☝️ A leader Claude agent spawns 8 sub-agents across 8 H100 GPUs, assigns experiment directions, monitors progress, cross-pollinates findings, and redirects unproductive agents — fully autonomously.* --- ## 🤔 Why ClawTeam? -Current AI agents are powerful — but they work in **isolation**. When facing complex tasks, you're stuck manually coordinating multiple agents, juggling context, and stitching together fragmented results. - -**What if agents could think and work as a team?** - -ClawTeam unlocks **Agent Swarm Intelligence** — where AI agents self-organize into collaborative teams, intelligently divide complex work, share insights in real-time, and converge on breakthrough solutions. - -• **🚀 Spawns specialized sub-agents** — each with dedicated environments and focus areas +AI coding agents are powerful — but they work **alone**. When a task is too big for one agent, you're stuck manually splitting work, copy-pasting context, and merging results. -• **📋 Designs intelligent task allocation** — with smart dependency management +**What if agents could swarm?** -• **💬 Facilitates real-time coordination** — seamless inter-agent communication +ClawTeam enables **Agent Swarm Intelligence** — agents that self-organize into teams, divide work, share discoveries, and converge on solutions. A leader agent can: -• **📊 Monitors team performance** — tracks progress and identifies bottlenecks +- 🚀 **Spawn sub-agents** — each in its own git worktree and tmux session +- 📋 **Assign tasks** — with dependency chains that auto-unblock +- 💬 **Send messages** — direct instructions to any sub-agent +- 📊 **Monitor progress** — check the kanban board, read results +- 🔄 **Redirect work** — kill unproductive agents, reassign with new directions -• **🔄 Adapts strategies dynamically** — reallocates resources and redirects efforts - -#### ✨ The Result?** -You set the vision. The swarm executes with collective intelligence. +The human just provides the initial goal. **The swarm handles the rest.**

How ClawTeam works - comic @@ -215,21 +194,7 @@ clawteam board serve --port 8080 ### 🔬 1. Autonomous ML Research — 8 Agents × 8 H100 GPUs -Based on [@karpathy's autoresearch](https://github.com/karpathy/autoresearch). - -#### 💫 One Command. Full Automation. - -#### Human input: "Optimize this LLM training setup using 8 GPUs" - -The Agent Team handles everything else: -- Spawns 8 specialized research agents across H100s -- Designs 2000+ autonomous experiments -- Achieves breakthrough improvements (val_bpb: 1.044→0.977) -- Zero human intervention required - -#### 🎯 Pure Research at Scale - -Transform months of manual hyperparameter tuning into hours of intelligent automation. +Based on [@karpathy's autoresearch](https://github.com/karpathy/autoresearch). The human tells a leader agent: *"Optimize this LLM training setup using 8 GPUs."* **The leader does everything else.**

AutoResearch Progress @@ -237,7 +202,7 @@ Transform months of manual hyperparameter tuning into hours of intelligent autom 🏆 val_bpb: 1.044 → 0.977 (6.4% improvement) | 2430+ experiments | ~30 GPU-hours

-**What agent team did autonomously:** +**What the leader agent did autonomously:** ``` Human prompt: "Use 8 GPUs to optimize train.py. Read program.md for instructions." @@ -648,7 +613,19 @@ We welcome contributions! ClawTeam is designed to be extensible: ## ⭐ Star History -If you find ClawTeam helpful, please consider to give us a star! ⭐ +If ClawTeam helps your AI agents work in teams, give us a star! ⭐ + + + +--- ## 📄 License diff --git a/clawteam/board/collector.py b/clawteam/board/collector.py index 3af4bb4b..9400e91b 100644 --- a/clawteam/board/collector.py +++ b/clawteam/board/collector.py @@ -92,6 +92,23 @@ def collect_team(self, team_name: str) -> dict: except Exception: pass + # Conflict/overlap data + conflict_data = {} + try: + from clawteam.workspace.conflicts import detect_overlaps + overlaps = detect_overlaps(team_name) + conflict_data = { + "overlaps": [ + {"file": o["file"], "agents": o["agents"], "severity": o["severity"]} + for o in overlaps + ], + "totalOverlaps": len(overlaps), + "highSeverity": sum(1 for o in overlaps if o["severity"] == "high"), + "mediumSeverity": sum(1 for o in overlaps if o["severity"] == "medium"), + } + except Exception: + pass + return { "team": { "name": config.name, @@ -106,6 +123,7 @@ def collect_team(self, team_name: str) -> dict: "taskSummary": summary, "messages": all_messages, "cost": cost_data, + "conflicts": conflict_data, } def collect_overview(self) -> list[dict]: diff --git a/clawteam/board/gource.py b/clawteam/board/gource.py new file mode 100644 index 00000000..870c54cb --- /dev/null +++ b/clawteam/board/gource.py @@ -0,0 +1,302 @@ +"""Gource visualization integration for ClawTeam. + +Generates Gource custom log format from ClawTeam events and git history, +and launches Gource visualizations of team activity. + +Gource custom log format: timestamp|username|type|path + - timestamp: unix timestamp + - username: agent name + - type: A (add), M (modify), D (delete) + - path: virtual file path representing the event +""" + +from __future__ import annotations + +import subprocess +import shutil +from datetime import datetime, timezone +from pathlib import Path + +from clawteam.board.collector import BoardCollector + + +# --------------------------------------------------------------------------- +# Color mapping for agents +# --------------------------------------------------------------------------- + +# Gource user colors (hex without #) +AGENT_COLORS = [ + "00FF00", # green + "FF6600", # orange + "00CCFF", # cyan + "FF00FF", # magenta + "FFFF00", # yellow + "FF3333", # red + "66FF66", # light green + "9966FF", # purple + "FF9999", # pink + "33FFCC", # teal +] + + +def _agent_color(index: int) -> str: + return AGENT_COLORS[index % len(AGENT_COLORS)] + + +# --------------------------------------------------------------------------- +# ClawTeam event log → Gource custom log +# --------------------------------------------------------------------------- + +def _parse_iso(ts: str) -> int: + """Parse ISO timestamp string to unix timestamp.""" + try: + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) + return int(dt.timestamp()) + except Exception: + return int(datetime.now(timezone.utc).timestamp()) + + +def generate_event_log(team_name: str) -> list[str]: + """Generate Gource custom log lines from ClawTeam events. + + Maps ClawTeam events to virtual paths: + - Task status changes → /tasks/{status}/{task_subject} + - Messages → /messages/{from_agent}/{to} + - Member joins → /team/{agent_name} + + Returns sorted list of 'timestamp|username|type|path' strings. + """ + collector = BoardCollector() + try: + data = collector.collect_team(team_name) + except ValueError: + return [] + + lines: list[str] = [] + + # Member joins as additions + for member in data.get("members", []): + name = member["name"] + joined = member.get("joinedAt", "") + if joined: + ts = _parse_iso(joined) + lines.append(f"{ts}|{name}|A|/team/{name}") + + # Tasks as file operations + for status, tasks in data.get("tasks", {}).items(): + for task in tasks: + owner = task.get("owner", "system") + subject = task.get("subject", "untitled").replace("/", "_") + task_id = task.get("id", "unknown") + updated = task.get("updatedAt", task.get("createdAt", "")) + created = task.get("createdAt", "") + + if created: + ts = _parse_iso(created) + creator = owner or "system" + lines.append(f"{ts}|{creator}|A|/tasks/pending/{task_id}_{subject}") + + if updated and status != "pending": + ts = _parse_iso(updated) + gource_type = "M" if status in ("in_progress", "blocked") else "A" + agent = owner or "system" + lines.append(f"{ts}|{agent}|{gource_type}|/tasks/{status}/{task_id}_{subject}") + + # Messages as modifications + for msg in data.get("messages", []): + from_agent = msg.get("fromAgent", "unknown") + to = msg.get("to", "broadcast") + ts_str = msg.get("timestamp", "") + msg_type = msg.get("type", "message") + if ts_str: + ts = _parse_iso(ts_str) + lines.append(f"{ts}|{from_agent}|M|/messages/{from_agent}/{to}/{msg_type}") + + # Sort by timestamp + lines.sort(key=lambda l: int(l.split("|")[0])) + return lines + + +# --------------------------------------------------------------------------- +# Git log → Gource log (via context layer) +# --------------------------------------------------------------------------- + +def generate_git_log(team_name: str, repo_path: str | None = None) -> list[str]: + """Combine git logs from all agent branches into unified Gource log. + + Uses the context layer's cross_branch_log() and file_owners() instead + of reading git logs directly, making Gource a view on top of context. + + Each agent's file paths are prefixed with their agent name to show + parallel work in different areas of the visualization tree. + """ + try: + from clawteam.workspace.context import cross_branch_log, file_owners + except ImportError: + return [] + + try: + entries = cross_branch_log(team_name, limit=500, repo=repo_path) + except Exception: + return [] + + lines: list[str] = [] + for entry in entries: + agent = entry.get("agent", "unknown") + ts_str = entry.get("timestamp", "") + ts = _parse_iso(ts_str) + for fpath in entry.get("files", []): + # Classify as M (modify) by default; context layer doesn't + # distinguish A/M/D per-file, so use "M" for all. + lines.append(f"{ts}|{agent}|M|/{agent}/{fpath}") + + # Enrich with file-owner coloring: mark multi-owner files + try: + owners = file_owners(team_name, repo=repo_path) + now_ts = int(datetime.now(timezone.utc).timestamp()) + for fname, agents in owners.items(): + if len(agents) > 1: + # Add a synthetic entry so Gource shows shared files + for agent in agents: + lines.append(f"{now_ts}|{agent}|M|/shared/{fname}") + except Exception: + pass + + # Sort by timestamp + lines.sort(key=lambda l: int(l.split("|")[0])) + return lines + + +def generate_combined_log(team_name: str, repo_path: str | None = None) -> list[str]: + """Combine both ClawTeam event log and git history into one Gource log.""" + events = generate_event_log(team_name) + git_lines = generate_git_log(team_name, repo_path) + combined = events + git_lines + combined.sort(key=lambda l: int(l.split("|")[0])) + return combined + + +# --------------------------------------------------------------------------- +# Gource user color config generation +# --------------------------------------------------------------------------- + +def generate_user_colors(team_name: str) -> str: + """Generate Gource --user-image-dir compatible color config. + + Returns content for a user colors file mapping agent names to colors. + Format: username=color (one per line). + """ + collector = BoardCollector() + try: + data = collector.collect_team(team_name) + except ValueError: + return "" + + lines: list[str] = [] + for i, member in enumerate(data.get("members", [])): + name = member["name"] + color = _agent_color(i) + lines.append(f"{name}={color}") + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Launch Gource +# --------------------------------------------------------------------------- + +def find_gource() -> str | None: + """Find gource binary. Returns path or None.""" + from clawteam.config import load_config + cfg = load_config() + custom_path = getattr(cfg, "gource_path", "") + if custom_path and Path(custom_path).is_file(): + return custom_path + return shutil.which("gource") + + +def launch_gource( + log_file: Path, + title: str = "", + resolution: str = "", + seconds_per_day: float = 0, + extra_args: list[str] | None = None, + export_path: str | None = None, +) -> subprocess.Popen | None: + """Launch Gource with the given custom log file. + + If export_path is provided, pipes through FFmpeg to produce an MP4. + Returns the Popen object, or None if gource is not found. + """ + gource_bin = find_gource() + if not gource_bin: + return None + + # Load config defaults + from clawteam.config import load_config + cfg = load_config() + if not resolution: + resolution = getattr(cfg, "gource_resolution", "1280x720") + if not seconds_per_day: + seconds_per_day = getattr(cfg, "gource_seconds_per_day", 0.5) + + cmd = [ + gource_bin, + str(log_file), + "--log-format", "custom", + "--seconds-per-day", str(seconds_per_day), + "--auto-skip-seconds", "0.5", + "--file-idle-time", "0", + "--max-files", "0", + "--highlight-users", + "--multi-sampling", + ] + + if resolution: + parts = resolution.split("x") + if len(parts) == 2: + cmd.extend(["--viewport", f"{parts[0]}x{parts[1]}"]) + + if title: + cmd.extend(["--title", title]) + + if extra_args: + cmd.extend(extra_args) + + if export_path: + # Pipe PPM stream to FFmpeg for video export + ffmpeg_bin = shutil.which("ffmpeg") + if not ffmpeg_bin: + return None + + cmd.extend(["--output-ppm-stream", "-"]) + + gource_proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + ) + + ffmpeg_cmd = [ + ffmpeg_bin, + "-y", # overwrite + "-r", "60", + "-f", "image2pipe", + "-vcodec", "ppm", + "-i", "-", + "-vcodec", "libx264", + "-preset", "medium", + "-pix_fmt", "yuv420p", + "-crf", "18", + export_path, + ] + + ffmpeg_proc = subprocess.Popen( + ffmpeg_cmd, + stdin=gource_proc.stdout, + ) + # Allow gource_proc to receive SIGPIPE if ffmpeg exits + if gource_proc.stdout: + gource_proc.stdout.close() + return ffmpeg_proc + else: + return subprocess.Popen(cmd) diff --git a/clawteam/board/renderer.py b/clawteam/board/renderer.py index c6a2c0ac..9150497e 100644 --- a/clawteam/board/renderer.py +++ b/clawteam/board/renderer.py @@ -49,6 +49,8 @@ def render_overview(self, teams: list[dict]) -> None: def render_team_board_live(self, collector, team_name: str, interval: float = 2.0) -> None: """Render a live-refreshing team board. Ctrl+C to stop.""" running = True + notify_counter = 0 + NOTIFY_INTERVAL = 5 # auto_notify every N refresh cycles def _handle_signal(signum, frame): nonlocal running @@ -70,6 +72,19 @@ def _handle_signal(signum, frame): live.update(renderable) break live.update(renderable) + + # Periodically run conflict auto-notification + notify_counter += 1 + if notify_counter >= NOTIFY_INTERVAL: + notify_counter = 0 + try: + from clawteam.team.mailbox import MailboxManager + from clawteam.workspace.conflicts import auto_notify + mailbox = MailboxManager(team_name) + auto_notify(team_name, mailbox) + except Exception: + pass + time.sleep(interval) finally: signal.signal(signal.SIGINT, old_sigint) @@ -132,8 +147,30 @@ def _build_team_board(self, data: dict) -> Group: # 3. Task board (4-column kanban) parts.append(self._build_task_kanban(tasks, summary)) + # 4. Conflict warnings (if any) + conflicts = data.get("conflicts", {}) + if conflicts.get("totalOverlaps", 0) > 0: + parts.append(self._build_conflict_panel(conflicts)) + return Group(*parts) + def _build_conflict_panel(self, conflicts: dict) -> Panel: + """Build a panel showing file overlap / conflict warnings.""" + overlaps = conflicts.get("overlaps", []) + high = conflicts.get("highSeverity", 0) + medium = conflicts.get("mediumSeverity", 0) + + lines: list[str] = [] + for o in overlaps: + severity = o["severity"] + style = "red bold" if severity == "high" else "yellow" + agents = ", ".join(o["agents"]) + lines.append(f"[{style}]{severity.upper()}[/{style}] `{o['file']}` — {agents}") + + body = "\n".join(lines) if lines else "[dim](none)[/dim]" + title = f"Conflict Warnings ({high} high, {medium} medium)" + return Panel(body, title=title, border_style="red" if high > 0 else "yellow") + def _build_task_kanban(self, tasks: dict, summary: dict) -> Panel: """Build the 4-column kanban task board.""" columns_cfg = [ diff --git a/clawteam/cli/commands.py b/clawteam/cli/commands.py index 83666f52..2e613d03 100644 --- a/clawteam/cli/commands.py +++ b/clawteam/cli/commands.py @@ -50,7 +50,7 @@ def main( None, "--data-dir", help="Override data directory (default: ~/.clawteam).", ), transport: Optional[str] = typer.Option( - None, "--transport", help="Transport backend: file or p2p.", + None, "--transport", help="Transport backend: file, p2p, or acpx.", ), ): """clawteam - Framework-agnostic multi-agent coordination CLI.""" @@ -93,7 +93,9 @@ def config_show(): """Show all configuration settings and their sources.""" from clawteam.config import get_effective - keys = ["data_dir", "user", "default_team"] + keys = ["data_dir", "user", "default_team", "transport", "default_backend", + "gource_path", "gource_resolution", "gource_seconds_per_day", + "acpx_path", "acpx_default_format", "acpx_approve_mode"] data = {} for k in keys: val, source = get_effective(k) @@ -113,13 +115,15 @@ def _human(d): @config_app.command("set") def config_set( - key: str = typer.Argument(..., help="Config key: data_dir, user, default_team"), + key: str = typer.Argument(..., help="Config key"), value: str = typer.Argument(..., help="Config value"), ): """Persistently set a configuration value.""" from clawteam.config import load_config, save_config - valid_keys = {"data_dir", "user", "default_team"} + valid_keys = {"data_dir", "user", "default_team", "transport", "default_backend", + "gource_path", "gource_resolution", "gource_seconds_per_day", + "acpx_path", "acpx_default_format", "acpx_approve_mode"} if key not in valid_keys: console.print(f"[red]Invalid key '{key}'. Valid: {', '.join(sorted(valid_keys))}[/red]") raise typer.Exit(1) @@ -136,12 +140,14 @@ def config_set( @config_app.command("get") def config_get( - key: str = typer.Argument(..., help="Config key: data_dir, user, default_team"), + key: str = typer.Argument(..., help="Config key"), ): """Get the effective value of a config key.""" from clawteam.config import get_effective - valid_keys = {"data_dir", "user", "default_team"} + valid_keys = {"data_dir", "user", "default_team", "transport", "default_backend", + "gource_path", "gource_resolution", "gource_seconds_per_day", + "acpx_path", "acpx_default_format", "acpx_approve_mode"} if key not in valid_keys: console.print(f"[red]Invalid key '{key}'. Valid: {', '.join(sorted(valid_keys))}[/red]") raise typer.Exit(1) @@ -1527,7 +1533,7 @@ def lifecycle_on_exit( @app.command("spawn") def spawn_agent( - backend: Optional[str] = typer.Argument(None, help="Backend: tmux (default) or subprocess"), + backend: Optional[str] = typer.Argument(None, help="Backend: tmux (default), subprocess, or acpx"), command: list[str] = typer.Argument(None, help="Command and arguments to run (default: claude)"), team: Optional[str] = typer.Option(None, "--team", "-t", help="Team name"), agent_name: Optional[str] = typer.Option(None, "--agent-name", "-n", help="Agent name"), @@ -1541,6 +1547,11 @@ def spawn_agent( """Spawn a new agent process with identity + task as its initial prompt. Defaults: tmux backend, claude command, git worktree isolation, skip-permissions on. + + Backends: + tmux - Launch in tmux windows (visual monitoring) + subprocess - Launch as background processes + acpx - Launch via ACPX Agent Client Protocol (multi-provider) """ from clawteam.config import get_effective from clawteam.spawn import get_backend @@ -1610,6 +1621,7 @@ def spawn_agent( user=_os.environ.get("CLAWTEAM_USER", ""), workspace_dir=cwd or "", workspace_branch=ws_branch, + repo_path=repo, ) # Session resume: inject --resume flag for claude commands @@ -1821,6 +1833,94 @@ def board_attach( console.print(f"[green]OK[/green] {result}") +@board_app.command("gource") +def board_gource( + team: str = typer.Argument(..., help="Team name"), + export: Optional[str] = typer.Option(None, "--export", help="Export video to file (requires FFmpeg)"), + log_only: bool = typer.Option(False, "--log-only", help="Output Gource custom log to stdout without launching"), + combine_worktrees: bool = typer.Option(True, "--combine-worktrees/--events-only", help="Combine git worktree logs with event log"), + repo: Optional[str] = typer.Option(None, "--repo", help="Git repo path for worktree discovery"), + resolution: Optional[str] = typer.Option(None, "--resolution", "-r", help="Viewport resolution (e.g. 1920x1080)"), + seconds_per_day: Optional[float] = typer.Option(None, "--speed", "-s", help="Seconds per day (lower = faster)"), +): + """Launch Gource visualization of team activity. + + Visualizes ClawTeam events (task changes, messages, agent joins) and + optionally combines git history from all agent worktrees into a unified + Gource animation showing parallel collaboration. + """ + from clawteam.board.gource import ( + generate_combined_log, + generate_event_log, + generate_git_log, + find_gource, + launch_gource, + ) + import tempfile + + # Generate log lines + if combine_worktrees: + lines = generate_combined_log(team, repo) + else: + lines = generate_event_log(team) + + if not lines: + _output( + {"error": f"No activity found for team '{team}'"}, + lambda d: console.print(f"[yellow]{d['error']}[/yellow]"), + ) + raise typer.Exit(1) + + # --log-only: just print the custom log + if log_only: + for line in lines: + print(line) + return + + # Check gource is available + gource_bin = find_gource() + if not gource_bin: + _output( + {"error": "Gource not found. Install it (https://gource.io/) or set gource_path in config."}, + lambda d: console.print(f"[red]{d['error']}[/red]"), + ) + raise typer.Exit(1) + + # Write log to temp file + with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False, prefix="clawteam-gource-") as f: + f.write("\n".join(lines) + "\n") + log_path = Path(f.name) + + try: + title = f"ClawTeam: {team}" + proc = launch_gource( + log_file=log_path, + title=title, + resolution=resolution or "", + seconds_per_day=seconds_per_day or 0, + export_path=export, + ) + if proc is None: + _output( + {"error": "Failed to launch Gource" + (" (FFmpeg required for export)" if export else "")}, + lambda d: console.print(f"[red]{d['error']}[/red]"), + ) + raise typer.Exit(1) + + if export: + console.print(f"Exporting Gource visualization to [cyan]{export}[/cyan]...") + proc.wait() + console.print(f"[green]OK[/green] Video saved to {export}") + else: + console.print(f"Gource launched for team [cyan]{team}[/cyan]. Close the window to exit.") + proc.wait() + finally: + try: + log_path.unlink() + except OSError: + pass + + # ============================================================================ # Workspace Commands # ============================================================================ @@ -1972,6 +2072,154 @@ def workspace_status( console.print(stat) +# ============================================================================ +# Context Commands (git context layer) +# ============================================================================ + +context_app = typer.Typer(help="Git context: diffs, file ownership, conflicts, cross-branch log") +app.add_typer(context_app, name="context") + + +@context_app.command("diff") +def context_diff( + team: str = typer.Argument(..., help="Team name"), + agent: str = typer.Argument(..., help="Agent name"), + repo: Optional[str] = typer.Option(None, "--repo", help="Git repo path"), +): + """Show diff statistics for an agent's branch vs. base.""" + from clawteam.workspace.context import agent_diff + + try: + data = agent_diff(team, agent, repo) + except Exception as e: + _output({"error": str(e)}, lambda d: console.print(f"[red]{d['error']}[/red]")) + raise typer.Exit(1) + + def _human(d): + console.print(f"[bold]{d['summary']}[/bold]") + if d["diff_stat"]: + console.print(d["diff_stat"]) + + _output(data, _human) + + +@context_app.command("files") +def context_files( + team: str = typer.Argument(..., help="Team name"), + repo: Optional[str] = typer.Option(None, "--repo", help="Git repo path"), +): + """Show file ownership map — which agents modify which files.""" + from clawteam.workspace.context import file_owners + + try: + data = file_owners(team, repo) + except Exception as e: + _output({"error": str(e)}, lambda d: console.print(f"[red]{d['error']}[/red]")) + raise typer.Exit(1) + + def _human(d): + if not d: + console.print("[dim]No modified files found.[/dim]") + return + table = Table(title=f"File Ownership — {team}") + table.add_column("File", style="cyan") + table.add_column("Agents") + for fname, agents in sorted(d.items()): + style = "bold red" if len(agents) > 1 else "" + table.add_row(fname, ", ".join(agents), style=style) + console.print(table) + + _output(data, _human) + + +@context_app.command("conflicts") +def context_conflicts( + team: str = typer.Argument(..., help="Team name"), + repo: Optional[str] = typer.Option(None, "--repo", help="Git repo path"), +): + """Detect file overlaps across agent branches.""" + from clawteam.workspace.conflicts import detect_overlaps + + try: + data = detect_overlaps(team, repo) + except Exception as e: + _output({"error": str(e)}, lambda d: console.print(f"[red]{d['error']}[/red]")) + raise typer.Exit(1) + + def _human(d): + if not d: + console.print("[green]No overlaps detected.[/green]") + return + table = Table(title=f"File Overlaps — {team}") + table.add_column("File", style="cyan") + table.add_column("Agents") + table.add_column("Severity") + severity_styles = {"high": "bold red", "medium": "yellow", "low": "dim"} + for item in d: + sev = item["severity"] + table.add_row( + item["file"], + ", ".join(item["agents"]), + f"[{severity_styles.get(sev, '')}]{sev}[/{severity_styles.get(sev, '')}]", + ) + console.print(table) + + _output(data, _human) + + +@context_app.command("log") +def context_log( + team: str = typer.Argument(..., help="Team name"), + limit: int = typer.Option(50, "--limit", "-n", help="Max entries"), + repo: Optional[str] = typer.Option(None, "--repo", help="Git repo path"), +): + """Unified cross-branch commit log for all agents.""" + from clawteam.workspace.context import cross_branch_log + + try: + data = cross_branch_log(team, limit=limit, repo=repo) + except Exception as e: + _output({"error": str(e)}, lambda d: console.print(f"[red]{d['error']}[/red]")) + raise typer.Exit(1) + + def _human(d): + if not d: + console.print("[dim]No commits found.[/dim]") + return + for entry in d: + ts = entry["timestamp"][:19] + console.print( + f"[dim]{ts}[/dim] [cyan]{entry['agent']}[/cyan] " + f"[yellow]{entry['hash'][:8]}[/yellow] {entry['message']}" + ) + if entry["files"]: + for f in entry["files"]: + console.print(f" {f}") + + _output(data, _human) + + +@context_app.command("inject") +def context_inject( + team: str = typer.Argument(..., help="Team name"), + agent: str = typer.Argument(..., help="Target agent name"), + repo: Optional[str] = typer.Option(None, "--repo", help="Git repo path"), +): + """Generate context block for injection into an agent's prompt.""" + from clawteam.workspace.context import inject_context + + try: + text = inject_context(team, agent, repo) + except Exception as e: + _output({"error": str(e)}, lambda d: console.print(f"[red]{d['error']}[/red]")) + raise typer.Exit(1) + + if _json_output: + _output({"context": text}, None) + else: + console.print(text) + + # ============================================================================ # Template Commands # ============================================================================ diff --git a/clawteam/config.py b/clawteam/config.py index dc51acc9..feb45de9 100644 --- a/clawteam/config.py +++ b/clawteam/config.py @@ -15,8 +15,15 @@ class ClawTeamConfig(BaseModel): default_team: str = "" transport: str = "" workspace: str = "auto" # "auto" | "always" | "never" | "" - default_backend: str = "tmux" # "tmux" | "subprocess" + default_backend: str = "tmux" # "tmux" | "subprocess" | "acpx" skip_permissions: bool = True # pass --dangerously-skip-permissions to claude + gource_path: str = "" # custom path to gource binary (auto-detected if empty) + gource_resolution: str = "1280x720" # default viewport resolution + gource_seconds_per_day: float = 0.5 # animation speed + # ACPX settings + acpx_path: str = "" # path to acpx binary (default: "acpx" from PATH) + acpx_default_format: str = "json" # "json" | "text" | "stream-json" + acpx_approve_mode: str = "" # "" | "approve-all" | "approve-reads" def config_path() -> Path: @@ -58,6 +65,12 @@ def get_effective(key: str) -> tuple[str, str]: "workspace": "CLAWTEAM_WORKSPACE", "default_backend": "CLAWTEAM_DEFAULT_BACKEND", "skip_permissions": "CLAWTEAM_SKIP_PERMISSIONS", + "gource_path": "CLAWTEAM_GOURCE_PATH", + "gource_resolution": "CLAWTEAM_GOURCE_RESOLUTION", + "gource_seconds_per_day": "CLAWTEAM_GOURCE_SECONDS_PER_DAY", + "acpx_path": "CLAWTEAM_ACPX_PATH", + "acpx_default_format": "CLAWTEAM_ACPX_DEFAULT_FORMAT", + "acpx_approve_mode": "CLAWTEAM_ACPX_APPROVE_MODE", } defaults = ClawTeamConfig() cfg = load_config() diff --git a/clawteam/spawn/__init__.py b/clawteam/spawn/__init__.py index d920fbbb..442baa87 100644 --- a/clawteam/spawn/__init__.py +++ b/clawteam/spawn/__init__.py @@ -13,8 +13,14 @@ def get_backend(name: str = "tmux") -> SpawnBackend: elif name == "tmux": from clawteam.spawn.tmux_backend import TmuxBackend return TmuxBackend() + elif name == "acpx": + from clawteam.spawn.acpx_backend import AcpxBackend + from clawteam.config import load_config + cfg = load_config() + acpx_path = getattr(cfg, "acpx_path", "") or "acpx" + return AcpxBackend(acpx_path=acpx_path) else: - raise ValueError(f"Unknown spawn backend: {name}. Available: subprocess, tmux") + raise ValueError(f"Unknown spawn backend: {name}. Available: subprocess, tmux, acpx") __all__ = ["SpawnBackend", "get_backend"] diff --git a/clawteam/spawn/acpx_backend.py b/clawteam/spawn/acpx_backend.py new file mode 100644 index 00000000..43a31b4b --- /dev/null +++ b/clawteam/spawn/acpx_backend.py @@ -0,0 +1,228 @@ +"""ACPX spawn backend - launches agents via Agent Client Protocol (acpx CLI).""" + +from __future__ import annotations + +import json +import os +import shlex +import shutil +import subprocess + +from clawteam.spawn.base import SpawnBackend + + +# ACPX-supported agent types and their acpx subcommand names +ACPX_AGENTS = frozenset({ + "pi", "codex", "claude", "gemini", "cursor", "copilot", "openclaw", +}) + + +class AcpxBackend(SpawnBackend): + """Spawn agents using acpx (Agent Client Protocol headless CLI). + + Instead of managing tmux sessions or raw subprocesses, this backend + delegates to ``acpx `` which communicates with + agents through their native ACP interface. + + Supports: + - Named sessions (``-s ``) for reconnect / resume + - JSON output format (``--format json``) for structured parsing + - Permission modes (``--approve-all``, ``--approve-reads``) + - Async prompt submission (``--no-wait``) + """ + + def __init__(self, acpx_path: str = "acpx"): + self._acpx = acpx_path + self._agents: dict[str, dict] = {} # agent_name -> spawn info + + # ------------------------------------------------------------------ + # SpawnBackend interface + # ------------------------------------------------------------------ + + def spawn( + self, + command: list[str], + agent_name: str, + agent_id: str, + agent_type: str, + team_name: str, + prompt: str | None = None, + env: dict[str, str] | None = None, + cwd: str | None = None, + skip_permissions: bool = False, + ) -> str: + if not shutil.which(self._acpx): + return ( + f"Error: '{self._acpx}' not found. " + "Install with: npm install -g acpx@latest" + ) + + # Determine the acpx agent type from the command + acpx_agent = _resolve_acpx_agent(command) + + # Build the acpx command + acpx_cmd = [self._acpx, acpx_agent] + + # Named session for reconnect support + session_name = f"clawteam-{team_name}-{agent_name}" + acpx_cmd.extend(["-s", session_name]) + + # JSON output for structured message parsing + acpx_cmd.extend(["--format", "json"]) + + # Permission modes + if skip_permissions: + acpx_cmd.append("--approve-all") + + # Async: run with --no-wait so the spawn returns immediately + acpx_cmd.append("--no-wait") + + # Append the prompt + if prompt: + acpx_cmd.append(prompt) + + # Prepare environment + spawn_env = os.environ.copy() + spawn_env.update({ + "CLAWTEAM_AGENT_ID": agent_id, + "CLAWTEAM_AGENT_NAME": agent_name, + "CLAWTEAM_AGENT_TYPE": agent_type, + "CLAWTEAM_TEAM_NAME": team_name, + "CLAWTEAM_AGENT_LEADER": "0", + }) + user = os.environ.get("CLAWTEAM_USER", "") + if user: + spawn_env["CLAWTEAM_USER"] = user + transport = os.environ.get("CLAWTEAM_TRANSPORT", "") + if transport: + spawn_env["CLAWTEAM_TRANSPORT"] = transport + if cwd: + spawn_env["CLAWTEAM_WORKSPACE_DIR"] = cwd + # Inject context awareness flags + spawn_env["CLAWTEAM_CONTEXT_ENABLED"] = "1" + if env: + spawn_env.update(env) + + # Wrap with on-exit hook + cmd_str = " ".join(shlex.quote(c) for c in acpx_cmd) + exit_hook = ( + f"clawteam lifecycle on-exit --team {shlex.quote(team_name)} " + f"--agent {shlex.quote(agent_name)}" + ) + shell_cmd = f"{cmd_str}; {exit_hook}" + + process = subprocess.Popen( + shell_cmd, + shell=True, + env=spawn_env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=cwd, + ) + + self._agents[agent_name] = { + "pid": process.pid, + "session": session_name, + "acpx_agent": acpx_agent, + "command": acpx_cmd, + } + + # Persist spawn info for liveness checking + from clawteam.spawn.registry import register_agent + + register_agent( + team_name=team_name, + agent_name=agent_name, + backend="acpx", + pid=process.pid, + command=acpx_cmd, + ) + + return ( + f"Agent '{agent_name}' spawned via acpx " + f"(agent={acpx_agent}, session={session_name}, pid={process.pid})" + ) + + def list_running(self) -> list[dict[str, str]]: + result = [] + for name, info in list(self._agents.items()): + pid = info.get("pid", 0) + if pid and _pid_alive(pid): + result.append({ + "name": name, + "pid": str(pid), + "session": info.get("session", ""), + "acpx_agent": info.get("acpx_agent", ""), + "backend": "acpx", + }) + else: + self._agents.pop(name, None) + return result + + # ------------------------------------------------------------------ + # ACPX session helpers + # ------------------------------------------------------------------ + + def send_prompt(self, session_name: str, prompt: str) -> str | None: + """Send a follow-up prompt to an existing ACPX session. + + Returns the JSON response or None on failure. + """ + cmd = [self._acpx, "send", "-s", session_name, "--format", "json", prompt] + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=300, + ) + if result.returncode == 0: + return result.stdout + except (subprocess.TimeoutExpired, OSError): + pass + return None + + def get_session_status(self, session_name: str) -> dict | None: + """Query ACPX session status. Returns parsed JSON or None.""" + cmd = [self._acpx, "status", "-s", session_name, "--format", "json"] + try: + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=10, + ) + if result.returncode == 0: + return json.loads(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError): + pass + return None + + @staticmethod + def is_available() -> bool: + """Check if acpx CLI is installed and reachable.""" + return shutil.which("acpx") is not None + + +def _resolve_acpx_agent(command: list[str]) -> str: + """Map a ClawTeam command list to an acpx agent type. + + If the command itself is an acpx-known agent (e.g. ["claude"]), + return it directly. Otherwise default to "claude". + """ + if not command: + return "claude" + cmd_base = command[0].rsplit("/", 1)[-1].lower() # basename, lowercase + if cmd_base in ACPX_AGENTS: + return cmd_base + # Check if command[0] is "acpx" and command[1] is the agent type + if cmd_base == "acpx" and len(command) > 1 and command[1] in ACPX_AGENTS: + return command[1] + return "claude" + + +def _pid_alive(pid: int) -> bool: + """Check if a process with the given PID is still running.""" + if pid <= 0: + return False + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True diff --git a/clawteam/spawn/prompt.py b/clawteam/spawn/prompt.py index 4ba7be21..0bea2c4c 100644 --- a/clawteam/spawn/prompt.py +++ b/clawteam/spawn/prompt.py @@ -1,4 +1,4 @@ -"""Agent prompt builder — identity + task only. +"""Agent prompt builder — identity + task + context awareness. Coordination knowledge (how to use clawteam CLI) is provided by the ClawTeam Skill, not duplicated here. @@ -7,6 +7,23 @@ from __future__ import annotations +def _build_context_block(team_name: str, agent_name: str, repo: str | None = None) -> str: + """Build a context awareness block from the workspace context layer. + + Includes recent changes from teammates, file overlap warnings, + and upstream dependency context. Returns empty string if context + layer is unavailable or no relevant context exists. + """ + try: + from clawteam.workspace.context import inject_context + ctx = inject_context(team_name, agent_name, repo) + if ctx and "No cross-agent context" not in ctx: + return ctx + except Exception: + pass + return "" + + def build_agent_prompt( agent_name: str, agent_id: str, @@ -17,8 +34,9 @@ def build_agent_prompt( user: str = "", workspace_dir: str = "", workspace_branch: str = "", + repo_path: str | None = None, ) -> str: - """Build agent prompt: identity + task + optional workspace info.""" + """Build agent prompt: identity + task + context + coordination.""" lines = [ "## Identity\n", f"- Name: {agent_name}", @@ -39,10 +57,23 @@ def build_agent_prompt( f"- Branch: {workspace_branch}", "- This is an isolated git worktree. Your changes do not affect the main branch.", ]) + lines.extend([ "", "## Task\n", task, + ]) + + # Inject cross-agent context awareness + context_block = _build_context_block(team_name, agent_name, repo_path) + if context_block: + lines.extend([ + "", + "## Context\n", + context_block, + ]) + + lines.extend([ "", "## Coordination Protocol\n", f"- Use `clawteam task list {team_name} --owner {agent_name}` to see your tasks.", diff --git a/clawteam/spawn/registry.py b/clawteam/spawn/registry.py index e937ca8d..f2530d5c 100644 --- a/clawteam/spawn/registry.py +++ b/clawteam/spawn/registry.py @@ -58,7 +58,7 @@ def is_agent_alive(team_name: str, agent_name: str) -> bool | None: if pid: return _pid_alive(pid) return alive - elif backend == "subprocess": + elif backend in ("subprocess", "acpx"): return _pid_alive(info.get("pid", 0)) return None diff --git a/clawteam/spawn/tmux_backend.py b/clawteam/spawn/tmux_backend.py index 493d283a..00e4c67b 100644 --- a/clawteam/spawn/tmux_backend.py +++ b/clawteam/spawn/tmux_backend.py @@ -54,6 +54,8 @@ def spawn( env_vars["CLAWTEAM_TRANSPORT"] = transport if cwd: env_vars["CLAWTEAM_WORKSPACE_DIR"] = cwd + # Inject context awareness flags + env_vars["CLAWTEAM_CONTEXT_ENABLED"] = "1" if env: env_vars.update(env) diff --git a/clawteam/team/mailbox.py b/clawteam/team/mailbox.py index f4eded5c..1d3ebb21 100644 --- a/clawteam/team/mailbox.py +++ b/clawteam/team/mailbox.py @@ -24,6 +24,9 @@ def _default_transport(team_name: str) -> Transport: agent = AgentIdentity.from_env().agent_name from clawteam.transport import get_transport return get_transport("p2p", team_name=team_name, bind_agent=agent) + if name == "acpx": + from clawteam.transport import get_transport + return get_transport("acpx", team_name=team_name) from clawteam.transport import get_transport return get_transport("file", team_name=team_name) diff --git a/clawteam/transport/__init__.py b/clawteam/transport/__init__.py index 877aa2d6..a18ecc61 100644 --- a/clawteam/transport/__init__.py +++ b/clawteam/transport/__init__.py @@ -10,6 +10,10 @@ def get_transport(name: str, team_name: str, **kwargs) -> Transport: if name == "p2p": from clawteam.transport.p2p import P2PTransport return P2PTransport(team_name, **kwargs) + if name == "acpx": + from clawteam.transport.acpx import AcpxTransport + acpx_path = kwargs.pop("acpx_path", "acpx") + return AcpxTransport(team_name, acpx_path=acpx_path) from clawteam.transport.file import FileTransport return FileTransport(team_name) diff --git a/clawteam/transport/acpx.py b/clawteam/transport/acpx.py new file mode 100644 index 00000000..81ac8b93 --- /dev/null +++ b/clawteam/transport/acpx.py @@ -0,0 +1,155 @@ +"""ACPX transport: uses ACPX sessions as message channels between agents. + +Leverages ACPX's structured JSON output for reliable message delivery. +Falls back to FileTransport if ACPX is unavailable. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import time +import uuid +from pathlib import Path + +from clawteam.team.models import get_data_dir +from clawteam.transport.base import Transport +from clawteam.transport.file import FileTransport + + +def _acpx_sessions_dir(team_name: str) -> Path: + """Directory to track ACPX session metadata for a team.""" + d = get_data_dir() / "teams" / team_name / "acpx_sessions" + d.mkdir(parents=True, exist_ok=True) + return d + + +class AcpxTransport(Transport): + """Transport that uses ACPX sessions as message channels. + + Each agent has a named ACPX session. Delivering a message sends a + structured prompt to the recipient's session. Fetching reads from + a local spool directory populated by ACPX JSON output. + + Falls back to FileTransport if the acpx CLI is not available. + """ + + def __init__(self, team_name: str, acpx_path: str = "acpx"): + self.team_name = team_name + self._acpx = acpx_path + self._file_fallback = FileTransport(team_name) + self._available = shutil.which(self._acpx) is not None + + def deliver(self, recipient: str, data: bytes) -> None: + if not self._available: + self._file_fallback.deliver(recipient, data) + return + + session_name = f"clawteam-{self.team_name}-{recipient}" + + # Try to deliver via ACPX session + try: + # Parse the message to extract content for the ACPX prompt + msg = json.loads(data) + content = msg.get("content", data.decode("utf-8", errors="replace")) + from_agent = msg.get("from_agent", "unknown") + payload = f"[ClawTeam message from {from_agent}]: {content}" + + result = subprocess.run( + [ + self._acpx, "send", + "-s", session_name, + "--format", "json", + "--no-wait", + payload, + ], + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode == 0: + # Also store in file spool as a reliable record + self._spool_message(recipient, data) + return + except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError): + pass + + # ACPX delivery failed — fall back to file transport + self._file_fallback.deliver(recipient, data) + + def fetch(self, agent_name: str, limit: int = 10, consume: bool = True) -> list[bytes]: + messages: list[bytes] = [] + + # Read from the ACPX spool directory first + spool = self._spool_dir(agent_name) + if spool.exists(): + files = sorted(spool.glob("msg-*.json")) + for f in files[:limit]: + try: + raw = f.read_bytes() + messages.append(raw) + if consume: + f.unlink() + except Exception: + if consume: + try: + f.unlink() + except OSError: + pass + + # Fill remaining from file fallback + remaining = limit - len(messages) + if remaining > 0: + messages.extend(self._file_fallback.fetch(agent_name, remaining, consume)) + + return messages[:limit] + + def count(self, agent_name: str) -> int: + spool = self._spool_dir(agent_name) + spool_count = len(list(spool.glob("msg-*.json"))) if spool.exists() else 0 + return spool_count + self._file_fallback.count(agent_name) + + def list_recipients(self) -> list[str]: + recipients: set[str] = set() + # Check ACPX sessions directory + sessions_dir = _acpx_sessions_dir(self.team_name) + for f in sessions_dir.glob("*.json"): + recipients.add(f.stem) + # Union with file transport recipients + recipients.update(self._file_fallback.list_recipients()) + return list(recipients) + + def close(self) -> None: + """Release resources.""" + pass + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _spool_dir(self, agent_name: str) -> Path: + """Per-agent spool directory for ACPX-delivered messages.""" + d = get_data_dir() / "teams" / self.team_name / "acpx_spool" / agent_name + d.mkdir(parents=True, exist_ok=True) + return d + + def _spool_message(self, recipient: str, data: bytes) -> None: + """Write a message to the recipient's spool directory (atomic).""" + spool = self._spool_dir(recipient) + ts = int(time.time() * 1000) + uid = uuid.uuid4().hex[:8] + filename = f"msg-{ts}-{uid}.json" + tmp = spool / f".tmp-{uid}.json" + target = spool / filename + tmp.write_bytes(data) + tmp.rename(target) + + def register_session(self, agent_name: str, session_name: str) -> None: + """Record an ACPX session for an agent (for recipient discovery).""" + sessions_dir = _acpx_sessions_dir(self.team_name) + info = {"session": session_name, "agent": agent_name} + path = sessions_dir / f"{agent_name}.json" + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(info), encoding="utf-8") + tmp.rename(path) diff --git a/clawteam/workspace/conflicts.py b/clawteam/workspace/conflicts.py new file mode 100644 index 00000000..a0484902 --- /dev/null +++ b/clawteam/workspace/conflicts.py @@ -0,0 +1,271 @@ +"""Conflict detection and overlap warnings for multi-agent git workspaces.""" + +from __future__ import annotations + +from pathlib import Path + +from clawteam.workspace import git +from clawteam.workspace.context import file_owners, _ws_manager, _agent_branch, _base_branch +from clawteam.workspace.manager import _load_registry + + +# --------------------------------------------------------------------------- +# detect_overlaps +# --------------------------------------------------------------------------- + +def detect_overlaps(team_name: str, repo: str | None = None) -> list[dict]: + """Detect files modified by multiple agents. + + Returns list of dicts with keys: file, agents, severity. + Severity: + - high: agents changed the same lines + - medium: agents changed the same file (different lines) + - low: agents changed files in the same directory + """ + owners = file_owners(team_name, repo) + mgr = _ws_manager(repo) + registry = _load_registry(team_name, str(mgr.repo_root)) + + overlaps: list[dict] = [] + for fname, agents in owners.items(): + if len(agents) < 2: + continue + + # Determine severity by checking if changed lines overlap + severity = _compute_severity(fname, agents, team_name, mgr) + overlaps.append({ + "file": fname, + "agents": agents, + "severity": severity, + }) + + # Sort: high first + order = {"high": 0, "medium": 1, "low": 2} + overlaps.sort(key=lambda o: order.get(o["severity"], 3)) + return overlaps + + +def _changed_lines( + fname: str, branch: str, base: str, repo_root: Path, +) -> set[int]: + """Return set of line numbers changed by branch for a specific file.""" + try: + diff_raw = git._run( + ["diff", "-U0", f"{base}...{branch}", "--", fname], + cwd=repo_root, + check=False, + ) + except Exception: + return set() + + lines: set[int] = set() + for line in diff_raw.splitlines(): + # Parse @@ -a,b +c,d @@ hunks + if line.startswith("@@"): + # Extract the +c,d portion (new-file lines) + parts = line.split("+") + if len(parts) >= 2: + hunk = parts[1].split(" ")[0].split("@@")[0] + if "," in hunk: + start, count = hunk.split(",", 1) + start = int(start) + count = int(count) + else: + start = int(hunk) + count = 1 + lines.update(range(start, start + count)) + return lines + + +def _compute_severity( + fname: str, + agents: list[str], + team_name: str, + mgr, +) -> str: + """Compute overlap severity for a file touched by multiple agents.""" + # Collect changed lines per agent + agent_lines: dict[str, set[int]] = {} + for agent_name in agents: + ws = mgr.get_workspace(team_name, agent_name) + if ws is None: + continue + branch = ws.branch_name + base = ws.base_branch + agent_lines[agent_name] = _changed_lines( + fname, branch, base, mgr.repo_root, + ) + + # Check pairwise overlap + agent_list = list(agent_lines.keys()) + for i in range(len(agent_list)): + for j in range(i + 1, len(agent_list)): + a_lines = agent_lines[agent_list[i]] + b_lines = agent_lines[agent_list[j]] + if a_lines & b_lines: + return "high" + + return "medium" + + +# --------------------------------------------------------------------------- +# check_conflicts +# --------------------------------------------------------------------------- + +def check_conflicts( + team_name: str, agent_a: str, agent_b: str, repo: str | None = None, +) -> list[dict]: + """Check for conflicts between two specific agents. + + Returns list of dicts with: file, conflict_markers (bool), details. + """ + mgr = _ws_manager(repo) + branch_a = _agent_branch(team_name, agent_a) + branch_b = _agent_branch(team_name, agent_b) + base_a = _base_branch(team_name, agent_a, mgr) + + # Find files changed by both + try: + files_a_raw = git._run( + ["diff", "--name-only", f"{base_a}...{branch_a}"], + cwd=mgr.repo_root, check=False, + ) + files_a = set(files_a_raw.splitlines()) if files_a_raw else set() + except Exception: + files_a = set() + + base_b = _base_branch(team_name, agent_b, mgr) + try: + files_b_raw = git._run( + ["diff", "--name-only", f"{base_b}...{branch_b}"], + cwd=mgr.repo_root, check=False, + ) + files_b = set(files_b_raw.splitlines()) if files_b_raw else set() + except Exception: + files_b = set() + + common_files = files_a & files_b + if not common_files: + return [] + + results: list[dict] = [] + for fname in sorted(common_files): + lines_a = _changed_lines(fname, branch_a, base_a, mgr.repo_root) + lines_b = _changed_lines(fname, branch_b, base_b, mgr.repo_root) + overlap = lines_a & lines_b + results.append({ + "file": fname, + "conflict_markers": bool(overlap), + "details": ( + f"Lines {sorted(overlap)[:10]}{'...' if len(overlap) > 10 else ''} " + f"changed by both agents" + if overlap + else f"Different lines modified (A: {len(lines_a)}, B: {len(lines_b)})" + ), + }) + + return results + + +# --------------------------------------------------------------------------- +# auto_notify +# --------------------------------------------------------------------------- + +def auto_notify(team_name: str, mailbox_mgr, repo: str | None = None) -> int: + """Scan for overlaps and send warning messages to affected agents. + + Returns number of warnings sent. + """ + overlaps = detect_overlaps(team_name, repo) + if not overlaps: + return 0 + + count = 0 + for overlap in overlaps: + if overlap["severity"] == "low": + continue # Only warn on medium/high + agents = overlap["agents"] + fname = overlap["file"] + severity = overlap["severity"] + for agent in agents: + others = [a for a in agents if a != agent] + content = ( + f"[context-warning] File overlap ({severity}): `{fname}` " + f"is also being modified by {', '.join(others)}. " + f"Consider coordinating to avoid merge conflicts." + ) + try: + mailbox_mgr.send( + from_agent="context-agent", + to=agent, + content=content, + ) + count += 1 + except Exception: + pass + return count + + +# --------------------------------------------------------------------------- +# suggest_rebase +# --------------------------------------------------------------------------- + +def suggest_rebase( + team_name: str, agent_name: str, repo: str | None = None, +) -> str | None: + """Suggest whether an agent should rebase onto the base branch. + + Returns a suggestion string, or None if no rebase is needed. + """ + mgr = _ws_manager(repo) + branch = _agent_branch(team_name, agent_name) + base = _base_branch(team_name, agent_name, mgr) + + # Count how many commits are on base that aren't on the agent's branch + try: + behind_raw = git._run( + ["rev-list", "--count", f"{branch}..{base}"], + cwd=mgr.repo_root, check=False, + ) + behind = int(behind_raw) if behind_raw.strip().isdigit() else 0 + except Exception: + behind = 0 + + if behind == 0: + return None + + # Check for overlapping files with merged changes + try: + base_files_raw = git._run( + ["diff", "--name-only", f"{branch}..{base}"], + cwd=mgr.repo_root, check=False, + ) + base_files = set(base_files_raw.splitlines()) if base_files_raw else set() + except Exception: + base_files = set() + + try: + agent_files_raw = git._run( + ["diff", "--name-only", f"{base}..{branch}"], + cwd=mgr.repo_root, check=False, + ) + agent_files = set(agent_files_raw.splitlines()) if agent_files_raw else set() + except Exception: + agent_files = set() + + overlapping = base_files & agent_files + if overlapping: + return ( + f"Rebase recommended: {agent_name}'s branch is {behind} commit(s) behind " + f"'{base}', and {len(overlapping)} file(s) overlap with upstream changes: " + f"{', '.join(sorted(overlapping)[:5])}{'...' if len(overlapping) > 5 else ''}. " + f"Run: git rebase {base}" + ) + elif behind > 5: + return ( + f"Rebase suggested: {agent_name}'s branch is {behind} commit(s) behind " + f"'{base}'. No file overlaps detected, but rebasing will keep the branch current. " + f"Run: git rebase {base}" + ) + + return None diff --git a/clawteam/workspace/context.py b/clawteam/workspace/context.py new file mode 100644 index 00000000..12856470 --- /dev/null +++ b/clawteam/workspace/context.py @@ -0,0 +1,286 @@ +"""Git context layer — provides cross-agent awareness of changes, file ownership, and overlap.""" + +from __future__ import annotations + +import re +from datetime import datetime, timezone +from pathlib import Path + +from clawteam.workspace import git +from clawteam.workspace.manager import WorkspaceManager, _load_registry + + +def _ws_manager(repo: str | None = None) -> WorkspaceManager: + path = Path(repo) if repo else None + mgr = WorkspaceManager.try_create(path) + if mgr is None: + raise RuntimeError("Not inside a git repository") + return mgr + + +def _agent_branch(team_name: str, agent_name: str) -> str: + return f"clawteam/{team_name}/{agent_name}" + + +def _base_branch(team_name: str, agent_name: str, mgr: WorkspaceManager) -> str: + ws = mgr.get_workspace(team_name, agent_name) + return ws.base_branch if ws else mgr.base_branch + + +# --------------------------------------------------------------------------- +# agent_diff +# --------------------------------------------------------------------------- + +def agent_diff(team_name: str, agent_name: str, repo: str | None = None) -> dict: + """Return diff statistics for an agent's branch vs. its base. + + Keys: files_changed, insertions, deletions, diff_stat, commit_count, summary + """ + mgr = _ws_manager(repo) + branch = _agent_branch(team_name, agent_name) + base = _base_branch(team_name, agent_name, mgr) + root = mgr.repo_root + + # numstat gives machine-readable per-file stats + try: + numstat_raw = git._run( + ["diff", "--numstat", f"{base}...{branch}"], cwd=root, check=False, + ) + except Exception: + numstat_raw = "" + + files_changed: list[str] = [] + insertions = 0 + deletions = 0 + for line in numstat_raw.splitlines(): + parts = line.split("\t") + if len(parts) == 3: + ins, dels, fname = parts + files_changed.append(fname) + if ins != "-": + insertions += int(ins) + if dels != "-": + deletions += int(dels) + + # Stat for human display + try: + diff_stat = git._run( + ["diff", "--stat", f"{base}...{branch}"], cwd=root, check=False, + ) + except Exception: + diff_stat = "" + + # Commit count + try: + count_raw = git._run( + ["rev-list", "--count", f"{base}..{branch}"], cwd=root, check=False, + ) + commit_count = int(count_raw) if count_raw.strip().isdigit() else 0 + except Exception: + commit_count = 0 + + summary = ( + f"{agent_name}: {len(files_changed)} file(s), " + f"+{insertions}/-{deletions}, {commit_count} commit(s)" + ) + return { + "files_changed": files_changed, + "insertions": insertions, + "deletions": deletions, + "diff_stat": diff_stat, + "commit_count": commit_count, + "summary": summary, + } + + +# --------------------------------------------------------------------------- +# file_owners +# --------------------------------------------------------------------------- + +def file_owners(team_name: str, repo: str | None = None) -> dict[str, list[str]]: + """Map each modified file to the list of agents that touched it.""" + mgr = _ws_manager(repo) + registry = _load_registry(team_name, str(mgr.repo_root)) + owners: dict[str, list[str]] = {} + + for ws in registry.workspaces: + branch = ws.branch_name + base = ws.base_branch + try: + numstat = git._run( + ["diff", "--numstat", f"{base}...{branch}"], + cwd=mgr.repo_root, + check=False, + ) + except Exception: + continue + for line in numstat.splitlines(): + parts = line.split("\t") + if len(parts) == 3: + fname = parts[2] + owners.setdefault(fname, []) + if ws.agent_name not in owners[fname]: + owners[fname].append(ws.agent_name) + return owners + + +# --------------------------------------------------------------------------- +# cross_branch_log +# --------------------------------------------------------------------------- + +def cross_branch_log( + team_name: str, limit: int = 50, repo: str | None = None, +) -> list[dict]: + """Unified commit log across all agent branches, newest first.""" + mgr = _ws_manager(repo) + registry = _load_registry(team_name, str(mgr.repo_root)) + entries: list[dict] = [] + + for ws in registry.workspaces: + branch = ws.branch_name + base = ws.base_branch + try: + log_raw = git._run( + [ + "log", + "--format=%H|%s|%aI", + "--name-only", + f"{base}..{branch}", + ], + cwd=mgr.repo_root, + check=False, + ) + except Exception: + continue + + current: dict | None = None + for line in log_raw.splitlines(): + if "|" in line and len(line.split("|")) >= 3: + if current is not None: + entries.append(current) + parts = line.split("|", 2) + current = { + "agent": ws.agent_name, + "hash": parts[0], + "message": parts[1], + "timestamp": parts[2], + "files": [], + } + elif line.strip() and current is not None: + current["files"].append(line.strip()) + if current is not None: + entries.append(current) + + # Sort by timestamp descending, take limit + entries.sort(key=lambda e: e["timestamp"], reverse=True) + return entries[:limit] + + +# --------------------------------------------------------------------------- +# agent_summary +# --------------------------------------------------------------------------- + +def agent_summary(team_name: str, agent_name: str, repo: str | None = None) -> str: + """Human-readable summary of an agent's git activity.""" + diff = agent_diff(team_name, agent_name, repo) + lines = [ + f"Agent: {agent_name}", + f"Branch: {_agent_branch(team_name, agent_name)}", + f"Commits: {diff['commit_count']}", + f"Files changed: {len(diff['files_changed'])}", + f"Insertions: +{diff['insertions']} Deletions: -{diff['deletions']}", + ] + if diff["files_changed"]: + lines.append("Modified files:") + for f in diff["files_changed"]: + lines.append(f" - {f}") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# inject_context +# --------------------------------------------------------------------------- + +def inject_context( + team_name: str, target_agent: str, repo: str | None = None, +) -> str: + """Build a context block for injection into an agent's prompt. + + Includes: + - Other agents' recent changes on files the target agent also touches + - File overlap warnings + - Upstream dependency diffs (if task has blocked_by) + """ + mgr = _ws_manager(repo) + registry = _load_registry(team_name, str(mgr.repo_root)) + + # Files the target agent is modifying + target_diff = agent_diff(team_name, target_agent, repo) + target_files = set(target_diff["files_changed"]) + + sections: list[str] = [] + + # --- Section 1: Other agents' changes on overlapping files --- + owners = file_owners(team_name, repo) + overlaps: dict[str, list[str]] = {} + for fname, agents in owners.items(): + if fname in target_files and len(agents) > 1: + others = [a for a in agents if a != target_agent] + if others: + overlaps[fname] = others + + if overlaps: + overlap_lines = ["## File Overlap Warnings"] + for fname, agents in overlaps.items(): + overlap_lines.append(f"- `{fname}` also modified by: {', '.join(agents)}") + sections.append("\n".join(overlap_lines)) + + # --- Section 2: Recent changes from other agents on related files --- + log = cross_branch_log(team_name, limit=20, repo=repo) + related: list[str] = [] + for entry in log: + if entry["agent"] == target_agent: + continue + common = target_files & set(entry["files"]) + if common: + related.append( + f"- [{entry['agent']}] {entry['hash'][:8]} {entry['message']} " + f"(files: {', '.join(common)})" + ) + if related: + sections.append("## Recent Related Changes\n" + "\n".join(related)) + + # --- Section 3: Upstream dependency diffs --- + try: + from clawteam.team.tasks import TaskStore + + store = TaskStore(team_name) + tasks = store.list_tasks(owner=target_agent) + dep_ids: set[str] = set() + for t in tasks: + dep_ids.update(t.blocked_by) + + if dep_ids: + # Find which agents own those upstream tasks + all_tasks = store.list_tasks() + dep_agents: set[str] = set() + for t in all_tasks: + if t.id in dep_ids and t.owner and t.owner != target_agent: + dep_agents.add(t.owner) + + if dep_agents: + dep_lines = ["## Upstream Dependency Changes"] + for dep_agent in sorted(dep_agents): + dep_diff = agent_diff(team_name, dep_agent, repo) + dep_lines.append( + f"- {dep_agent}: {dep_diff['summary']}" + ) + sections.append("\n".join(dep_lines)) + except Exception: + pass # Tasks may not exist yet + + if not sections: + return "No cross-agent context to inject — working in isolation." + + header = f"# Git Context for {target_agent}\n" + return header + "\n\n".join(sections) From ed718c9f68f21483e14a3be265278ef846a6aa97 Mon Sep 17 00:00:00 2001 From: tjb-tech <1193992557@qq.com> Date: Wed, 18 Mar 2026 07:05:03 +0000 Subject: [PATCH 2/4] Revert README.md to main branch version Keep original README content including Korean docs link. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 97 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 60 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 7a2d7406..255759a1 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,11 @@ WeChat

-**One Command Line: Set Your Goal** — agents spawn swarms, delegate tasks, and deliver results automatically. +**One Command Line: Full Automation.** — agents spawn swarms, delegate tasks, and deliver results. -Full compatibility with [Claude Code](https://claude.ai/claude-code), [Codex](https://openai.com/codex), [OpenClaw](https://github.com/nicepkg/OpenClaw), [nanobot](https://github.com/AbanteAI/nanobot), [Cursor](https://cursor.com), and any CLI agent.  [**中文文档**](README_CN.md) +Human provides the goal. The Agent Team orchestrates everything else. + +Full compatibility with [Claude Code](https://claude.ai/claude-code), [Codex](https://openai.com/codex), [OpenClaw](https://github.com/nicepkg/OpenClaw), [nanobot](https://github.com/AbanteAI/nanobot), [Cursor](https://cursor.com), and any CLI agent.  [**中文文档**](README_CN.md) | [**한국어**](README_KR.md)

ClawTeam - AI agents orchestrating themselves @@ -45,13 +47,13 @@ Full compatibility with [Claude Code](https://claude.ai/claude-code), [Codex](ht -

Large-Scale Automated ML Experimentation

+

• Large-Scale Automated ML Experimentation

-

AI Model Training & Optimization

+

• AI Model Training & Optimization

-

AI-Driven Hypothesis Generation & Validation

+

• AI-Driven Hypothesis Generation & Validation

-

Self-Improving Model Architectures

+

• Self-Improving Model Architectures

@@ -64,9 +66,13 @@ Full compatibility with [Claude Code](https://claude.ai/claude-code), [Codex](ht -

Parallel Software Development

+

• Autonomous Full-Stack Development

+ +

• Self-Evolving Software

+ +

• Collaborative Open Source Development

-

Agents split work into API, backend, frontend, tests — each on its own git branch, auto-merging on completion

+

• Real-Time System Integration

@@ -79,9 +85,13 @@ Full compatibility with [Claude Code](https://claude.ai/claude-code), [Codex](ht -

Multi-Analyst Signal Fusion

+

• Automated Market Research & Data Mining

-

7 analyst agents (value, growth, technical, fundamentals, sentiment) + risk manager converge on investment decisions

+

• Multi-Strategy Portfolio Optimization

+ +

• Real-Time Risk Assessment

+ +

• Algorithmic Trading Execution & Monitoring

@@ -94,9 +104,13 @@ Full compatibility with [Claude Code](https://claude.ai/claude-code), [Codex](ht -

One-Command Team Launch

+

• Custom Scientific Research Teams

+ +

• Personalized Investment Committees

-

Define any team archetype as a TOML template — roles, tasks, prompts — and launch with clawteam launch

+

• Business Operations Teams

+ +

• Content Production Studios

@@ -106,25 +120,32 @@ Full compatibility with [Claude Code](https://claude.ai/claude-code), [Codex](ht https://github.com/user-attachments/assets/f6f0b220-9a5e-4d0a-a25d-f80753d3639b -*☝️ A leader Claude agent spawns 8 sub-agents across 8 H100 GPUs, assigns experiment directions, monitors progress, cross-pollinates findings, and redirects unproductive agents — fully autonomously.* +☝️ Intelligent leader agent orchestrates 8 specialized sub-agents across 8 H100 GPUs, autonomously designing experiments and dynamically reallocating resources based on real-time performance. + +🧠 The system synthesizes breakthroughs across teams and evolves strategies independently — achieving full research automation without human intervention. --- ## 🤔 Why ClawTeam? -AI coding agents are powerful — but they work **alone**. When a task is too big for one agent, you're stuck manually splitting work, copy-pasting context, and merging results. +Current AI agents are powerful — but they work in **isolation**. When facing complex tasks, you're stuck manually coordinating multiple agents, juggling context, and stitching together fragmented results. + +**What if agents could think and work as a team?** + +ClawTeam unlocks **Agent Swarm Intelligence** — where AI agents self-organize into collaborative teams, intelligently divide complex work, share insights in real-time, and converge on breakthrough solutions. + +• **🚀 Spawns specialized sub-agents** — each with dedicated environments and focus areas -**What if agents could swarm?** +• **📋 Designs intelligent task allocation** — with smart dependency management -ClawTeam enables **Agent Swarm Intelligence** — agents that self-organize into teams, divide work, share discoveries, and converge on solutions. A leader agent can: +• **💬 Facilitates real-time coordination** — seamless inter-agent communication -- 🚀 **Spawn sub-agents** — each in its own git worktree and tmux session -- 📋 **Assign tasks** — with dependency chains that auto-unblock -- 💬 **Send messages** — direct instructions to any sub-agent -- 📊 **Monitor progress** — check the kanban board, read results -- 🔄 **Redirect work** — kill unproductive agents, reassign with new directions +• **📊 Monitors team performance** — tracks progress and identifies bottlenecks -The human just provides the initial goal. **The swarm handles the rest.** +• **🔄 Adapts strategies dynamically** — reallocates resources and redirects efforts + +#### ✨ The Result?** +You set the vision. The swarm executes with collective intelligence.

How ClawTeam works - comic @@ -194,7 +215,21 @@ clawteam board serve --port 8080 ### 🔬 1. Autonomous ML Research — 8 Agents × 8 H100 GPUs -Based on [@karpathy's autoresearch](https://github.com/karpathy/autoresearch). The human tells a leader agent: *"Optimize this LLM training setup using 8 GPUs."* **The leader does everything else.** +Based on [@karpathy's autoresearch](https://github.com/karpathy/autoresearch). + +#### 💫 One Command. Full Automation. + +#### Human input: "Optimize this LLM training setup using 8 GPUs" + +The Agent Team handles everything else: +- Spawns 8 specialized research agents across H100s +- Designs 2000+ autonomous experiments +- Achieves breakthrough improvements (val_bpb: 1.044→0.977) +- Zero human intervention required + +#### 🎯 Pure Research at Scale + +Transform months of manual hyperparameter tuning into hours of intelligent automation.

AutoResearch Progress @@ -202,7 +237,7 @@ Based on [@karpathy's autoresearch](https://github.com/karpathy/autoresearch). T 🏆 val_bpb: 1.044 → 0.977 (6.4% improvement) | 2430+ experiments | ~30 GPU-hours

-**What the leader agent did autonomously:** +**What agent team did autonomously:** ``` Human prompt: "Use 8 GPUs to optimize train.py. Read program.md for instructions." @@ -613,19 +648,7 @@ We welcome contributions! ClawTeam is designed to be extensible: ## ⭐ Star History -If ClawTeam helps your AI agents work in teams, give us a star! ⭐ - - - ---- +If you find ClawTeam helpful, please consider to give us a star! ⭐ ## 📄 License From eccd58d2292dfeff883ef4d644a8970887180869 Mon Sep 17 00:00:00 2001 From: tjb-tech <1193992557@qq.com> Date: Fri, 20 Mar 2026 09:11:33 +0000 Subject: [PATCH 3/4] Drop ACPX and harden native CLI spawning --- clawteam/cli/commands.py | 60 +++++-- clawteam/config.py | 9 +- clawteam/spawn/__init__.py | 8 +- clawteam/spawn/acpx_backend.py | 228 --------------------------- clawteam/spawn/adapters.py | 93 +++++++++++ clawteam/spawn/registry.py | 2 +- clawteam/spawn/subprocess_backend.py | 76 ++------- clawteam/spawn/tmux_backend.py | 119 +++++--------- clawteam/team/mailbox.py | 3 - clawteam/transport/__init__.py | 4 - clawteam/transport/acpx.py | 155 ------------------ tests/test_spawn_backends.py | 4 + tests/test_spawn_cli.py | 34 ++++ 13 files changed, 236 insertions(+), 559 deletions(-) delete mode 100644 clawteam/spawn/acpx_backend.py create mode 100644 clawteam/spawn/adapters.py delete mode 100644 clawteam/transport/acpx.py diff --git a/clawteam/cli/commands.py b/clawteam/cli/commands.py index d9b4c4d1..de1cc561 100644 --- a/clawteam/cli/commands.py +++ b/clawteam/cli/commands.py @@ -50,7 +50,7 @@ def main( None, "--data-dir", help="Override data directory (default: ~/.clawteam).", ), transport: Optional[str] = typer.Option( - None, "--transport", help="Transport backend: file, p2p, or acpx.", + None, "--transport", help="Transport backend: file or p2p.", ), ): """clawteam - Framework-agnostic multi-agent coordination CLI.""" @@ -116,7 +116,7 @@ def _human(d): def config_set( key: str = typer.Argument( ..., - help="Config key (e.g. data_dir, user, transport, workspace, default_backend, skip_permissions, gource_path, acpx_path)", + help="Config key (e.g. data_dir, user, transport, workspace, default_backend, skip_permissions, gource_path)", ), value: str = typer.Argument(..., help="Config value"), ): @@ -146,7 +146,7 @@ def config_set( def config_get( key: str = typer.Argument( ..., - help="Config key (e.g. data_dir, user, transport, workspace, default_backend, skip_permissions, gource_path, acpx_path)", + help="Config key (e.g. data_dir, user, transport, workspace, default_backend, skip_permissions, gource_path)", ), ): """Get the effective value of a config key.""" @@ -1730,7 +1730,7 @@ def lifecycle_on_exit( @app.command("spawn") def spawn_agent( - backend: Optional[str] = typer.Argument(None, help="Backend: tmux (default), subprocess, or acpx"), + backend: Optional[str] = typer.Argument(None, help="Backend: tmux (default) or subprocess"), command: list[str] = typer.Argument(None, help="Command and arguments to run (default: claude)"), team: Optional[str] = typer.Option(None, "--team", "-t", help="Team name"), agent_name: Optional[str] = typer.Option(None, "--agent-name", "-n", help="Agent name"), @@ -1748,7 +1748,6 @@ def spawn_agent( Backends: tmux - Launch in tmux windows (visual monitoring) subprocess - Launch as background processes - acpx - Launch via ACPX Agent Client Protocol (multi-provider) """ from clawteam.config import get_effective from clawteam.spawn import get_backend @@ -2050,6 +2049,8 @@ def board_gource( team: str = typer.Argument(..., help="Team name"), export: Optional[str] = typer.Option(None, "--export", help="Export video to file (requires FFmpeg)"), log_only: bool = typer.Option(False, "--log-only", help="Output Gource custom log to stdout without launching"), + live: bool = typer.Option(False, "--live", help="Stream new activity into Gource in realtime"), + interval: float = typer.Option(2.0, "--interval", min=0.2, help="Polling interval in seconds for --live"), combine_worktrees: bool = typer.Option(True, "--combine-worktrees/--events-only", help="Combine git worktree logs with event log"), repo: Optional[str] = typer.Option(None, "--repo", help="Git repo path for worktree discovery"), resolution: Optional[str] = typer.Option(None, "--resolution", "-r", help="Viewport resolution (e.g. 1920x1080)"), @@ -2061,14 +2062,24 @@ def board_gource( optionally combines git history from all agent worktrees into a unified Gource animation showing parallel collaboration. """ + import tempfile + from clawteam.board.gource import ( + append_log_lines, + collect_live_log_lines, + find_gource, generate_combined_log, generate_event_log, - generate_git_log, - find_gource, launch_gource, + stream_gource_live, ) - import tempfile + + if live and export: + _output( + {"error": "--live cannot be used with --export"}, + lambda d: console.print(f"[red]{d['error']}[/red]"), + ) + raise typer.Exit(1) # Generate log lines if combine_worktrees: @@ -2106,11 +2117,12 @@ def board_gource( try: title = f"ClawTeam: {team}" proc = launch_gource( - log_file=log_path, + log_file=None if live else log_path, title=title, resolution=resolution or "", seconds_per_day=seconds_per_day or 0, export_path=export, + live_stream=live, ) if proc is None: _output( @@ -2123,6 +2135,36 @@ def board_gource( console.print(f"Exporting Gource visualization to [cyan]{export}[/cyan]...") proc.wait() console.print(f"[green]OK[/green] Video saved to {export}") + elif live: + if proc.stdin is None: + console.print("[red]Failed to open live Gource stream.[/red]") + raise typer.Exit(1) + console.print( + f"Gource live stream launched for team [cyan]{team}[/cyan]. " + "Close the window or press Ctrl+C to stop." + ) + seed_lines = collect_live_log_lines( + set(), + team, + combine_worktrees=combine_worktrees, + repo_path=repo, + ) + append_log_lines(proc.stdin, seed_lines) + try: + stream_gource_live( + proc, + team, + combine_worktrees=combine_worktrees, + repo_path=repo, + poll_interval=interval, + ) + except KeyboardInterrupt: + if proc.poll() is None: + proc.terminate() + finally: + if proc.stdin is not None: + proc.stdin.close() + proc.wait() else: console.print(f"Gource launched for team [cyan]{team}[/cyan]. Close the window to exit.") proc.wait() diff --git a/clawteam/config.py b/clawteam/config.py index 44e2f62e..c7ad1592 100644 --- a/clawteam/config.py +++ b/clawteam/config.py @@ -15,15 +15,11 @@ class ClawTeamConfig(BaseModel): default_team: str = "" transport: str = "" workspace: str = "auto" # "auto" | "always" | "never" | "" - default_backend: str = "tmux" # "tmux" | "subprocess" | "acpx" + default_backend: str = "tmux" # "tmux" | "subprocess" skip_permissions: bool = True # pass --dangerously-skip-permissions to claude gource_path: str = "" # custom path to gource binary (auto-detected if empty) gource_resolution: str = "1280x720" # default viewport resolution gource_seconds_per_day: float = 0.5 # animation speed - # ACPX settings - acpx_path: str = "" # path to acpx binary (default: "acpx" from PATH) - acpx_default_format: str = "json" # "json" | "text" | "stream-json" - acpx_approve_mode: str = "" # "" | "approve-all" | "approve-reads" def config_path() -> Path: @@ -68,9 +64,6 @@ def get_effective(key: str) -> tuple[str, str]: "gource_path": "CLAWTEAM_GOURCE_PATH", "gource_resolution": "CLAWTEAM_GOURCE_RESOLUTION", "gource_seconds_per_day": "CLAWTEAM_GOURCE_SECONDS_PER_DAY", - "acpx_path": "CLAWTEAM_ACPX_PATH", - "acpx_default_format": "CLAWTEAM_ACPX_DEFAULT_FORMAT", - "acpx_approve_mode": "CLAWTEAM_ACPX_APPROVE_MODE", } defaults = ClawTeamConfig() cfg = load_config() diff --git a/clawteam/spawn/__init__.py b/clawteam/spawn/__init__.py index 442baa87..d920fbbb 100644 --- a/clawteam/spawn/__init__.py +++ b/clawteam/spawn/__init__.py @@ -13,14 +13,8 @@ def get_backend(name: str = "tmux") -> SpawnBackend: elif name == "tmux": from clawteam.spawn.tmux_backend import TmuxBackend return TmuxBackend() - elif name == "acpx": - from clawteam.spawn.acpx_backend import AcpxBackend - from clawteam.config import load_config - cfg = load_config() - acpx_path = getattr(cfg, "acpx_path", "") or "acpx" - return AcpxBackend(acpx_path=acpx_path) else: - raise ValueError(f"Unknown spawn backend: {name}. Available: subprocess, tmux, acpx") + raise ValueError(f"Unknown spawn backend: {name}. Available: subprocess, tmux") __all__ = ["SpawnBackend", "get_backend"] diff --git a/clawteam/spawn/acpx_backend.py b/clawteam/spawn/acpx_backend.py deleted file mode 100644 index 43a31b4b..00000000 --- a/clawteam/spawn/acpx_backend.py +++ /dev/null @@ -1,228 +0,0 @@ -"""ACPX spawn backend - launches agents via Agent Client Protocol (acpx CLI).""" - -from __future__ import annotations - -import json -import os -import shlex -import shutil -import subprocess - -from clawteam.spawn.base import SpawnBackend - - -# ACPX-supported agent types and their acpx subcommand names -ACPX_AGENTS = frozenset({ - "pi", "codex", "claude", "gemini", "cursor", "copilot", "openclaw", -}) - - -class AcpxBackend(SpawnBackend): - """Spawn agents using acpx (Agent Client Protocol headless CLI). - - Instead of managing tmux sessions or raw subprocesses, this backend - delegates to ``acpx `` which communicates with - agents through their native ACP interface. - - Supports: - - Named sessions (``-s ``) for reconnect / resume - - JSON output format (``--format json``) for structured parsing - - Permission modes (``--approve-all``, ``--approve-reads``) - - Async prompt submission (``--no-wait``) - """ - - def __init__(self, acpx_path: str = "acpx"): - self._acpx = acpx_path - self._agents: dict[str, dict] = {} # agent_name -> spawn info - - # ------------------------------------------------------------------ - # SpawnBackend interface - # ------------------------------------------------------------------ - - def spawn( - self, - command: list[str], - agent_name: str, - agent_id: str, - agent_type: str, - team_name: str, - prompt: str | None = None, - env: dict[str, str] | None = None, - cwd: str | None = None, - skip_permissions: bool = False, - ) -> str: - if not shutil.which(self._acpx): - return ( - f"Error: '{self._acpx}' not found. " - "Install with: npm install -g acpx@latest" - ) - - # Determine the acpx agent type from the command - acpx_agent = _resolve_acpx_agent(command) - - # Build the acpx command - acpx_cmd = [self._acpx, acpx_agent] - - # Named session for reconnect support - session_name = f"clawteam-{team_name}-{agent_name}" - acpx_cmd.extend(["-s", session_name]) - - # JSON output for structured message parsing - acpx_cmd.extend(["--format", "json"]) - - # Permission modes - if skip_permissions: - acpx_cmd.append("--approve-all") - - # Async: run with --no-wait so the spawn returns immediately - acpx_cmd.append("--no-wait") - - # Append the prompt - if prompt: - acpx_cmd.append(prompt) - - # Prepare environment - spawn_env = os.environ.copy() - spawn_env.update({ - "CLAWTEAM_AGENT_ID": agent_id, - "CLAWTEAM_AGENT_NAME": agent_name, - "CLAWTEAM_AGENT_TYPE": agent_type, - "CLAWTEAM_TEAM_NAME": team_name, - "CLAWTEAM_AGENT_LEADER": "0", - }) - user = os.environ.get("CLAWTEAM_USER", "") - if user: - spawn_env["CLAWTEAM_USER"] = user - transport = os.environ.get("CLAWTEAM_TRANSPORT", "") - if transport: - spawn_env["CLAWTEAM_TRANSPORT"] = transport - if cwd: - spawn_env["CLAWTEAM_WORKSPACE_DIR"] = cwd - # Inject context awareness flags - spawn_env["CLAWTEAM_CONTEXT_ENABLED"] = "1" - if env: - spawn_env.update(env) - - # Wrap with on-exit hook - cmd_str = " ".join(shlex.quote(c) for c in acpx_cmd) - exit_hook = ( - f"clawteam lifecycle on-exit --team {shlex.quote(team_name)} " - f"--agent {shlex.quote(agent_name)}" - ) - shell_cmd = f"{cmd_str}; {exit_hook}" - - process = subprocess.Popen( - shell_cmd, - shell=True, - env=spawn_env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=cwd, - ) - - self._agents[agent_name] = { - "pid": process.pid, - "session": session_name, - "acpx_agent": acpx_agent, - "command": acpx_cmd, - } - - # Persist spawn info for liveness checking - from clawteam.spawn.registry import register_agent - - register_agent( - team_name=team_name, - agent_name=agent_name, - backend="acpx", - pid=process.pid, - command=acpx_cmd, - ) - - return ( - f"Agent '{agent_name}' spawned via acpx " - f"(agent={acpx_agent}, session={session_name}, pid={process.pid})" - ) - - def list_running(self) -> list[dict[str, str]]: - result = [] - for name, info in list(self._agents.items()): - pid = info.get("pid", 0) - if pid and _pid_alive(pid): - result.append({ - "name": name, - "pid": str(pid), - "session": info.get("session", ""), - "acpx_agent": info.get("acpx_agent", ""), - "backend": "acpx", - }) - else: - self._agents.pop(name, None) - return result - - # ------------------------------------------------------------------ - # ACPX session helpers - # ------------------------------------------------------------------ - - def send_prompt(self, session_name: str, prompt: str) -> str | None: - """Send a follow-up prompt to an existing ACPX session. - - Returns the JSON response or None on failure. - """ - cmd = [self._acpx, "send", "-s", session_name, "--format", "json", prompt] - try: - result = subprocess.run( - cmd, capture_output=True, text=True, timeout=300, - ) - if result.returncode == 0: - return result.stdout - except (subprocess.TimeoutExpired, OSError): - pass - return None - - def get_session_status(self, session_name: str) -> dict | None: - """Query ACPX session status. Returns parsed JSON or None.""" - cmd = [self._acpx, "status", "-s", session_name, "--format", "json"] - try: - result = subprocess.run( - cmd, capture_output=True, text=True, timeout=10, - ) - if result.returncode == 0: - return json.loads(result.stdout) - except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError): - pass - return None - - @staticmethod - def is_available() -> bool: - """Check if acpx CLI is installed and reachable.""" - return shutil.which("acpx") is not None - - -def _resolve_acpx_agent(command: list[str]) -> str: - """Map a ClawTeam command list to an acpx agent type. - - If the command itself is an acpx-known agent (e.g. ["claude"]), - return it directly. Otherwise default to "claude". - """ - if not command: - return "claude" - cmd_base = command[0].rsplit("/", 1)[-1].lower() # basename, lowercase - if cmd_base in ACPX_AGENTS: - return cmd_base - # Check if command[0] is "acpx" and command[1] is the agent type - if cmd_base == "acpx" and len(command) > 1 and command[1] in ACPX_AGENTS: - return command[1] - return "claude" - - -def _pid_alive(pid: int) -> bool: - """Check if a process with the given PID is still running.""" - if pid <= 0: - return False - try: - os.kill(pid, 0) - return True - except ProcessLookupError: - return False - except PermissionError: - return True diff --git a/clawteam/spawn/adapters.py b/clawteam/spawn/adapters.py new file mode 100644 index 00000000..7d619cfb --- /dev/null +++ b/clawteam/spawn/adapters.py @@ -0,0 +1,93 @@ +"""Runtime adapters for agent-specific command preparation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from clawteam.spawn.command_validation import normalize_spawn_command + + +@dataclass(frozen=True) +class PreparedCommand: + """Prepared native CLI command plus any post-launch prompt injection.""" + + normalized_command: list[str] + final_command: list[str] + post_launch_prompt: str | None = None + + +class NativeCliAdapter: + """Adapter for direct CLI runtimes such as claude, codex, gemini, nanobot.""" + + def prepare_command( + self, + command: list[str], + *, + prompt: str | None = None, + cwd: str | None = None, + skip_permissions: bool = False, + interactive: bool = False, + ) -> PreparedCommand: + normalized_command = normalize_spawn_command(command) + final_command = list(normalized_command) + post_launch_prompt = None + + if skip_permissions: + if is_claude_command(normalized_command): + final_command.append("--dangerously-skip-permissions") + elif is_codex_command(normalized_command): + final_command.append("--dangerously-bypass-approvals-and-sandbox") + elif is_gemini_command(normalized_command): + final_command.append("--yolo") + + if is_nanobot_command(normalized_command): + if cwd and not command_has_workspace_arg(normalized_command): + final_command.extend(["-w", cwd]) + if prompt: + final_command.extend(["-m", prompt]) + elif prompt: + if interactive and is_claude_command(normalized_command): + post_launch_prompt = prompt + elif is_codex_command(normalized_command): + final_command.append(prompt) + else: + final_command.extend(["-p", prompt]) + + return PreparedCommand( + normalized_command=normalized_command, + final_command=final_command, + post_launch_prompt=post_launch_prompt, + ) + + +def command_basename(command: list[str]) -> str: + """Return the normalized executable basename for a command.""" + if not command: + return "" + return Path(command[0]).name.lower() + + +def is_claude_command(command: list[str]) -> bool: + """Check if the command is a Claude CLI invocation.""" + return command_basename(command) in ("claude", "claude-code") + + +def is_codex_command(command: list[str]) -> bool: + """Check if the command is a Codex CLI invocation.""" + return command_basename(command) in ("codex", "codex-cli") + + +def is_nanobot_command(command: list[str]) -> bool: + """Check if the command is a nanobot CLI invocation.""" + return command_basename(command) == "nanobot" + + +def is_gemini_command(command: list[str]) -> bool: + """Check if the command is a Gemini CLI invocation.""" + return command_basename(command) == "gemini" + + +def command_has_workspace_arg(command: list[str]) -> bool: + """Return True when a command already specifies a workspace.""" + return "-w" in command or "--workspace" in command diff --git a/clawteam/spawn/registry.py b/clawteam/spawn/registry.py index f2530d5c..e937ca8d 100644 --- a/clawteam/spawn/registry.py +++ b/clawteam/spawn/registry.py @@ -58,7 +58,7 @@ def is_agent_alive(team_name: str, agent_name: str) -> bool | None: if pid: return _pid_alive(pid) return alive - elif backend in ("subprocess", "acpx"): + elif backend == "subprocess": return _pid_alive(info.get("pid", 0)) return None diff --git a/clawteam/spawn/subprocess_backend.py b/clawteam/spawn/subprocess_backend.py index 683ccdc1..ca76a481 100644 --- a/clawteam/spawn/subprocess_backend.py +++ b/clawteam/spawn/subprocess_backend.py @@ -6,9 +6,10 @@ import shlex import subprocess +from clawteam.spawn.adapters import NativeCliAdapter from clawteam.spawn.base import SpawnBackend from clawteam.spawn.cli_env import build_spawn_path, resolve_clawteam_executable -from clawteam.spawn.command_validation import normalize_spawn_command, validate_spawn_command +from clawteam.spawn.command_validation import validate_spawn_command class SubprocessBackend(SpawnBackend): @@ -16,6 +17,7 @@ class SubprocessBackend(SpawnBackend): def __init__(self): self._processes: dict[str, subprocess.Popen] = {} + self._adapter = NativeCliAdapter() def spawn( self, @@ -54,32 +56,21 @@ def spawn( if os.path.isabs(clawteam_bin): spawn_env.setdefault("CLAWTEAM_BIN", clawteam_bin) - normalized_command = normalize_spawn_command(command) + prepared = self._adapter.prepare_command( + command, + prompt=prompt, + cwd=cwd, + skip_permissions=skip_permissions, + interactive=False, + ) + normalized_command = prepared.normalized_command + validation_command = normalized_command + final_command = list(prepared.final_command) - command_error = validate_spawn_command(normalized_command, path=spawn_env["PATH"], cwd=cwd) + command_error = validate_spawn_command(validation_command, path=spawn_env["PATH"], cwd=cwd) if command_error: return command_error - final_command = list(normalized_command) - if skip_permissions: - if _is_claude_command(normalized_command): - final_command.append("--dangerously-skip-permissions") - elif _is_codex_command(normalized_command): - final_command.append("--dangerously-bypass-approvals-and-sandbox") - elif _is_gemini_command(normalized_command): - final_command.append("--yolo") - if _is_nanobot_command(normalized_command): - if cwd and not _command_has_workspace_arg(normalized_command): - final_command.extend(["-w", cwd]) - if prompt: - final_command.extend(["-m", prompt]) - elif prompt: - if _is_codex_command(normalized_command): - # Codex accepts prompt as positional argument - final_command.append(prompt) - else: - final_command.extend(["-p", prompt]) - # Wrap with on-exit hook so task status updates immediately on exit cmd_str = " ".join(shlex.quote(c) for c in final_command) exit_cmd = shlex.quote(clawteam_bin) if os.path.isabs(clawteam_bin) else "clawteam" @@ -106,7 +97,7 @@ def spawn( agent_name=agent_name, backend="subprocess", pid=process.pid, - command=list(normalized_command), + command=list(final_command), ) return f"Agent '{agent_name}' spawned as subprocess (pid={process.pid})" @@ -119,40 +110,3 @@ def list_running(self) -> list[dict[str, str]]: else: self._processes.pop(name, None) return result - - -def _is_claude_command(command: list[str]) -> bool: - """Check if the command is a claude CLI invocation.""" - if not command: - return False - cmd = command[0].rsplit("/", 1)[-1] - return cmd in ("claude", "claude-code") - - -def _is_codex_command(command: list[str]) -> bool: - """Check if the command is a codex CLI invocation.""" - if not command: - return False - cmd = command[0].rsplit("/", 1)[-1] - return cmd in ("codex", "codex-cli") - - -def _is_nanobot_command(command: list[str]) -> bool: - """Check if the command is a nanobot CLI invocation.""" - if not command: - return False - cmd = command[0].rsplit("/", 1)[-1] - return cmd == "nanobot" - - -def _is_gemini_command(command: list[str]) -> bool: - """Check if the command is a Gemini CLI invocation.""" - if not command: - return False - cmd = command[0].rsplit("/", 1)[-1] - return cmd == "gemini" - - -def _command_has_workspace_arg(command: list[str]) -> bool: - """Return True when a command already specifies a nanobot workspace.""" - return "-w" in command or "--workspace" in command diff --git a/clawteam/spawn/tmux_backend.py b/clawteam/spawn/tmux_backend.py index 151c7e35..8c518dfa 100644 --- a/clawteam/spawn/tmux_backend.py +++ b/clawteam/spawn/tmux_backend.py @@ -9,9 +9,16 @@ import tempfile import time +from clawteam.spawn.adapters import ( + NativeCliAdapter, + is_claude_command, + is_codex_command, + is_gemini_command, + is_nanobot_command, +) from clawteam.spawn.base import SpawnBackend from clawteam.spawn.cli_env import build_spawn_path, resolve_clawteam_executable -from clawteam.spawn.command_validation import normalize_spawn_command, validate_spawn_command +from clawteam.spawn.command_validation import validate_spawn_command class TmuxBackend(SpawnBackend): @@ -23,6 +30,7 @@ class TmuxBackend(SpawnBackend): def __init__(self): self._agents: dict[str, str] = {} # agent_name -> tmux target + self._adapter = NativeCliAdapter() def spawn( self, @@ -41,21 +49,14 @@ def spawn( session_name = f"clawteam-{team_name}" clawteam_bin = resolve_clawteam_executable() - env_vars = { + env_vars = os.environ.copy() + env_vars.update({ "CLAWTEAM_AGENT_ID": agent_id, "CLAWTEAM_AGENT_NAME": agent_name, "CLAWTEAM_AGENT_TYPE": agent_type, "CLAWTEAM_TEAM_NAME": team_name, "CLAWTEAM_AGENT_LEADER": "0", - } - # Propagate user if set - user = os.environ.get("CLAWTEAM_USER", "") - if user: - env_vars["CLAWTEAM_USER"] = user - # Propagate transport if set - transport = os.environ.get("CLAWTEAM_TRANSPORT", "") - if transport: - env_vars["CLAWTEAM_TRANSPORT"] = transport + }) if cwd: env_vars["CLAWTEAM_WORKSPACE_DIR"] = cwd # Inject context awareness flags @@ -66,34 +67,24 @@ def spawn( if os.path.isabs(clawteam_bin): env_vars.setdefault("CLAWTEAM_BIN", clawteam_bin) - normalized_command = normalize_spawn_command(command) + prepared = self._adapter.prepare_command( + command, + prompt=prompt, + cwd=cwd, + skip_permissions=skip_permissions, + interactive=True, + ) + normalized_command = prepared.normalized_command + validation_command = normalized_command + final_command = list(prepared.final_command) + post_launch_prompt = prepared.post_launch_prompt - command_error = validate_spawn_command(normalized_command, path=env_vars["PATH"], cwd=cwd) + command_error = validate_spawn_command(validation_command, path=env_vars["PATH"], cwd=cwd) if command_error: return command_error export_str = "; ".join(f"export {k}={shlex.quote(v)}" for k, v in env_vars.items()) - # Build the command (without prompt — we'll send it via send-keys) - final_command = list(normalized_command) - if skip_permissions: - if _is_claude_command(normalized_command): - final_command.append("--dangerously-skip-permissions") - elif _is_codex_command(normalized_command): - final_command.append("--dangerously-bypass-approvals-and-sandbox") - elif _is_gemini_command(normalized_command): - final_command.append("--yolo") - - if _is_nanobot_command(normalized_command): - if cwd and not _command_has_workspace_arg(normalized_command): - final_command.extend(["-w", cwd]) - if prompt: - final_command.extend(["-m", prompt]) - elif prompt and _is_codex_command(normalized_command): - final_command.append(prompt) - elif prompt and _is_gemini_command(normalized_command): - final_command.extend(["-p", prompt]) - cmd_str = " ".join(shlex.quote(c) for c in final_command) # Append on-exit hook: runs immediately when agent process exits exit_cmd = shlex.quote(clawteam_bin) if os.path.isabs(clawteam_bin) else "clawteam" @@ -152,7 +143,7 @@ def spawn( # Send the prompt as input to the interactive claude session # (codex prompt is passed as positional arg above, so skip here) - if prompt and _is_claude_command(normalized_command): + if post_launch_prompt and is_claude_command(normalized_command): # Wait for Claude Code to finish startup and show input prompt. # Bedrock-backed instances can take 10+ seconds to initialize. _wait_for_claude_ready(target, timeout_seconds=30) @@ -161,7 +152,7 @@ def spawn( with tempfile.NamedTemporaryFile( mode="w", suffix=".txt", delete=False, prefix="clawteam-prompt-" ) as f: - f.write(prompt) + f.write(post_launch_prompt) tmp_path = f.name subprocess.run( ["tmux", "load-buffer", "-b", f"prompt-{agent_name}", tmp_path], @@ -193,7 +184,7 @@ def spawn( stderr=subprocess.PIPE, ) os.unlink(tmp_path) - elif prompt and not _is_codex_command(normalized_command) and not _is_nanobot_command(normalized_command) and not _is_gemini_command(normalized_command): + elif prompt and not is_codex_command(normalized_command) and not is_nanobot_command(normalized_command) and not is_gemini_command(normalized_command): time.sleep(1) subprocess.run( ["tmux", "send-keys", "-t", target, prompt, "Enter"], @@ -223,7 +214,7 @@ def spawn( backend="tmux", tmux_target=target, pid=pane_pid, - command=list(normalized_command), + command=list(final_command), ) return f"Agent '{agent_name}' spawned in tmux ({target})" @@ -306,44 +297,6 @@ def attach_all(team_name: str) -> str: subprocess.run(["tmux", "attach-session", "-t", session]) return result - -def _is_claude_command(command: list[str]) -> bool: - """Check if the command is a claude CLI invocation.""" - if not command: - return False - cmd = command[0].rsplit("/", 1)[-1] # basename - return cmd in ("claude", "claude-code") - - -def _is_codex_command(command: list[str]) -> bool: - """Check if the command is a codex CLI invocation.""" - if not command: - return False - cmd = command[0].rsplit("/", 1)[-1] # basename - return cmd in ("codex", "codex-cli") - - -def _is_nanobot_command(command: list[str]) -> bool: - """Check if the command is a nanobot CLI invocation.""" - if not command: - return False - cmd = command[0].rsplit("/", 1)[-1] - return cmd == "nanobot" - - -def _is_gemini_command(command: list[str]) -> bool: - """Check if the command is a Gemini CLI invocation.""" - if not command: - return False - cmd = command[0].rsplit("/", 1)[-1] - return cmd == "gemini" - - -def _command_has_workspace_arg(command: list[str]) -> bool: - """Return True when a command already specifies a nanobot workspace.""" - return "-w" in command or "--workspace" in command - - def _confirm_workspace_trust_if_prompted( target: str, command: list[str], @@ -357,7 +310,7 @@ def _confirm_workspace_trust_if_prompted( injection and accept it with a single Enter so the interactive TUI remains intact. """ - if not (_is_claude_command(command) or _is_codex_command(command) or _is_gemini_command(command)): + if not (is_claude_command(command) or is_codex_command(command) or is_gemini_command(command)): return False deadline = time.monotonic() + timeout_seconds @@ -387,18 +340,18 @@ def _looks_like_workspace_trust_prompt(command: list[str], pane_text: str) -> bo if not pane_text: return False - if _is_claude_command(command): + if is_claude_command(command): return ("trust this folder" in pane_text or "trust the contents" in pane_text) and ( "enter to confirm" in pane_text or "press enter" in pane_text or "enter to continue" in pane_text ) - if _is_codex_command(command): + if is_codex_command(command): return ( "trust the contents of this directory" in pane_text and "press enter to continue" in pane_text ) - if _is_gemini_command(command): + if is_gemini_command(command): return "trust folder" in pane_text or "trust parent folder" in pane_text return False @@ -407,10 +360,10 @@ def _looks_like_workspace_trust_prompt(command: list[str], pane_text: str) -> bo def _is_interactive_cli(command: list[str]) -> bool: """Check if the command is an interactive AI CLI.""" return ( - _is_claude_command(command) - or _is_codex_command(command) - or _is_nanobot_command(command) - or _is_gemini_command(command) + is_claude_command(command) + or is_codex_command(command) + or is_nanobot_command(command) + or is_gemini_command(command) ) diff --git a/clawteam/team/mailbox.py b/clawteam/team/mailbox.py index c0d8b73d..8955323d 100644 --- a/clawteam/team/mailbox.py +++ b/clawteam/team/mailbox.py @@ -23,9 +23,6 @@ def _default_transport(team_name: str) -> Transport: agent = AgentIdentity.from_env().agent_name from clawteam.transport import get_transport return get_transport("p2p", team_name=team_name, bind_agent=agent) - if name == "acpx": - from clawteam.transport import get_transport - return get_transport("acpx", team_name=team_name) from clawteam.transport import get_transport return get_transport("file", team_name=team_name) diff --git a/clawteam/transport/__init__.py b/clawteam/transport/__init__.py index a18ecc61..877aa2d6 100644 --- a/clawteam/transport/__init__.py +++ b/clawteam/transport/__init__.py @@ -10,10 +10,6 @@ def get_transport(name: str, team_name: str, **kwargs) -> Transport: if name == "p2p": from clawteam.transport.p2p import P2PTransport return P2PTransport(team_name, **kwargs) - if name == "acpx": - from clawteam.transport.acpx import AcpxTransport - acpx_path = kwargs.pop("acpx_path", "acpx") - return AcpxTransport(team_name, acpx_path=acpx_path) from clawteam.transport.file import FileTransport return FileTransport(team_name) diff --git a/clawteam/transport/acpx.py b/clawteam/transport/acpx.py deleted file mode 100644 index 81ac8b93..00000000 --- a/clawteam/transport/acpx.py +++ /dev/null @@ -1,155 +0,0 @@ -"""ACPX transport: uses ACPX sessions as message channels between agents. - -Leverages ACPX's structured JSON output for reliable message delivery. -Falls back to FileTransport if ACPX is unavailable. -""" - -from __future__ import annotations - -import json -import shutil -import subprocess -import time -import uuid -from pathlib import Path - -from clawteam.team.models import get_data_dir -from clawteam.transport.base import Transport -from clawteam.transport.file import FileTransport - - -def _acpx_sessions_dir(team_name: str) -> Path: - """Directory to track ACPX session metadata for a team.""" - d = get_data_dir() / "teams" / team_name / "acpx_sessions" - d.mkdir(parents=True, exist_ok=True) - return d - - -class AcpxTransport(Transport): - """Transport that uses ACPX sessions as message channels. - - Each agent has a named ACPX session. Delivering a message sends a - structured prompt to the recipient's session. Fetching reads from - a local spool directory populated by ACPX JSON output. - - Falls back to FileTransport if the acpx CLI is not available. - """ - - def __init__(self, team_name: str, acpx_path: str = "acpx"): - self.team_name = team_name - self._acpx = acpx_path - self._file_fallback = FileTransport(team_name) - self._available = shutil.which(self._acpx) is not None - - def deliver(self, recipient: str, data: bytes) -> None: - if not self._available: - self._file_fallback.deliver(recipient, data) - return - - session_name = f"clawteam-{self.team_name}-{recipient}" - - # Try to deliver via ACPX session - try: - # Parse the message to extract content for the ACPX prompt - msg = json.loads(data) - content = msg.get("content", data.decode("utf-8", errors="replace")) - from_agent = msg.get("from_agent", "unknown") - payload = f"[ClawTeam message from {from_agent}]: {content}" - - result = subprocess.run( - [ - self._acpx, "send", - "-s", session_name, - "--format", "json", - "--no-wait", - payload, - ], - capture_output=True, - text=True, - timeout=30, - ) - if result.returncode == 0: - # Also store in file spool as a reliable record - self._spool_message(recipient, data) - return - except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError): - pass - - # ACPX delivery failed — fall back to file transport - self._file_fallback.deliver(recipient, data) - - def fetch(self, agent_name: str, limit: int = 10, consume: bool = True) -> list[bytes]: - messages: list[bytes] = [] - - # Read from the ACPX spool directory first - spool = self._spool_dir(agent_name) - if spool.exists(): - files = sorted(spool.glob("msg-*.json")) - for f in files[:limit]: - try: - raw = f.read_bytes() - messages.append(raw) - if consume: - f.unlink() - except Exception: - if consume: - try: - f.unlink() - except OSError: - pass - - # Fill remaining from file fallback - remaining = limit - len(messages) - if remaining > 0: - messages.extend(self._file_fallback.fetch(agent_name, remaining, consume)) - - return messages[:limit] - - def count(self, agent_name: str) -> int: - spool = self._spool_dir(agent_name) - spool_count = len(list(spool.glob("msg-*.json"))) if spool.exists() else 0 - return spool_count + self._file_fallback.count(agent_name) - - def list_recipients(self) -> list[str]: - recipients: set[str] = set() - # Check ACPX sessions directory - sessions_dir = _acpx_sessions_dir(self.team_name) - for f in sessions_dir.glob("*.json"): - recipients.add(f.stem) - # Union with file transport recipients - recipients.update(self._file_fallback.list_recipients()) - return list(recipients) - - def close(self) -> None: - """Release resources.""" - pass - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - def _spool_dir(self, agent_name: str) -> Path: - """Per-agent spool directory for ACPX-delivered messages.""" - d = get_data_dir() / "teams" / self.team_name / "acpx_spool" / agent_name - d.mkdir(parents=True, exist_ok=True) - return d - - def _spool_message(self, recipient: str, data: bytes) -> None: - """Write a message to the recipient's spool directory (atomic).""" - spool = self._spool_dir(recipient) - ts = int(time.time() * 1000) - uid = uuid.uuid4().hex[:8] - filename = f"msg-{ts}-{uid}.json" - tmp = spool / f".tmp-{uid}.json" - target = spool / filename - tmp.write_bytes(data) - tmp.rename(target) - - def register_session(self, agent_name: str, session_name: str) -> None: - """Record an ACPX session for an agent (for recipient discovery).""" - sessions_dir = _acpx_sessions_dir(self.team_name) - info = {"session": session_name, "agent": agent_name} - path = sessions_dir / f"{agent_name}.json" - tmp = path.with_suffix(".tmp") - tmp.write_text(json.dumps(info), encoding="utf-8") - tmp.rename(path) diff --git a/tests/test_spawn_backends.py b/tests/test_spawn_backends.py index f2ba0950..1c3c41a3 100644 --- a/tests/test_spawn_backends.py +++ b/tests/test_spawn_backends.py @@ -57,6 +57,8 @@ def fake_popen(cmd, **kwargs): def test_tmux_backend_exports_spawn_path_for_agent_commands(monkeypatch, tmp_path): monkeypatch.setenv("PATH", "/usr/bin:/bin") + monkeypatch.setenv("CLAWTEAM_DATA_DIR", "/tmp/clawteam-data") + monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "demo-project") clawteam_bin = tmp_path / "venv" / "bin" / "clawteam" clawteam_bin.parent.mkdir(parents=True) clawteam_bin.write_text("#!/bin/sh\n") @@ -109,6 +111,8 @@ def fake_which(name, path=None): full_cmd = new_session[-1] assert f"export PATH={clawteam_bin.parent}:/usr/bin:/bin" in full_cmd assert f"export CLAWTEAM_BIN={clawteam_bin}" in full_cmd + assert "export CLAWTEAM_DATA_DIR=/tmp/clawteam-data" in full_cmd + assert "export GOOGLE_CLOUD_PROJECT=demo-project" in full_cmd assert f"{clawteam_bin} lifecycle on-exit --team demo-team --agent worker1" in full_cmd diff --git a/tests/test_spawn_cli.py b/tests/test_spawn_cli.py index 9a0b1b9a..bc7f9494 100644 --- a/tests/test_spawn_cli.py +++ b/tests/test_spawn_cli.py @@ -66,3 +66,37 @@ def test_launch_cli_passes_skip_permissions_from_config(monkeypatch, tmp_path): assert result.exit_code == 0 assert backend.calls assert all(call["skip_permissions"] is True for call in backend.calls) + + +def test_spawn_cli_rejects_removed_acpx_backend(monkeypatch, tmp_path): + monkeypatch.setenv("CLAWTEAM_DATA_DIR", str(tmp_path)) + TeamManager.create_team( + name="demo", + leader_name="leader", + leader_id="leader001", + ) + + runner = CliRunner() + result = runner.invoke( + app, + ["spawn", "acpx", "claude", "--team", "demo", "--agent-name", "alice", "--no-workspace"], + env={"CLAWTEAM_DATA_DIR": str(tmp_path)}, + ) + + assert result.exit_code == 1 + assert "Unknown spawn backend: acpx. Available: subprocess, tmux" in result.output + + +def test_launch_cli_rejects_removed_acpx_backend(monkeypatch, tmp_path): + monkeypatch.setenv("CLAWTEAM_DATA_DIR", str(tmp_path)) + monkeypatch.chdir(tmp_path) + + runner = CliRunner() + result = runner.invoke( + app, + ["launch", "hedge-fund", "--backend", "acpx", "--team", "fund1", "--goal", "Analyze AAPL"], + env={"CLAWTEAM_DATA_DIR": str(tmp_path)}, + ) + + assert result.exit_code == 1 + assert "Unknown spawn backend: acpx. Available: subprocess, tmux" in result.output From fdcd018901fd8b9f5578ba71eb0de316f80a5591 Mon Sep 17 00:00:00 2001 From: tjb-tech <1193992557@qq.com> Date: Fri, 20 Mar 2026 09:11:37 +0000 Subject: [PATCH 4/4] Polish gource live mode and context auto-discovery --- clawteam/board/gource.py | 164 ++++++++++++++++++++++++++------ clawteam/board/renderer.py | 4 +- clawteam/workspace/conflicts.py | 77 +++++++++------ clawteam/workspace/context.py | 36 +++++-- tests/test_context.py | 51 ++++++++++ tests/test_gource.py | 120 +++++++++++++++++++++++ 6 files changed, 383 insertions(+), 69 deletions(-) create mode 100644 tests/test_context.py create mode 100644 tests/test_gource.py diff --git a/clawteam/board/gource.py b/clawteam/board/gource.py index 870c54cb..16216d8c 100644 --- a/clawteam/board/gource.py +++ b/clawteam/board/gource.py @@ -12,14 +12,15 @@ from __future__ import annotations -import subprocess import shutil +import subprocess +import time from datetime import datetime, timezone +from io import TextIOBase from pathlib import Path from clawteam.board.collector import BoardCollector - # --------------------------------------------------------------------------- # Color mapping for agents # --------------------------------------------------------------------------- @@ -43,10 +44,25 @@ def _agent_color(index: int) -> str: return AGENT_COLORS[index % len(AGENT_COLORS)] +def _virtual_path(*parts: str) -> str: + components: list[str] = [] + for part in parts: + if not part: + continue + for component in str(part).replace("\\", "/").split("/"): + if not component or component == ".": + continue + if components and components[-1] == component: + continue + components.append(component) + return "/" + "/".join(components) + + # --------------------------------------------------------------------------- # ClawTeam event log → Gource custom log # --------------------------------------------------------------------------- + def _parse_iso(ts: str) -> int: """Parse ISO timestamp string to unix timestamp.""" try: @@ -73,14 +89,19 @@ def generate_event_log(team_name: str) -> list[str]: return [] lines: list[str] = [] + inbox_aliases: dict[str, str] = {} # Member joins as additions for member in data.get("members", []): name = member["name"] + inbox_aliases[name] = name + user = member.get("user", "") + if user: + inbox_aliases[f"{user}_{name}"] = name joined = member.get("joinedAt", "") if joined: ts = _parse_iso(joined) - lines.append(f"{ts}|{name}|A|/team/{name}") + lines.append(f"{ts}|{name}|A|{_virtual_path('team', name)}") # Tasks as file operations for status, tasks in data.get("tasks", {}).items(): @@ -94,26 +115,30 @@ def generate_event_log(team_name: str) -> list[str]: if created: ts = _parse_iso(created) creator = owner or "system" - lines.append(f"{ts}|{creator}|A|/tasks/pending/{task_id}_{subject}") + lines.append(f"{ts}|{creator}|A|{_virtual_path('tasks', 'pending', f'{task_id}_{subject}')}") if updated and status != "pending": ts = _parse_iso(updated) gource_type = "M" if status in ("in_progress", "blocked") else "A" agent = owner or "system" - lines.append(f"{ts}|{agent}|{gource_type}|/tasks/{status}/{task_id}_{subject}") + lines.append( + f"{ts}|{agent}|{gource_type}|{_virtual_path('tasks', status, f'{task_id}_{subject}')}" + ) # Messages as modifications for msg in data.get("messages", []): - from_agent = msg.get("fromAgent", "unknown") - to = msg.get("to", "broadcast") + raw_from = msg.get("from") or msg.get("fromAgent") or "unknown" + from_agent = inbox_aliases.get(raw_from, raw_from) + raw_to = msg.get("to") or "broadcast" + to = inbox_aliases.get(raw_to, raw_to) ts_str = msg.get("timestamp", "") msg_type = msg.get("type", "message") if ts_str: ts = _parse_iso(ts_str) - lines.append(f"{ts}|{from_agent}|M|/messages/{from_agent}/{to}/{msg_type}") + lines.append(f"{ts}|{from_agent}|M|{_virtual_path('messages', from_agent, to, msg_type)}") # Sort by timestamp - lines.sort(key=lambda l: int(l.split("|")[0])) + lines.sort(key=lambda line: int(line.split("|")[0])) return lines @@ -121,6 +146,7 @@ def generate_event_log(team_name: str) -> list[str]: # Git log → Gource log (via context layer) # --------------------------------------------------------------------------- + def generate_git_log(team_name: str, repo_path: str | None = None) -> list[str]: """Combine git logs from all agent branches into unified Gource log. @@ -148,7 +174,7 @@ def generate_git_log(team_name: str, repo_path: str | None = None) -> list[str]: for fpath in entry.get("files", []): # Classify as M (modify) by default; context layer doesn't # distinguish A/M/D per-file, so use "M" for all. - lines.append(f"{ts}|{agent}|M|/{agent}/{fpath}") + lines.append(f"{ts}|{agent}|M|{_virtual_path(agent, fpath)}") # Enrich with file-owner coloring: mark multi-owner files try: @@ -158,12 +184,12 @@ def generate_git_log(team_name: str, repo_path: str | None = None) -> list[str]: if len(agents) > 1: # Add a synthetic entry so Gource shows shared files for agent in agents: - lines.append(f"{now_ts}|{agent}|M|/shared/{fname}") + lines.append(f"{now_ts}|{agent}|M|{_virtual_path('shared', fname)}") except Exception: pass # Sort by timestamp - lines.sort(key=lambda l: int(l.split("|")[0])) + lines.sort(key=lambda line: int(line.split("|")[0])) return lines @@ -172,14 +198,72 @@ def generate_combined_log(team_name: str, repo_path: str | None = None) -> list[ events = generate_event_log(team_name) git_lines = generate_git_log(team_name, repo_path) combined = events + git_lines - combined.sort(key=lambda l: int(l.split("|")[0])) + combined.sort(key=lambda line: int(line.split("|")[0])) return combined +def collect_live_log_lines( + seen_lines: set[str], + team_name: str, + *, + combine_worktrees: bool = True, + repo_path: str | None = None, +) -> list[str]: + """Return newly observed log lines for live streaming. + + This is intentionally side-effect free with respect to ClawTeam state. + It only polls current event/git views and de-duplicates against a local + in-memory cursor owned by the `board gource --live` command. + """ + all_lines = ( + generate_combined_log(team_name, repo_path) + if combine_worktrees + else generate_event_log(team_name) + ) + new_lines = [line for line in all_lines if line not in seen_lines] + new_lines.sort(key=lambda line: int(line.split("|")[0])) + return new_lines + + +def append_log_lines(stream: TextIOBase, lines: list[str]) -> None: + """Append custom-log lines to a live Gource input stream.""" + if not lines: + return + stream.write("\n".join(lines) + "\n") + stream.flush() + + +def stream_gource_live( + proc: subprocess.Popen, + team_name: str, + *, + combine_worktrees: bool = True, + repo_path: str | None = None, + poll_interval: float = 2.0, +) -> None: + """Feed Gource custom log lines to a running process via STDIN.""" + if proc.stdin is None: + raise RuntimeError("Live gource process missing stdin pipe") + + seen_lines: set[str] = set() + while proc.poll() is None: + new_lines = collect_live_log_lines( + seen_lines, + team_name, + combine_worktrees=combine_worktrees, + repo_path=repo_path, + ) + if new_lines: + append_log_lines(proc.stdin, new_lines) + seen_lines.update(new_lines) + time.sleep(poll_interval) + + # --------------------------------------------------------------------------- # Gource user color config generation # --------------------------------------------------------------------------- + def generate_user_colors(team_name: str) -> str: """Generate Gource --user-image-dir compatible color config. @@ -205,9 +289,11 @@ def generate_user_colors(team_name: str) -> str: # Launch Gource # --------------------------------------------------------------------------- + def find_gource() -> str | None: """Find gource binary. Returns path or None.""" from clawteam.config import load_config + cfg = load_config() custom_path = getattr(cfg, "gource_path", "") if custom_path and Path(custom_path).is_file(): @@ -216,12 +302,13 @@ def find_gource() -> str | None: def launch_gource( - log_file: Path, + log_file: Path | None = None, title: str = "", resolution: str = "", seconds_per_day: float = 0, extra_args: list[str] | None = None, export_path: str | None = None, + live_stream: bool = False, ) -> subprocess.Popen | None: """Launch Gource with the given custom log file. @@ -234,6 +321,7 @@ def launch_gource( # Load config defaults from clawteam.config import load_config + cfg = load_config() if not resolution: resolution = getattr(cfg, "gource_resolution", "1280x720") @@ -242,15 +330,22 @@ def launch_gource( cmd = [ gource_bin, - str(log_file), - "--log-format", "custom", - "--seconds-per-day", str(seconds_per_day), - "--auto-skip-seconds", "0.5", - "--file-idle-time", "0", - "--max-files", "0", + "-" if live_stream else str(log_file), + "--log-format", + "custom", + "--seconds-per-day", + str(seconds_per_day), + "--auto-skip-seconds", + "0.5", + "--file-idle-time", + "0", + "--max-files", + "0", "--highlight-users", "--multi-sampling", ] + if live_stream: + cmd.append("--realtime") if resolution: parts = resolution.split("x") @@ -279,14 +374,22 @@ def launch_gource( ffmpeg_cmd = [ ffmpeg_bin, "-y", # overwrite - "-r", "60", - "-f", "image2pipe", - "-vcodec", "ppm", - "-i", "-", - "-vcodec", "libx264", - "-preset", "medium", - "-pix_fmt", "yuv420p", - "-crf", "18", + "-r", + "60", + "-f", + "image2pipe", + "-vcodec", + "ppm", + "-i", + "-", + "-vcodec", + "libx264", + "-preset", + "medium", + "-pix_fmt", + "yuv420p", + "-crf", + "18", export_path, ] @@ -299,4 +402,7 @@ def launch_gource( gource_proc.stdout.close() return ffmpeg_proc else: - return subprocess.Popen(cmd) + popen_kwargs: dict[str, object] = {} + if live_stream: + popen_kwargs.update({"stdin": subprocess.PIPE, "text": True}) + return subprocess.Popen(cmd, **popen_kwargs) diff --git a/clawteam/board/renderer.py b/clawteam/board/renderer.py index 9150497e..1a47816f 100644 --- a/clawteam/board/renderer.py +++ b/clawteam/board/renderer.py @@ -50,7 +50,7 @@ def render_team_board_live(self, collector, team_name: str, interval: float = 2. """Render a live-refreshing team board. Ctrl+C to stop.""" running = True notify_counter = 0 - NOTIFY_INTERVAL = 5 # auto_notify every N refresh cycles + notify_interval = 5 # auto_notify every N refresh cycles def _handle_signal(signum, frame): nonlocal running @@ -75,7 +75,7 @@ def _handle_signal(signum, frame): # Periodically run conflict auto-notification notify_counter += 1 - if notify_counter >= NOTIFY_INTERVAL: + if notify_counter >= notify_interval: notify_counter = 0 try: from clawteam.team.mailbox import MailboxManager diff --git a/clawteam/workspace/conflicts.py b/clawteam/workspace/conflicts.py index a0484902..9991c615 100644 --- a/clawteam/workspace/conflicts.py +++ b/clawteam/workspace/conflicts.py @@ -5,14 +5,13 @@ from pathlib import Path from clawteam.workspace import git -from clawteam.workspace.context import file_owners, _ws_manager, _agent_branch, _base_branch -from clawteam.workspace.manager import _load_registry - +from clawteam.workspace.context import _agent_branch, _base_branch, _ws_manager, file_owners # --------------------------------------------------------------------------- # detect_overlaps # --------------------------------------------------------------------------- + def detect_overlaps(team_name: str, repo: str | None = None) -> list[dict]: """Detect files modified by multiple agents. @@ -24,7 +23,6 @@ def detect_overlaps(team_name: str, repo: str | None = None) -> list[dict]: """ owners = file_owners(team_name, repo) mgr = _ws_manager(repo) - registry = _load_registry(team_name, str(mgr.repo_root)) overlaps: list[dict] = [] for fname, agents in owners.items(): @@ -33,11 +31,13 @@ def detect_overlaps(team_name: str, repo: str | None = None) -> list[dict]: # Determine severity by checking if changed lines overlap severity = _compute_severity(fname, agents, team_name, mgr) - overlaps.append({ - "file": fname, - "agents": agents, - "severity": severity, - }) + overlaps.append( + { + "file": fname, + "agents": agents, + "severity": severity, + } + ) # Sort: high first order = {"high": 0, "medium": 1, "low": 2} @@ -46,7 +46,10 @@ def detect_overlaps(team_name: str, repo: str | None = None) -> list[dict]: def _changed_lines( - fname: str, branch: str, base: str, repo_root: Path, + fname: str, + branch: str, + base: str, + repo_root: Path, ) -> set[int]: """Return set of line numbers changed by branch for a specific file.""" try: @@ -93,7 +96,10 @@ def _compute_severity( branch = ws.branch_name base = ws.base_branch agent_lines[agent_name] = _changed_lines( - fname, branch, base, mgr.repo_root, + fname, + branch, + base, + mgr.repo_root, ) # Check pairwise overlap @@ -112,8 +118,12 @@ def _compute_severity( # check_conflicts # --------------------------------------------------------------------------- + def check_conflicts( - team_name: str, agent_a: str, agent_b: str, repo: str | None = None, + team_name: str, + agent_a: str, + agent_b: str, + repo: str | None = None, ) -> list[dict]: """Check for conflicts between two specific agents. @@ -128,7 +138,8 @@ def check_conflicts( try: files_a_raw = git._run( ["diff", "--name-only", f"{base_a}...{branch_a}"], - cwd=mgr.repo_root, check=False, + cwd=mgr.repo_root, + check=False, ) files_a = set(files_a_raw.splitlines()) if files_a_raw else set() except Exception: @@ -138,7 +149,8 @@ def check_conflicts( try: files_b_raw = git._run( ["diff", "--name-only", f"{base_b}...{branch_b}"], - cwd=mgr.repo_root, check=False, + cwd=mgr.repo_root, + check=False, ) files_b = set(files_b_raw.splitlines()) if files_b_raw else set() except Exception: @@ -153,16 +165,18 @@ def check_conflicts( lines_a = _changed_lines(fname, branch_a, base_a, mgr.repo_root) lines_b = _changed_lines(fname, branch_b, base_b, mgr.repo_root) overlap = lines_a & lines_b - results.append({ - "file": fname, - "conflict_markers": bool(overlap), - "details": ( - f"Lines {sorted(overlap)[:10]}{'...' if len(overlap) > 10 else ''} " - f"changed by both agents" - if overlap - else f"Different lines modified (A: {len(lines_a)}, B: {len(lines_b)})" - ), - }) + results.append( + { + "file": fname, + "conflict_markers": bool(overlap), + "details": ( + f"Lines {sorted(overlap)[:10]}{'...' if len(overlap) > 10 else ''} " + f"changed by both agents" + if overlap + else f"Different lines modified (A: {len(lines_a)}, B: {len(lines_b)})" + ), + } + ) return results @@ -171,6 +185,7 @@ def check_conflicts( # auto_notify # --------------------------------------------------------------------------- + def auto_notify(team_name: str, mailbox_mgr, repo: str | None = None) -> int: """Scan for overlaps and send warning messages to affected agents. @@ -210,8 +225,11 @@ def auto_notify(team_name: str, mailbox_mgr, repo: str | None = None) -> int: # suggest_rebase # --------------------------------------------------------------------------- + def suggest_rebase( - team_name: str, agent_name: str, repo: str | None = None, + team_name: str, + agent_name: str, + repo: str | None = None, ) -> str | None: """Suggest whether an agent should rebase onto the base branch. @@ -225,7 +243,8 @@ def suggest_rebase( try: behind_raw = git._run( ["rev-list", "--count", f"{branch}..{base}"], - cwd=mgr.repo_root, check=False, + cwd=mgr.repo_root, + check=False, ) behind = int(behind_raw) if behind_raw.strip().isdigit() else 0 except Exception: @@ -238,7 +257,8 @@ def suggest_rebase( try: base_files_raw = git._run( ["diff", "--name-only", f"{branch}..{base}"], - cwd=mgr.repo_root, check=False, + cwd=mgr.repo_root, + check=False, ) base_files = set(base_files_raw.splitlines()) if base_files_raw else set() except Exception: @@ -247,7 +267,8 @@ def suggest_rebase( try: agent_files_raw = git._run( ["diff", "--name-only", f"{base}..{branch}"], - cwd=mgr.repo_root, check=False, + cwd=mgr.repo_root, + check=False, ) agent_files = set(agent_files_raw.splitlines()) if agent_files_raw else set() except Exception: diff --git a/clawteam/workspace/context.py b/clawteam/workspace/context.py index 12856470..a030f870 100644 --- a/clawteam/workspace/context.py +++ b/clawteam/workspace/context.py @@ -2,16 +2,35 @@ from __future__ import annotations -import re -from datetime import datetime, timezone +import json from pathlib import Path +from clawteam.team.models import get_data_dir from clawteam.workspace import git from clawteam.workspace.manager import WorkspaceManager, _load_registry -def _ws_manager(repo: str | None = None) -> WorkspaceManager: - path = Path(repo) if repo else None +def _registry_repo_root(team_name: str) -> str | None: + path = get_data_dir() / "workspaces" / team_name / "workspace-registry.json" + if not path.exists(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + repo_root = data.get("repo_root") + if not isinstance(repo_root, str) or not repo_root: + return None + return repo_root + + +def _resolve_repo_path(team_name: str, repo: str | None = None) -> str | None: + return repo or _registry_repo_root(team_name) + + +def _ws_manager(team_name: str, repo: str | None = None) -> WorkspaceManager: + resolved_repo = _resolve_repo_path(team_name, repo) + path = Path(resolved_repo) if resolved_repo else None mgr = WorkspaceManager.try_create(path) if mgr is None: raise RuntimeError("Not inside a git repository") @@ -36,7 +55,7 @@ def agent_diff(team_name: str, agent_name: str, repo: str | None = None) -> dict Keys: files_changed, insertions, deletions, diff_stat, commit_count, summary """ - mgr = _ws_manager(repo) + mgr = _ws_manager(team_name, repo) branch = _agent_branch(team_name, agent_name) base = _base_branch(team_name, agent_name, mgr) root = mgr.repo_root @@ -99,7 +118,7 @@ def agent_diff(team_name: str, agent_name: str, repo: str | None = None) -> dict def file_owners(team_name: str, repo: str | None = None) -> dict[str, list[str]]: """Map each modified file to the list of agents that touched it.""" - mgr = _ws_manager(repo) + mgr = _ws_manager(team_name, repo) registry = _load_registry(team_name, str(mgr.repo_root)) owners: dict[str, list[str]] = {} @@ -132,7 +151,7 @@ def cross_branch_log( team_name: str, limit: int = 50, repo: str | None = None, ) -> list[dict]: """Unified commit log across all agent branches, newest first.""" - mgr = _ws_manager(repo) + mgr = _ws_manager(team_name, repo) registry = _load_registry(team_name, str(mgr.repo_root)) entries: list[dict] = [] @@ -211,9 +230,6 @@ def inject_context( - File overlap warnings - Upstream dependency diffs (if task has blocked_by) """ - mgr = _ws_manager(repo) - registry = _load_registry(team_name, str(mgr.repo_root)) - # Files the target agent is modifying target_diff = agent_diff(team_name, target_agent, repo) target_files = set(target_diff["files_changed"]) diff --git a/tests/test_context.py b/tests/test_context.py new file mode 100644 index 00000000..087ab65e --- /dev/null +++ b/tests/test_context.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import json + +from clawteam.workspace.context import _resolve_repo_path + + +def test_resolve_repo_path_uses_workspace_registry(isolated_data_dir): + repo_root = isolated_data_dir / "demo-repo" + repo_root.mkdir() + registry_path = ( + isolated_data_dir + / "workspaces" + / "demo-team" + / "workspace-registry.json" + ) + registry_path.parent.mkdir(parents=True, exist_ok=True) + registry_path.write_text( + json.dumps( + { + "team_name": "demo-team", + "repo_root": str(repo_root), + "workspaces": [], + } + ), + encoding="utf-8", + ) + + assert _resolve_repo_path("demo-team") == str(repo_root) + + +def test_resolve_repo_path_prefers_explicit_repo(isolated_data_dir): + registry_path = ( + isolated_data_dir + / "workspaces" + / "demo-team" + / "workspace-registry.json" + ) + registry_path.parent.mkdir(parents=True, exist_ok=True) + registry_path.write_text( + json.dumps( + { + "team_name": "demo-team", + "repo_root": "/tmp/registry-repo", + "workspaces": [], + } + ), + encoding="utf-8", + ) + + assert _resolve_repo_path("demo-team", "/tmp/explicit-repo") == "/tmp/explicit-repo" diff --git a/tests/test_gource.py b/tests/test_gource.py new file mode 100644 index 00000000..0ff9ed81 --- /dev/null +++ b/tests/test_gource.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from clawteam.board.gource import ( + append_log_lines, + collect_live_log_lines, + generate_event_log, + generate_git_log, + launch_gource, +) + + +def test_collect_live_log_lines_returns_only_unseen(monkeypatch): + monkeypatch.setattr( + "clawteam.board.gource.generate_combined_log", + lambda team, repo=None: [ + "1|alice|A|/tasks/1", + "2|bob|M|/tasks/2", + ], + ) + + seen = {"1|alice|A|/tasks/1"} + assert collect_live_log_lines(seen, "demo") == ["2|bob|M|/tasks/2"] + + +def test_append_log_lines_writes_and_flushes(): + class DummyStream: + def __init__(self): + self.data = "" + self.flushed = False + + def write(self, text): + self.data += text + + def flush(self): + self.flushed = True + + stream = DummyStream() + append_log_lines(stream, ["1|alice|A|/a", "2|bob|M|/b"]) + + assert stream.data == "1|alice|A|/a\n2|bob|M|/b\n" + assert stream.flushed is True + + +def test_launch_gource_live_stream_uses_stdin(monkeypatch): + captured: dict[str, object] = {} + + class DummyProcess: + def __init__(self): + self.stdin = object() + + monkeypatch.setattr("clawteam.board.gource.find_gource", lambda: "/usr/bin/gource") + + def fake_popen(cmd, **kwargs): + captured["cmd"] = cmd + captured["kwargs"] = kwargs + return DummyProcess() + + monkeypatch.setattr("clawteam.board.gource.subprocess.Popen", fake_popen) + + proc = launch_gource( + log_file=None, + title="Demo", + live_stream=True, + ) + + assert proc is not None + assert captured["cmd"][1] == "-" + assert "--realtime" in captured["cmd"] + assert captured["kwargs"]["stdin"] is not None + assert captured["kwargs"]["text"] is True + + +def test_generate_event_log_uses_message_sender_and_member_aliases(monkeypatch): + monkeypatch.setattr( + "clawteam.board.gource.BoardCollector.collect_team", + lambda self, team: { + "members": [ + {"name": "leader", "user": "alice", "joinedAt": "2026-03-20T08:40:03+00:00"}, + {"name": "backend", "joinedAt": "2026-03-20T08:40:40+00:00"}, + ], + "tasks": {"pending": [], "in_progress": [], "completed": [], "blocked": []}, + "messages": [ + { + "from": "alice_leader", + "to": "backend", + "type": "message", + "timestamp": "2026-03-20T08:41:23+00:00", + } + ], + }, + ) + + lines = generate_event_log("demo") + + assert any("|leader|M|/messages/leader/backend/message" in line for line in lines) + assert all("/messages/unknown/" not in line for line in lines) + + +def test_generate_git_log_normalizes_duplicate_path_segments(monkeypatch): + monkeypatch.setattr( + "clawteam.workspace.context.cross_branch_log", + lambda team, limit=500, repo=None: [ + { + "agent": "backend", + "timestamp": "2026-03-20T08:41:22+00:00", + "files": ["backend/app.py", "shared/api-contract.md"], + } + ], + ) + monkeypatch.setattr( + "clawteam.workspace.context.file_owners", + lambda team, repo=None: {"shared/api-contract.md": ["frontend", "backend"]}, + ) + + lines = generate_git_log("demo") + + assert any("|backend|M|/backend/app.py" in line for line in lines) + assert any("|backend|M|/shared/api-contract.md" in line for line in lines) + assert all("/backend/backend/" not in line for line in lines) + assert all("/shared/shared/" not in line for line in lines)