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 @@
-**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)
@@ -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.**
@@ -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.**
@@ -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 @@
-**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)
@@ -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.
@@ -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.
@@ -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)