first commit

This commit is contained in:
2026-04-20 16:01:59 +08:00
parent 6bcd073ba0
commit b03c079099
103 changed files with 19736 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
"""
Multi-Agent Pipeline Scripts.
This module provides orchestration for multi-agent workflows.
"""
+17
View File
@@ -0,0 +1,17 @@
"""Bootstrap path setup for multi_agent scripts.
Import this module before importing from common/:
import _bootstrap # noqa: F401
This adds the parent scripts/ directory to sys.path so that
`from common.xxx import yyy` works when running scripts directly
via `python3 .trellis/scripts/multi_agent/some_script.py`.
"""
import sys
from pathlib import Path
_scripts_dir = str(Path(__file__).resolve().parent.parent)
if _scripts_dir not in sys.path:
sys.path.insert(0, _scripts_dir)
+398
View File
@@ -0,0 +1,398 @@
#!/usr/bin/env python3
"""
Multi-Agent Pipeline: Cleanup Worktree.
Usage:
python3 cleanup.py <branch-name> Remove specific worktree
python3 cleanup.py --list List all worktrees
python3 cleanup.py --merged Remove merged worktrees
python3 cleanup.py --all Remove all worktrees (with confirmation)
Options:
-y, --yes Skip confirmation prompts
--keep-branch Don't delete the git branch
This script:
1. Archives task directory to archive/{YYYY-MM}/
2. Removes agent from registry
3. Removes git worktree
4. Optionally deletes git branch
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path
import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path
from common.git import run_git
from common.log import Colors, log_info, log_success, log_warn, log_error
from common.paths import FILE_TASK_JSON, get_repo_root
from common.registry import (
registry_get_file,
registry_get_task_dir,
registry_remove_by_id,
registry_remove_by_worktree,
registry_search_agent,
)
from common.task_utils import (
archive_task_complete,
is_safe_task_path,
)
# Colors, log_info, log_success, log_warn, log_error
# are now imported from common.log above.
def confirm(prompt: str, skip_confirm: bool) -> bool:
"""Ask for confirmation."""
if skip_confirm:
return True
if not sys.stdin.isatty():
log_error("Non-interactive mode detected. Use -y to skip confirmation.")
return False
response = input(f"{prompt} [y/N] ")
return response.lower() in ("y", "yes")
def _warn_submodule_prs(task_dir_abs: Path) -> None:
"""Print reminders for any open submodule PRs found in task.json."""
task_json = task_dir_abs / FILE_TASK_JSON
if not task_json.is_file():
return
try:
task_data = json.loads(task_json.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return
submodule_prs = task_data.get("submodule_prs")
if not isinstance(submodule_prs, dict) or not submodule_prs:
return
for name, url in submodule_prs.items():
log_warn(f"Submodule PR still open: {name} -> {url}")
log_info("Remember to close/merge submodule PRs before cleanup")
# =============================================================================
# Commands
# =============================================================================
def cmd_list(repo_root: Path) -> int:
"""List worktrees."""
print(f"{Colors.BLUE}=== Git Worktrees ==={Colors.NC}")
print()
subprocess.run(["git", "worktree", "list"], cwd=repo_root)
print()
# Show registry info
registry_file = registry_get_file(repo_root)
if registry_file and registry_file.is_file():
print(f"{Colors.BLUE}=== Registered Agents ==={Colors.NC}")
print()
data = json.loads(registry_file.read_text(encoding="utf-8"))
agents = data.get("agents", [])
if agents:
for agent in agents:
print(
f" {agent.get('id', '?')}: PID={agent.get('pid', '?')} [{agent.get('worktree_path', '?')}]"
)
else:
print(" (none)")
print()
return 0
def archive_task(worktree_path: str, repo_root: Path) -> None:
"""Archive task directory."""
task_dir = registry_get_task_dir(worktree_path, repo_root)
if not task_dir or not is_safe_task_path(task_dir, repo_root):
return
task_dir_abs = repo_root / task_dir
if not task_dir_abs.is_dir():
return
result = archive_task_complete(task_dir_abs, repo_root)
if "archived_to" in result:
dest = Path(result["archived_to"])
log_success(f"Archived task: {dest.name} -> archive/{dest.parent.name}/")
def cleanup_registry_only(search: str, repo_root: Path, skip_confirm: bool) -> int:
"""Cleanup from registry only (no worktree)."""
agent_info = registry_search_agent(search, repo_root)
if not agent_info:
log_error(f"No agent found in registry matching: {search}")
return 1
agent_id = agent_info.get("id", "?")
task_dir = agent_info.get("task_dir", "?")
print()
print(f"{Colors.BLUE}=== Cleanup Agent (no worktree) ==={Colors.NC}")
print(f" Agent ID: {agent_id}")
print(f" Task Dir: {task_dir}")
print()
if not confirm("Archive task and remove from registry?", skip_confirm):
log_info("Aborted")
return 0
# Check for submodule PRs and archive task directory
if task_dir and is_safe_task_path(task_dir, repo_root):
task_dir_abs = repo_root / task_dir
if task_dir_abs.is_dir():
_warn_submodule_prs(task_dir_abs)
result = archive_task_complete(task_dir_abs, repo_root)
if "archived_to" in result:
dest = Path(result["archived_to"])
log_success(
f"Archived task: {dest.name} -> archive/{dest.parent.name}/"
)
else:
log_warn("Invalid task_dir in registry, skipping archive")
# Remove from registry
registry_remove_by_id(agent_id, repo_root)
log_success(f"Removed from registry: {agent_id}")
log_success("Cleanup complete")
return 0
def cleanup_worktree(
branch: str, repo_root: Path, skip_confirm: bool, keep_branch: bool
) -> int:
"""Cleanup single worktree."""
# Find worktree path for branch
_, worktree_list, _ = run_git(
["worktree", "list", "--porcelain"], cwd=repo_root
)
worktree_path = None
current_worktree = None
for line in worktree_list.splitlines():
if line.startswith("worktree "):
current_worktree = line[9:] # Remove "worktree " prefix
elif line.startswith("branch refs/heads/"):
current_branch = line[18:] # Remove "branch refs/heads/" prefix
if current_branch == branch:
worktree_path = current_worktree
break
if not worktree_path:
# No worktree found, try to cleanup from registry only
log_warn(f"No worktree found for: {branch}")
log_info("Trying to cleanup from registry...")
return cleanup_registry_only(branch, repo_root, skip_confirm)
print()
print(f"{Colors.BLUE}=== Cleanup Worktree ==={Colors.NC}")
print(f" Branch: {branch}")
print(f" Worktree: {worktree_path}")
print()
if not confirm("Remove this worktree?", skip_confirm):
log_info("Aborted")
return 0
# 1. Archive task (and check for submodule PRs)
task_dir = registry_get_task_dir(worktree_path, repo_root)
if task_dir and is_safe_task_path(task_dir, repo_root):
task_dir_abs_for_warn = repo_root / task_dir
if task_dir_abs_for_warn.is_dir():
_warn_submodule_prs(task_dir_abs_for_warn)
archive_task(worktree_path, repo_root)
# 2. Remove from registry
registry_remove_by_worktree(worktree_path, repo_root)
log_info("Removed from registry")
# 3. Remove worktree
log_info("Removing worktree...")
ret, _, _ = run_git(
["worktree", "remove", worktree_path, "--force"], cwd=repo_root
)
if ret != 0:
# Try removing directory manually
try:
shutil.rmtree(worktree_path)
except Exception as e:
log_error(f"Failed to remove worktree: {e}")
log_success("Worktree removed")
# 4. Delete branch (optional)
if not keep_branch:
log_info("Deleting branch...")
ret, _, _ = run_git(["branch", "-D", branch], cwd=repo_root)
if ret != 0:
log_warn("Could not delete branch (may be checked out elsewhere)")
log_success(f"Cleanup complete for: {branch}")
return 0
def cmd_merged(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int:
"""Cleanup merged worktrees."""
# Get main branch
_, head_out, _ = run_git(
["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=repo_root
)
main_branch = head_out.strip().replace("refs/remotes/origin/", "") or "main"
print(f"{Colors.BLUE}=== Finding Merged Worktrees ==={Colors.NC}")
print()
# Get merged branches
_, merged_out, _ = run_git(
["branch", "--merged", main_branch], cwd=repo_root
)
merged_branches = []
for line in merged_out.splitlines():
branch = line.strip().lstrip("* ")
if branch and branch != main_branch:
merged_branches.append(branch)
if not merged_branches:
log_info("No merged branches found")
return 0
# Get worktree list
_, worktree_list, _ = run_git(["worktree", "list"], cwd=repo_root)
worktree_branches = []
for branch in merged_branches:
if f"[{branch}]" in worktree_list:
worktree_branches.append(branch)
print(f" - {branch}")
if not worktree_branches:
log_info("No merged worktrees found")
return 0
print()
if not confirm("Remove these merged worktrees?", skip_confirm):
log_info("Aborted")
return 0
for branch in worktree_branches:
cleanup_worktree(branch, repo_root, True, keep_branch)
return 0
def cmd_all(repo_root: Path, skip_confirm: bool, keep_branch: bool) -> int:
"""Cleanup all worktrees."""
print(f"{Colors.BLUE}=== All Worktrees ==={Colors.NC}")
print()
# Get worktree list
_, worktree_list, _ = run_git(
["worktree", "list", "--porcelain"], cwd=repo_root
)
worktrees = []
main_worktree = str(repo_root.resolve())
for line in worktree_list.splitlines():
if line.startswith("worktree "):
wt = line[9:]
if wt != main_worktree:
worktrees.append(wt)
if not worktrees:
log_info("No worktrees to remove")
return 0
for wt in worktrees:
print(f" - {wt}")
print()
print(f"{Colors.RED}WARNING: This will remove ALL worktrees!{Colors.NC}")
if not confirm("Are you sure?", skip_confirm):
log_info("Aborted")
return 0
# Get branch for each worktree
for wt in worktrees:
# Find branch name from worktree list
_, wt_list, _ = run_git(["worktree", "list"], cwd=repo_root)
for line in wt_list.splitlines():
if wt in line:
# Extract branch from [branch] format
import re
match = re.search(r"\[([^\]]+)\]", line)
if match:
branch = match.group(1)
cleanup_worktree(branch, repo_root, True, keep_branch)
break
return 0
# =============================================================================
# Main
# =============================================================================
def main() -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Multi-Agent Pipeline: Cleanup Worktree"
)
parser.add_argument("branch", nargs="?", help="Branch name to cleanup")
parser.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
parser.add_argument(
"--keep-branch", action="store_true", help="Don't delete git branch"
)
parser.add_argument("--list", action="store_true", help="List all worktrees")
parser.add_argument("--merged", action="store_true", help="Remove merged worktrees")
parser.add_argument("--all", action="store_true", help="Remove all worktrees")
args = parser.parse_args()
repo_root = get_repo_root()
if args.list:
return cmd_list(repo_root)
elif args.merged:
return cmd_merged(repo_root, args.yes, args.keep_branch)
elif args.all:
return cmd_all(repo_root, args.yes, args.keep_branch)
elif args.branch:
return cleanup_worktree(args.branch, repo_root, args.yes, args.keep_branch)
else:
print("""Usage:
python3 cleanup.py <branch-name> Remove specific worktree
python3 cleanup.py --list List all worktrees
python3 cleanup.py --merged Remove merged worktrees
python3 cleanup.py --all Remove all worktrees
Options:
-y, --yes Skip confirmation
--keep-branch Don't delete git branch
""")
return 1
if __name__ == "__main__":
sys.exit(main())
+620
View File
@@ -0,0 +1,620 @@
#!/usr/bin/env python3
"""
Multi-Agent Pipeline: Create PR.
Usage:
python3 create_pr.py [task-dir] [--dry-run]
This script:
1. Handles submodule changes (commit, push, PR) if any submodules are configured
2. Stages and commits all main-repo changes (excluding workspace/)
3. Pushes to origin
4. Creates a Draft PR using `gh pr create`
5. Updates task.json with status="completed", pr_url, submodule_prs, and current_phase
Note: This is the only action that performs git commit, as it's the final
step after all implementation and checks are complete.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path
from common.config import get_submodule_packages
from common.git import run_git
from common.io import read_json, write_json
from common.log import Colors
from common.paths import (
DIR_WORKFLOW,
FILE_TASK_JSON,
get_current_task,
get_repo_root,
)
from common.phase import get_phase_for_action
# Colors, read_json, write_json
# are now imported from common.log and common.io above.
# =============================================================================
# Submodule PR Helpers
# =============================================================================
# Warning message prepended to main PR body when submodule PRs exist
_SUBMODULE_SQUASH_WARNING_MARKER = (
"Merge submodule PR(s) first. If squash-merged, update submodule ref after merge."
)
def _get_submodule_default_branch(submodule_abs: Path) -> str:
"""Get the default branch of a submodule repository.
Uses `git symbolic-ref refs/remotes/origin/HEAD` for portability
(no grep, no English-dependent output).
Returns:
Default branch name (e.g. "main"), falls back to "main" on failure.
"""
ret, out, _ = run_git(
["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=submodule_abs
)
if ret == 0 and out.strip():
# Output: "refs/remotes/origin/main" -> "main"
ref = out.strip()
prefix = "refs/remotes/origin/"
if ref.startswith(prefix):
return ref[len(prefix):]
return "main"
def _process_submodule_changes(
repo_root: Path,
current_branch: str,
commit_prefix: str,
scope: str,
task_name: str,
task_data: dict,
task_json: Path,
dry_run: bool,
) -> tuple[dict[str, str], list[str], bool]:
"""Process submodule changes: commit, push, create PRs.
Returns:
Tuple of (submodule_prs dict, changed_submodule_paths list, success bool).
On failure, submodule_prs contains URLs persisted so far.
"""
submodule_packages = get_submodule_packages(repo_root)
if not submodule_packages:
return {}, [], True
# Load existing submodule_prs for incremental merge
raw_prs = task_data.get("submodule_prs")
submodule_prs: dict[str, str] = dict(raw_prs) if isinstance(raw_prs, dict) else {}
# Detect which submodules have changes
changed: list[tuple[str, str]] = [] # (name, path)
for pkg_name, pkg_path in submodule_packages.items():
sub_abs = repo_root / pkg_path
if not sub_abs.is_dir():
continue
ret, status_out, _ = run_git(
["status", "--porcelain"], cwd=sub_abs
)
if ret != 0:
continue
if status_out.strip():
changed.append((pkg_name, pkg_path))
if not changed:
return submodule_prs, [], True
# Determine submodule branch name: <repo-dir-name>/<main-branch>
repo_dir_name = repo_root.name
sub_branch = f"{repo_dir_name}/{current_branch}"
print(f"\n{Colors.BLUE}=== Submodule Changes Detected ==={Colors.NC}")
for pkg_name, pkg_path in changed:
print(f" - {pkg_name} ({pkg_path})")
print()
changed_paths: list[str] = []
for pkg_name, pkg_path in changed:
sub_abs = repo_root / pkg_path
sub_base = _get_submodule_default_branch(sub_abs)
sub_commit_msg = f"{commit_prefix}({scope}): {task_name}"
print(f"{Colors.YELLOW}Processing submodule: {pkg_name} ({pkg_path}){Colors.NC}")
print(f" Submodule base branch: {sub_base}")
print(f" Submodule branch: {sub_branch}")
if dry_run:
print(f" [DRY-RUN] Would checkout branch: {sub_branch}")
print(f" [DRY-RUN] Would commit: {sub_commit_msg}")
print(f" [DRY-RUN] Would push to: origin/{sub_branch}")
print(f" [DRY-RUN] Would create PR: {sub_branch} -> {sub_base}")
submodule_prs[pkg_name] = "https://github.com/example/repo/pull/DRY-RUN"
changed_paths.append(pkg_path)
continue
# --- Checkout or create branch in submodule ---
ret, _, _ = run_git(
["show-ref", "--verify", "--quiet", f"refs/heads/{sub_branch}"],
cwd=sub_abs,
)
if ret == 0:
# Branch exists, checkout
ret, _, err = run_git(
["checkout", sub_branch], cwd=sub_abs
)
if ret != 0:
print(f"{Colors.RED}Failed to checkout branch in {pkg_name}: {err}{Colors.NC}")
return submodule_prs, changed_paths, False
# Check for divergence (reuse risk)
ret_anc, _, _ = run_git(
["merge-base", "--is-ancestor", sub_base, sub_branch],
cwd=sub_abs,
)
if ret_anc != 0:
print(
f" {Colors.YELLOW}[WARN] submodule branch has diverged history, "
f"consider recreating{Colors.NC}"
)
else:
# Create new branch
ret, _, err = run_git(
["checkout", "-b", sub_branch], cwd=sub_abs
)
if ret != 0:
print(f"{Colors.RED}Failed to create branch in {pkg_name}: {err}{Colors.NC}")
return submodule_prs, changed_paths, False
# --- Stage and commit ---
run_git(["add", "-A"], cwd=sub_abs)
ret, _, _ = run_git(["diff", "--cached", "--quiet"], cwd=sub_abs)
if ret != 0:
# Has staged changes
ret, _, err = run_git(
["commit", "-m", sub_commit_msg], cwd=sub_abs
)
if ret != 0:
print(f"{Colors.RED}Failed to commit in {pkg_name}: {err}{Colors.NC}")
return submodule_prs, changed_paths, False
print(f" {Colors.GREEN}Committed in {pkg_name}{Colors.NC}")
else:
print(f" No staged changes in {pkg_name}, skipping commit")
# --- Push ---
ret, _, err = run_git(
["push", "-u", "origin", sub_branch], cwd=sub_abs
)
if ret != 0:
print(f"{Colors.RED}Failed to push {pkg_name}: {err}{Colors.NC}")
return submodule_prs, changed_paths, False
print(f" {Colors.GREEN}Pushed {pkg_name} to origin/{sub_branch}{Colors.NC}")
# --- Create or reuse PR ---
result = subprocess.run(
[
"gh", "pr", "list",
"--head", sub_branch,
"--base", sub_base,
"--json", "url",
"--jq", ".[0].url",
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
cwd=str(sub_abs),
)
existing_sub_pr = result.stdout.strip()
if existing_sub_pr:
print(f" {Colors.YELLOW}PR already exists: {existing_sub_pr}{Colors.NC}")
sub_pr_url = existing_sub_pr
else:
result = subprocess.run(
[
"gh", "pr", "create",
"--draft",
"--base", sub_base,
"--title", f"{commit_prefix}({scope}): {task_name} [{pkg_name}]",
"--body", f"Submodule changes for {task_name}",
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
cwd=str(sub_abs),
)
if result.returncode != 0:
print(
f"{Colors.RED}Failed to create PR for {pkg_name}: "
f"{result.stderr}{Colors.NC}"
)
return submodule_prs, changed_paths, False
sub_pr_url = result.stdout.strip()
print(f" {Colors.GREEN}PR created for {pkg_name}: {sub_pr_url}{Colors.NC}")
# Persist immediately (incremental, supports re-entry)
submodule_prs[pkg_name] = sub_pr_url
task_data["submodule_prs"] = submodule_prs
write_json(task_json, task_data)
changed_paths.append(pkg_path)
return submodule_prs, changed_paths, True
def _build_submodule_warning(submodule_prs: dict[str, str]) -> str:
"""Build the squash-merge warning block for the main PR body."""
pr_lines = "\n".join(f"> - {name}: {url}" for name, url in submodule_prs.items())
return (
f"> {_SUBMODULE_SQUASH_WARNING_MARKER}\n"
f">\n"
f"> Submodule PRs:\n"
f"{pr_lines}\n"
f"\n---\n\n"
)
def _ensure_submodule_warning_on_existing_pr(
submodule_prs: dict[str, str],
dry_run: bool,
) -> None:
"""Read-modify-write: add squash warning to existing PR if missing."""
if dry_run:
print("[DRY-RUN] Would check/add submodule warning to existing PR")
return
# Read current PR body
result = subprocess.run(
[
"gh", "pr", "view",
"--json", "body",
"--jq", ".body",
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode != 0:
return
current_body = result.stdout.strip()
if _SUBMODULE_SQUASH_WARNING_MARKER in current_body:
return # Warning already present
# Prepend warning to existing body
warning = _build_submodule_warning(submodule_prs)
new_body = warning + current_body
subprocess.run(
["gh", "pr", "edit", "--body", new_body],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
print(f" {Colors.GREEN}Added submodule merge warning to existing PR{Colors.NC}")
# =============================================================================
# Main
# =============================================================================
def main() -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Create PR")
parser.add_argument("dir", nargs="?", help="Task directory")
parser.add_argument(
"--dry-run", action="store_true", help="Show what would be done"
)
args = parser.parse_args()
repo_root = get_repo_root()
# =============================================================================
# Get Task Directory
# =============================================================================
target_dir = args.dir
if not target_dir:
# Try to get from .current-task
current_task = get_current_task(repo_root)
if current_task:
target_dir = current_task
if not target_dir:
print(
f"{Colors.RED}Error: No task directory specified and no current task set{Colors.NC}"
)
print("Usage: python3 create_pr.py [task-dir] [--dry-run]")
return 1
# Support relative paths
if not target_dir.startswith("/"):
target_dir_path = repo_root / target_dir
else:
target_dir_path = Path(target_dir)
task_json = target_dir_path / FILE_TASK_JSON
if not task_json.is_file():
print(f"{Colors.RED}Error: task.json not found at {target_dir_path}{Colors.NC}")
return 1
# =============================================================================
# Main
# =============================================================================
print(f"{Colors.BLUE}=== Create PR ==={Colors.NC}")
if args.dry_run:
print(
f"{Colors.YELLOW}[DRY-RUN MODE] No actual changes will be made{Colors.NC}"
)
print()
# Read task config
task_data = read_json(task_json)
if not task_data:
print(f"{Colors.RED}Error: Failed to read task.json{Colors.NC}")
return 1
task_name = task_data.get("name", "")
base_branch = task_data.get("base_branch", "main")
scope = task_data.get("scope", "core")
dev_type = task_data.get("dev_type", "feature")
# Map dev_type to commit prefix
prefix_map = {
"feature": "feat",
"frontend": "feat",
"backend": "feat",
"fullstack": "feat",
"bugfix": "fix",
"fix": "fix",
"refactor": "refactor",
"docs": "docs",
"test": "test",
}
commit_prefix = prefix_map.get(dev_type, "feat")
print(f"Task: {task_name}")
print(f"Base branch: {base_branch}")
print(f"Scope: {scope}")
print(f"Commit prefix: {commit_prefix}")
print()
# Get current branch
_, branch_out, _ = run_git(["branch", "--show-current"])
current_branch = branch_out.strip()
print(f"Current branch: {current_branch}")
# =============================================================================
# Submodule PR Flow (runs BEFORE main repo staging)
# =============================================================================
submodule_prs, changed_submodule_paths, sub_success = _process_submodule_changes(
repo_root=repo_root,
current_branch=current_branch,
commit_prefix=commit_prefix,
scope=scope,
task_name=task_name,
task_data=task_data,
task_json=task_json,
dry_run=args.dry_run,
)
if not sub_success:
print(
f"\n{Colors.RED}Submodule PR flow failed. "
f"Skipping main repo commit/PR.{Colors.NC}"
)
print("Already-created submodule PRs have been saved to task.json.")
return 1
# =============================================================================
# Main Repo: Stage, Commit, Push, PR
# =============================================================================
# Check for changes
print(f"{Colors.YELLOW}Checking for changes...{Colors.NC}")
# Stage changes
run_git(["add", "-A"])
# Exclude workspace and temp files
run_git(["reset", f"{DIR_WORKFLOW}/workspace/"])
run_git(["reset", ".agent-log", ".session-id"])
# If submodules changed, ensure their ref updates are staged
for sub_path in changed_submodule_paths:
run_git(["add", sub_path])
# Check if there are staged changes
ret, _, _ = run_git(["diff", "--cached", "--quiet"])
has_staged_changes = ret != 0
if not has_staged_changes:
print(f"{Colors.YELLOW}No staged changes to commit{Colors.NC}")
# Check for unpushed commits
ret, log_out, _ = run_git(
["log", f"origin/{current_branch}..HEAD", "--oneline"]
)
unpushed = len([line for line in log_out.splitlines() if line.strip()])
if unpushed == 0:
if args.dry_run:
run_git(["reset", "HEAD"])
print(f"{Colors.RED}No changes to create PR{Colors.NC}")
return 1
print(f"Found {unpushed} unpushed commit(s)")
else:
# Commit changes
print(f"{Colors.YELLOW}Committing changes...{Colors.NC}")
commit_msg = f"{commit_prefix}({scope}): {task_name}"
if args.dry_run:
print(f"[DRY-RUN] Would commit with message: {commit_msg}")
print("[DRY-RUN] Staged files:")
_, staged_out, _ = run_git(["diff", "--cached", "--name-only"])
for line in staged_out.splitlines():
print(f" - {line}")
else:
run_git(["commit", "-m", commit_msg])
print(f"{Colors.GREEN}Committed: {commit_msg}{Colors.NC}")
# Push to remote
print(f"{Colors.YELLOW}Pushing to remote...{Colors.NC}")
if args.dry_run:
print(f"[DRY-RUN] Would push to: origin/{current_branch}")
else:
ret, _, err = run_git(["push", "-u", "origin", current_branch])
if ret != 0:
print(f"{Colors.RED}Failed to push: {err}{Colors.NC}")
return 1
print(f"{Colors.GREEN}Pushed to origin/{current_branch}{Colors.NC}")
# Create PR
print(f"{Colors.YELLOW}Creating PR...{Colors.NC}")
pr_title = f"{commit_prefix}({scope}): {task_name}"
pr_url = ""
# Build PR body with optional submodule warning
has_submodule_prs = bool(submodule_prs)
if args.dry_run:
print("[DRY-RUN] Would create PR:")
print(f" Title: {pr_title}")
print(f" Base: {base_branch}")
print(f" Head: {current_branch}")
prd_file = target_dir_path / "prd.md"
if prd_file.is_file():
print(" Body: (from prd.md)")
if has_submodule_prs:
print(" Body includes submodule merge warning")
pr_url = "https://github.com/example/repo/pull/DRY-RUN"
else:
# Check if PR already exists
result = subprocess.run(
[
"gh",
"pr",
"list",
"--head",
current_branch,
"--base",
base_branch,
"--json",
"url",
"--jq",
".[0].url",
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
existing_pr = result.stdout.strip()
if existing_pr:
print(f"{Colors.YELLOW}PR already exists: {existing_pr}{Colors.NC}")
pr_url = existing_pr
# Read-modify-write: add submodule warning if missing
if has_submodule_prs:
_ensure_submodule_warning_on_existing_pr(
submodule_prs, args.dry_run
)
else:
# Read PRD as PR body
pr_body = ""
prd_file = target_dir_path / "prd.md"
if prd_file.is_file():
pr_body = prd_file.read_text(encoding="utf-8")
# Prepend submodule warning if applicable
if has_submodule_prs:
pr_body = _build_submodule_warning(submodule_prs) + pr_body
# Create PR
result = subprocess.run(
[
"gh",
"pr",
"create",
"--draft",
"--base",
base_branch,
"--title",
pr_title,
"--body",
pr_body,
],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode != 0:
print(f"{Colors.RED}Failed to create PR: {result.stderr}{Colors.NC}")
return 1
pr_url = result.stdout.strip()
print(f"{Colors.GREEN}PR created: {pr_url}{Colors.NC}")
# Update task.json
print(f"{Colors.YELLOW}Updating task status...{Colors.NC}")
if args.dry_run:
print("[DRY-RUN] Would update task.json:")
print(" status: completed")
print(f" pr_url: {pr_url}")
if has_submodule_prs:
print(f" submodule_prs: {submodule_prs}")
print(" current_phase: (set to create-pr phase)")
else:
# Get the phase number for create-pr action
create_pr_phase = get_phase_for_action(task_json, "create-pr")
if not create_pr_phase:
create_pr_phase = 4 # Default fallback
task_data["status"] = "completed"
task_data["pr_url"] = pr_url
task_data["current_phase"] = create_pr_phase
if has_submodule_prs:
task_data["submodule_prs"] = submodule_prs
write_json(task_json, task_data)
print(
f"{Colors.GREEN}Task status updated to 'completed', phase {create_pr_phase}{Colors.NC}"
)
# In dry-run, reset the staging area
if args.dry_run:
run_git(["reset", "HEAD"])
print()
print(f"{Colors.GREEN}=== PR Created Successfully ==={Colors.NC}")
print(f"PR URL: {pr_url}")
if has_submodule_prs:
print("Submodule PRs:")
for name, url in submodule_prs.items():
print(f" - {name}: {url}")
return 0
if __name__ == "__main__":
sys.exit(main())
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""
Multi-Agent Pipeline: Plan Agent Launcher.
Usage: python3 plan.py --name <task-name> --type <dev-type> --requirement "<requirement>"
This script:
1. Creates task directory
2. Starts Plan Agent in background
3. Plan Agent produces fully configured task directory
After completion, use start.py to launch the Dispatch Agent.
Prerequisites:
- agents/plan.md must exist (in .claude/, .cursor/, .iflow/, or .opencode/)
- Developer must be initialized
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
from pathlib import Path
import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path
from common.cli_adapter import get_cli_adapter
from common.log import Colors, log_info, log_success, log_error
from common.paths import get_repo_root
from common.developer import ensure_developer
# =============================================================================
# Constants
# =============================================================================
DEFAULT_PLATFORM = "claude"
# =============================================================================
# Main
# =============================================================================
def main() -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Multi-Agent Pipeline: Plan Agent Launcher"
)
parser.add_argument("--name", "-n", required=True, help="Task name (e.g., user-auth)")
parser.add_argument("--type", "-t", required=True, help="Dev type: backend|frontend|fullstack")
parser.add_argument("--requirement", "-r", required=True, help="Requirement description")
parser.add_argument(
"--platform", "-p",
choices=["claude", "cursor", "iflow", "opencode", "codex", "qoder"],
default=DEFAULT_PLATFORM,
help="Platform to use (default: claude)"
)
args = parser.parse_args()
task_name = args.name
dev_type = args.type
requirement = args.requirement
platform = args.platform
# Initialize CLI adapter
adapter = get_cli_adapter(platform)
# Validate dev type
if dev_type not in ("backend", "frontend", "fullstack"):
log_error(f"Invalid dev type: {dev_type} (must be: backend, frontend, fullstack)")
return 1
project_root = get_repo_root()
# Check plan agent exists (path varies by platform)
if adapter.requires_agent_definition_file:
plan_md = adapter.get_agent_path("plan", project_root)
if not plan_md.is_file():
log_error(f"Agent definition not found at {plan_md}")
log_info(f"Platform: {platform}")
return 1
ensure_developer(project_root)
# =============================================================================
# Step 1: Create Task Directory
# =============================================================================
print()
print(f"{Colors.BLUE}=== Multi-Agent Pipeline: Plan ==={Colors.NC}")
log_info(f"Task: {task_name}")
log_info(f"Type: {dev_type}")
log_info(f"Requirement: {requirement}")
print()
log_info("Step 1: Creating task directory...")
# Import task module to create task
from task import cmd_create
import argparse as ap
# Create task using task.py's create command
create_args = ap.Namespace(
title=requirement,
slug=task_name,
assignee=None,
priority="P2",
description=""
)
# Capture stdout to get task dir
import io
from contextlib import redirect_stdout
stdout_capture = io.StringIO()
with redirect_stdout(stdout_capture):
ret = cmd_create(create_args)
if ret != 0:
log_error("Failed to create task directory")
return 1
task_dir = stdout_capture.getvalue().strip().split("\n")[-1]
task_dir_abs = project_root / task_dir
log_success(f"Task directory: {task_dir}")
# =============================================================================
# Step 2: Prepare and Start Plan Agent
# =============================================================================
log_info("Step 2: Starting Plan Agent in background...")
log_file = task_dir_abs / ".plan-log"
log_file.touch()
# Get proxy environment variables
https_proxy = os.environ.get("https_proxy", "")
http_proxy = os.environ.get("http_proxy", "")
all_proxy = os.environ.get("all_proxy", "")
# Start agent in background (cross-platform, no shell script needed)
env = os.environ.copy()
env["PLAN_TASK_NAME"] = task_name
env["PLAN_DEV_TYPE"] = dev_type
env["PLAN_TASK_DIR"] = task_dir
env["PLAN_REQUIREMENT"] = requirement
env["https_proxy"] = https_proxy
env["http_proxy"] = http_proxy
env["all_proxy"] = all_proxy
# Clear nested-session detection so the new CLI process can start
env.pop("CLAUDECODE", None)
# Set non-interactive env var based on platform
env.update(adapter.get_non_interactive_env())
# Build CLI command using adapter
cli_cmd = adapter.build_run_command(
agent="plan", # Will be mapped to "trellis-plan" for OpenCode
prompt=f"Start planning for task: {task_name}",
skip_permissions=True,
verbose=True,
json_output=True,
)
with log_file.open("w") as log_f:
# Use shell=False for cross-platform compatibility
# creationflags for Windows, start_new_session for Unix
popen_kwargs = {
"stdout": log_f,
"stderr": subprocess.STDOUT,
"cwd": str(project_root),
"env": env,
}
if sys.platform == "win32":
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
popen_kwargs["start_new_session"] = True
process = subprocess.Popen(cli_cmd, **popen_kwargs)
agent_pid = process.pid
log_success(f"Plan Agent started (PID: {agent_pid})")
# =============================================================================
# Summary
# =============================================================================
print()
print(f"{Colors.GREEN}=== Plan Agent Running ==={Colors.NC}")
print()
print(f" Task: {task_name}")
print(f" Type: {dev_type}")
print(f" Dir: {task_dir}")
print(f" Log: {log_file}")
print(f" PID: {agent_pid}")
print()
print(f"{Colors.YELLOW}To monitor:{Colors.NC}")
print(f" tail -f {log_file}")
print()
print(f"{Colors.YELLOW}To check status:{Colors.NC}")
print(f" ps -p {agent_pid}")
print(f" ls -la {task_dir}")
print()
print(f"{Colors.YELLOW}After completion, run:{Colors.NC}")
print(f" python3 ./.trellis/scripts/multi_agent/start.py {task_dir}")
return 0
if __name__ == "__main__":
sys.exit(main())
+539
View File
@@ -0,0 +1,539 @@
#!/usr/bin/env python3
"""
Multi-Agent Pipeline: Start Worktree Agent.
Usage: python3 start.py <task-dir>
Example: python3 start.py .trellis/tasks/01-21-my-task
This script:
1. Creates worktree (if not exists) with dependency install
2. Copies environment files (from worktree.yaml config)
3. Sets .current-task in worktree
4. Starts claude agent in background
5. Registers agent to registry.json
Prerequisites:
- task.json must exist with 'branch' field
- agents/dispatch.md must exist (in .claude/, .cursor/, .iflow/, or .opencode/)
Configuration: .trellis/worktree.yaml
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import uuid
from pathlib import Path
import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path
from common.cli_adapter import get_cli_adapter
from common.git import run_git
from common.io import read_json, write_json
from common.log import Colors, log_info, log_success, log_warn, log_error
from common.paths import (
DIR_WORKFLOW,
FILE_CURRENT_TASK,
FILE_TASK_JSON,
get_repo_root,
)
from common.registry import (
registry_add_agent,
registry_get_file,
)
from common.config import (
get_default_package,
get_packages,
get_submodule_packages,
validate_package,
)
from common.worktree import (
get_worktree_base_dir,
get_worktree_config,
get_worktree_copy_files,
get_worktree_post_create_hooks,
)
# Colors, log_info, log_success, log_warn, log_error, read_json, write_json
# are now imported from common.log and common.io above.
# =============================================================================
# Constants
# =============================================================================
DEFAULT_PLATFORM = "claude"
# =============================================================================
# Submodule Init
# =============================================================================
def _init_submodules_for_task(
task_data: dict, worktree_path: str, project_root: Path
) -> None:
"""Initialize submodules in worktree based on task's target package.
Resolves the target package from task_data.package -> default_package -> None.
Only initializes submodule-type packages. Idempotent: skips already-initialized
submodules to avoid detaching HEAD on in-progress work.
"""
# Skip if not a monorepo (no packages configured)
if get_packages(project_root) is None:
return
# Resolve package: task.package -> default_package -> None
task_package = task_data.get("package")
package = None
if task_package and isinstance(task_package, str):
if validate_package(task_package, project_root):
package = task_package
else:
log_warn(
f"package '{task_package}' not found in config.yaml, "
"skipping submodule init"
)
return
else:
# Fallback to default_package
default_pkg = get_default_package(project_root)
if default_pkg:
if validate_package(default_pkg, project_root):
package = default_pkg
else:
log_warn(
f"package '{default_pkg}' not found in config.yaml, "
"skipping submodule init"
)
return
if not package:
log_warn("no package specified, skipping submodule init")
return
# Check if this package is a submodule
submodule_packages = get_submodule_packages(project_root)
if package not in submodule_packages:
log_info(f"Package '{package}' is not a submodule, skipping submodule init")
return
submodule_path = submodule_packages[package]
log_info(f"Checking submodule status for '{package}' ({submodule_path})...")
# Run git submodule status in worktree directory
ret, status_out, status_err = run_git(
["submodule", "status", submodule_path], cwd=Path(worktree_path)
)
if ret != 0:
log_warn(
f"git submodule status failed for '{submodule_path}': {status_err.strip()}, "
"skipping submodule init"
)
return
# Parse the prefix character from submodule status output
# Format: "<prefix><sha1> <path> (<describe>)"
# Prefix: '-' (uninitialized), ' ' (normal), '+' (commit mismatch), 'U' (conflict)
status_line = status_out.rstrip("\n\r")
if not status_line:
log_warn(f"Empty submodule status for '{submodule_path}', skipping")
return
prefix = status_line[0]
if prefix == "-":
# Uninitialized: run git submodule update --init
log_info(f"Initializing submodule '{submodule_path}'...")
ret, _, err = run_git(
["submodule", "update", "--init", submodule_path],
cwd=Path(worktree_path),
)
if ret != 0:
log_warn(f"Failed to initialize submodule '{submodule_path}': {err.strip()}")
else:
log_success(f"Submodule '{submodule_path}' initialized")
elif prefix == " ":
log_info(f"Submodule '{submodule_path}' already initialized, skipping")
elif prefix == "+":
log_warn(
f"submodule {submodule_path} has local changes, skipping update"
)
elif prefix == "U":
log_warn(
f"submodule {submodule_path} has conflicts, skipping"
)
else:
log_warn(
f"Unknown submodule status prefix '{prefix}' for '{submodule_path}', skipping"
)
# =============================================================================
# Main
# =============================================================================
def main() -> int:
"""Main entry point."""
import argparse
parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Start Worktree Agent")
parser.add_argument("task_dir", help="Task directory path")
parser.add_argument(
"--platform", "-p",
choices=["claude", "cursor", "iflow", "opencode", "codex", "qoder"],
default=DEFAULT_PLATFORM,
help="Platform to use (default: claude)"
)
args = parser.parse_args()
task_dir_arg = args.task_dir
platform = args.platform
# Initialize CLI adapter
adapter = get_cli_adapter(platform)
project_root = get_repo_root()
# Normalize paths
task_dir_path = Path(task_dir_arg)
if task_dir_path.is_absolute():
task_dir_abs = task_dir_path
else:
task_dir_abs = project_root / task_dir_path
try:
task_dir_relative = task_dir_abs.relative_to(project_root).as_posix()
except ValueError:
task_dir_relative = str(task_dir_abs)
task_json_path = task_dir_abs / FILE_TASK_JSON
# =============================================================================
# Validation
# =============================================================================
if not task_json_path.is_file():
log_error(f"task.json not found at {task_json_path}")
return 1
if adapter.requires_agent_definition_file:
dispatch_md = adapter.get_agent_path("dispatch", project_root)
if not dispatch_md.is_file():
log_error(f"Agent definition not found at {dispatch_md}")
log_info(f"Platform: {platform}")
return 1
config_file = get_worktree_config(project_root)
if not config_file.is_file():
log_error(f"worktree.yaml not found at {config_file}")
return 1
# =============================================================================
# Read Task Config
# =============================================================================
print()
print(f"{Colors.BLUE}=== Multi-Agent Pipeline: Start ==={Colors.NC}")
log_info(f"Task: {task_dir_abs}")
task_data = read_json(task_json_path)
if not task_data:
log_error("Failed to read task.json")
return 1
branch = task_data.get("branch")
task_name = task_data.get("name")
task_status = task_data.get("status")
worktree_path = task_data.get("worktree_path")
# Check if task was rejected
if task_status == "rejected":
log_error("Task was rejected by Plan Agent")
rejected_file = task_dir_abs / "REJECTED.md"
if rejected_file.is_file():
print()
print(f"{Colors.YELLOW}Rejection reason:{Colors.NC}")
print(rejected_file.read_text(encoding="utf-8"))
print()
log_info(
"To retry, delete this directory and run plan.py again with revised requirements"
)
return 1
# Check if prd.md exists (plan completed successfully)
prd_file = task_dir_abs / "prd.md"
if not prd_file.is_file():
log_error("prd.md not found - Plan Agent may not have completed")
log_info(f"Check plan log: {task_dir_abs}/.plan-log")
return 1
if not branch:
log_error("branch field not set in task.json")
log_info("Please set branch field first, e.g.:")
log_info(
" jq '.branch = \"task/my-task\"' task.json > tmp && mv tmp task.json"
)
return 1
log_info(f"Branch: {branch}")
log_info(f"Name: {task_name}")
# =============================================================================
# Step 1: Create Worktree (if not exists)
# =============================================================================
if not worktree_path or not Path(worktree_path).is_dir():
log_info("Step 1: Creating worktree...")
# Record current branch as base_branch (PR target)
_, base_branch_out, _ = run_git(
["branch", "--show-current"], cwd=project_root
)
base_branch = base_branch_out.strip()
log_info(f"Base branch (PR target): {base_branch}")
# Calculate worktree path
worktree_base = get_worktree_base_dir(project_root)
worktree_base.mkdir(parents=True, exist_ok=True)
worktree_base = worktree_base.resolve()
worktree_path_obj = worktree_base / branch
worktree_path = str(worktree_path_obj)
# Create parent directory
worktree_path_obj.parent.mkdir(parents=True, exist_ok=True)
# Create branch if not exists
ret, _, _ = run_git(
["show-ref", "--verify", "--quiet", f"refs/heads/{branch}"],
cwd=project_root,
)
if ret == 0:
log_info("Branch exists, checking out...")
ret, _, err = run_git(
["worktree", "add", worktree_path, branch], cwd=project_root
)
else:
log_info(f"Creating new branch: {branch}")
ret, _, err = run_git(
["worktree", "add", "-b", branch, worktree_path], cwd=project_root
)
if ret != 0:
log_error(f"Failed to create worktree: {err}")
return 1
log_success(f"Worktree created: {worktree_path}")
# Update task.json with worktree_path and base_branch
task_data["worktree_path"] = worktree_path
task_data["base_branch"] = base_branch
write_json(task_json_path, task_data)
# ----- Copy environment files -----
log_info("Copying environment files...")
copy_list = get_worktree_copy_files(project_root)
copy_count = 0
for item in copy_list:
if not item:
continue
source = project_root / item
target = Path(worktree_path) / item
if source.is_file():
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(str(source), str(target))
copy_count += 1
if copy_count > 0:
log_success(f"Copied {copy_count} file(s)")
# ----- Copy task directory (may not be committed yet) -----
log_info("Copying task directory...")
task_target_dir = Path(worktree_path) / task_dir_relative
task_target_dir.parent.mkdir(parents=True, exist_ok=True)
if task_target_dir.exists():
shutil.rmtree(str(task_target_dir))
shutil.copytree(str(task_dir_abs), str(task_target_dir))
log_success("Task directory copied to worktree")
# ----- Initialize submodules (before hooks, so hooks can use submodule content) -----
_init_submodules_for_task(task_data, worktree_path, project_root)
# ----- Run post_create hooks -----
log_info("Running post_create hooks...")
post_create = get_worktree_post_create_hooks(project_root)
hook_count = 0
for cmd in post_create:
if not cmd:
continue
log_info(f" Running: {cmd}")
ret = subprocess.run(cmd, shell=True, cwd=worktree_path)
if ret.returncode != 0:
log_error(f"Hook failed: {cmd}")
return 1
hook_count += 1
if hook_count > 0:
log_success(f"Ran {hook_count} hook(s)")
else:
log_info(f"Step 1: Using existing worktree: {worktree_path}")
# ----- Initialize submodules (idempotent, for reused worktrees) -----
_init_submodules_for_task(task_data, worktree_path, project_root)
# =============================================================================
# Step 2: Set .current-task in Worktree
# =============================================================================
log_info("Step 2: Setting current task in worktree...")
worktree_workflow_dir = Path(worktree_path) / DIR_WORKFLOW
worktree_workflow_dir.mkdir(parents=True, exist_ok=True)
current_task_file = worktree_workflow_dir / FILE_CURRENT_TASK
current_task_file.write_text(task_dir_relative, encoding="utf-8")
log_success(f"Current task set: {task_dir_relative}")
# =============================================================================
# Step 3: Prepare and Start Claude Agent
# =============================================================================
log_info(f"Step 3: Starting {adapter.cli_name} agent...")
# Update task status
task_data["status"] = "in_progress"
write_json(task_json_path, task_data)
log_file = Path(worktree_path) / ".agent-log"
session_id_file = Path(worktree_path) / ".session-id"
log_file.touch()
# Generate session ID for resume support (Claude Code only)
# OpenCode generates its own session ID, we'll extract it from logs later
if adapter.supports_session_id_on_create:
session_id = str(uuid.uuid4()).lower()
session_id_file.write_text(session_id, encoding="utf-8")
log_info(f"Session ID: {session_id}")
else:
session_id = None # Will be extracted from logs after startup
log_info("Session ID will be extracted from logs after startup")
# Get proxy environment variables
https_proxy = os.environ.get("https_proxy", "")
http_proxy = os.environ.get("http_proxy", "")
all_proxy = os.environ.get("all_proxy", "")
# Start agent in background (cross-platform, no shell script needed)
env = os.environ.copy()
env["https_proxy"] = https_proxy
env["http_proxy"] = http_proxy
env["all_proxy"] = all_proxy
# Clear nested-session detection so the new CLI process can start
# (when this script runs inside a Claude Code session, CLAUDECODE=1 is inherited)
env.pop("CLAUDECODE", None)
# Set non-interactive env var based on platform
env.update(adapter.get_non_interactive_env())
# Build CLI command using adapter
# Note: Use explicit prompt to avoid confusion with CI/CD pipelines
# Also remind the model to follow its agent definition for better cross-model compatibility
cli_cmd = adapter.build_run_command(
agent="dispatch",
prompt="Follow your agent instructions to execute the task workflow. Start by reading .trellis/.current-task to get the task directory, then execute each action in task.json next_action array in order.",
session_id=session_id if adapter.supports_session_id_on_create else None,
skip_permissions=True,
verbose=True,
json_output=True,
)
with log_file.open("w") as log_f:
# Use shell=False for cross-platform compatibility
# creationflags for Windows, start_new_session for Unix
popen_kwargs = {
"stdout": log_f,
"stderr": subprocess.STDOUT,
"cwd": worktree_path,
"env": env,
}
if sys.platform == "win32":
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
else:
popen_kwargs["start_new_session"] = True
process = subprocess.Popen(cli_cmd, **popen_kwargs)
agent_pid = process.pid
log_success(f"Agent started with PID: {agent_pid}")
# For OpenCode: extract session ID from logs after startup
if not adapter.supports_session_id_on_create:
import time
log_info("Waiting for session ID from logs...")
# Wait a bit for the log to have session ID
for _ in range(10): # Try for up to 5 seconds
time.sleep(0.5)
try:
log_content = log_file.read_text(encoding="utf-8", errors="replace")
session_id = adapter.extract_session_id_from_log(log_content)
if session_id:
session_id_file.write_text(session_id, encoding="utf-8")
log_success(f"Session ID extracted: {session_id}")
break
except Exception:
pass
else:
log_warn("Could not extract session ID from logs")
session_id = "unknown"
# =============================================================================
# Step 4: Register to Registry (in main repo, not worktree)
# =============================================================================
log_info("Step 4: Registering agent to registry...")
# Generate agent ID
task_id = task_data.get("id")
if not task_id:
task_id = branch.replace("/", "-")
registry_add_agent(
task_id, worktree_path, agent_pid, task_dir_relative, project_root, platform
)
log_success(f"Agent registered: {task_id}")
# =============================================================================
# Summary
# =============================================================================
print()
print(f"{Colors.GREEN}=== Agent Started ==={Colors.NC}")
print()
print(f" ID: {task_id}")
print(f" PID: {agent_pid}")
print(f" Session: {session_id}")
print(f" Worktree: {worktree_path}")
print(f" Task: {task_dir_relative}")
print(f" Log: {log_file}")
print(f" Registry: {registry_get_file(project_root)}")
print()
print(f"{Colors.YELLOW}To monitor:{Colors.NC} tail -f {log_file}")
print(f"{Colors.YELLOW}To stop:{Colors.NC} kill {agent_pid}")
if session_id and session_id != "unknown":
resume_cmd = adapter.get_resume_command_str(session_id, cwd=worktree_path)
print(f"{Colors.YELLOW}To resume:{Colors.NC} {resume_cmd}")
else:
print(f"{Colors.YELLOW}To resume:{Colors.NC} (session ID not available)")
return 0
if __name__ == "__main__":
sys.exit(main())
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""
Multi-Agent Pipeline: Status Monitor.
Usage:
python3 status.py Show summary of all tasks (default)
python3 status.py -a <assignee> Filter tasks by assignee
python3 status.py --list List all worktrees and agents
python3 status.py --detail <task> Detailed task status
python3 status.py --watch <task> Watch agent log in real-time
python3 status.py --log <task> Show recent log entries
python3 status.py --registry Show agent registry
Entry shim — delegates to status_display and status_monitor.
"""
from __future__ import annotations
import argparse
import sys
import _bootstrap # noqa: F401 — adds parent scripts/ dir to sys.path
from common.paths import get_repo_root
from .status_display import (
cmd_detail,
cmd_help,
cmd_list,
cmd_registry,
cmd_summary,
)
from .status_monitor import cmd_log, cmd_watch
# =============================================================================
# Main
# =============================================================================
def main() -> int:
"""Main entry point."""
parser = argparse.ArgumentParser(description="Multi-Agent Pipeline: Status Monitor")
parser.add_argument("-a", "--assignee", help="Filter by assignee")
parser.add_argument(
"--list", action="store_true", help="List all worktrees and agents"
)
parser.add_argument("--detail", metavar="TASK", help="Detailed task status")
parser.add_argument("--progress", metavar="TASK", help="Quick progress view")
parser.add_argument("--watch", metavar="TASK", help="Watch agent log")
parser.add_argument("--log", metavar="TASK", help="Show recent log entries")
parser.add_argument("--registry", action="store_true", help="Show agent registry")
parser.add_argument("target", nargs="?", help="Target task")
args = parser.parse_args()
repo_root = get_repo_root()
if args.list:
return cmd_list(repo_root)
elif args.detail:
return cmd_detail(args.detail, repo_root)
elif args.progress:
return cmd_detail(args.progress, repo_root) # Similar to detail
elif args.watch:
return cmd_watch(args.watch, repo_root)
elif args.log:
return cmd_log(args.log, repo_root)
elif args.registry:
return cmd_registry(repo_root)
elif args.target:
return cmd_detail(args.target, repo_root)
else:
return cmd_summary(repo_root, args.assignee)
if __name__ == "__main__":
sys.exit(main())
+542
View File
@@ -0,0 +1,542 @@
#!/usr/bin/env python3
"""
Multi-Agent Pipeline: Status display and formatting.
Provides:
cmd_help - Show help text
cmd_list - List worktrees and agents
cmd_summary - Summary of all tasks with agent status
cmd_detail - Detailed single-agent status
cmd_registry - Dump agent registry
Also exports shared utilities used by status_monitor:
is_running, find_agent, get_registry_file, calc_elapsed, count_modified_files
"""
from __future__ import annotations
import json
import os
import subprocess
from datetime import datetime
from pathlib import Path
from common.cli_adapter import get_cli_adapter
from common.io import read_json
from common.log import Colors
from common.developer import ensure_developer
from common.paths import (
get_repo_root,
get_tasks_dir,
)
from common.phase import get_phase_info
from common.task_queue import format_task_stats, get_task_stats
from common.tasks import iter_active_tasks
from common.worktree import get_agents_dir
# =============================================================================
# Shared Utilities
# =============================================================================
def is_running(pid: int | str | None) -> bool:
"""Check if PID is running."""
if not pid:
return False
try:
pid_int = int(pid)
os.kill(pid_int, 0)
return True
except (ProcessLookupError, ValueError, PermissionError, TypeError):
return False
def status_color(status: str) -> str:
"""Get status color."""
colors = {
"completed": Colors.GREEN,
"in_progress": Colors.BLUE,
"planning": Colors.YELLOW,
}
return colors.get(status, Colors.DIM)
def get_registry_file(repo_root: Path) -> Path | None:
"""Get registry file path."""
agents_dir = get_agents_dir(repo_root)
if agents_dir:
return agents_dir / "registry.json"
return None
def find_agent(search: str, repo_root: Path) -> dict | None:
"""Find agent by task name or ID."""
registry_file = get_registry_file(repo_root)
if not registry_file or not registry_file.is_file():
return None
data = read_json(registry_file)
if not data:
return None
for agent in data.get("agents", []):
# Exact ID match
if agent.get("id") == search:
return agent
# Partial match on task_dir
task_dir = agent.get("task_dir", "")
if search in task_dir:
return agent
return None
def calc_elapsed(started: str | None) -> str:
"""Calculate elapsed time from ISO timestamp."""
if not started:
return "N/A"
try:
# Parse ISO format
if "+" in started:
started = started.split("+")[0]
if "T" in started:
start_dt = datetime.fromisoformat(started)
else:
return "N/A"
now = datetime.now()
elapsed = (now - start_dt).total_seconds()
if elapsed < 60:
return f"{int(elapsed)}s"
elif elapsed < 3600:
mins = int(elapsed // 60)
secs = int(elapsed % 60)
return f"{mins}m {secs}s"
else:
hours = int(elapsed // 3600)
mins = int((elapsed % 3600) // 60)
return f"{hours}h {mins}m"
except (ValueError, TypeError):
return "N/A"
def count_modified_files(worktree: str) -> int:
"""Count modified files in worktree."""
if not Path(worktree).is_dir():
return 0
try:
result = subprocess.run(
["git", "status", "--short"],
cwd=worktree,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
return len([line for line in result.stdout.splitlines() if line.strip()])
except Exception:
return 0
# =============================================================================
# Commands
# =============================================================================
def cmd_help() -> int:
"""Show help."""
print("""Multi-Agent Pipeline: Status Monitor
Usage:
python3 status.py Show summary of all tasks
python3 status.py -a <assignee> Filter tasks by assignee
python3 status.py --list List all worktrees and agents
python3 status.py --detail <task> Detailed task status
python3 status.py --progress <task> Quick progress view with recent activity
python3 status.py --watch <task> Watch agent log in real-time
python3 status.py --log <task> Show recent log entries
python3 status.py --registry Show agent registry
Examples:
python3 status.py -a taosu
python3 status.py --detail my-task
python3 status.py --progress my-task
python3 status.py --watch 01-16-worktree-support
python3 status.py --log worktree-support
""")
return 0
def cmd_list(repo_root: Path) -> int:
"""List worktrees and agents."""
print(f"{Colors.BLUE}=== Git Worktrees ==={Colors.NC}")
print()
subprocess.run(["git", "worktree", "list"], cwd=repo_root)
print()
print(f"{Colors.BLUE}=== Registered Agents ==={Colors.NC}")
print()
registry_file = get_registry_file(repo_root)
if not registry_file or not registry_file.is_file():
print(" (no registry found)")
return 0
data = read_json(registry_file)
if not data or not data.get("agents"):
print(" (no agents registered)")
return 0
for agent in data["agents"]:
agent_id = agent.get("id", "?")
pid = agent.get("pid")
wt = agent.get("worktree_path", "?")
started = agent.get("started_at", "?")
if is_running(pid):
status_icon = f"{Colors.GREEN}{Colors.NC}"
else:
status_icon = f"{Colors.RED}{Colors.NC}"
print(f" {status_icon} {agent_id} (PID: {pid})")
print(f" {Colors.DIM}Worktree: {wt}{Colors.NC}")
print(f" {Colors.DIM}Started: {started}{Colors.NC}")
print()
return 0
def cmd_summary(repo_root: Path, filter_assignee: str | None = None) -> int:
"""Show summary of all tasks."""
# Import lazily to avoid circular import at module level
from .status_monitor import get_last_tool, get_last_message
ensure_developer(repo_root)
tasks_dir = get_tasks_dir(repo_root)
if not tasks_dir.is_dir():
print("No tasks directory found")
return 0
registry_file = get_registry_file(repo_root)
# Count running agents
running_count = 0
total_agents = 0
if registry_file and registry_file.is_file():
data = read_json(registry_file)
if data:
agents = data.get("agents", [])
total_agents = len(agents)
for agent in agents:
if is_running(agent.get("pid")):
running_count += 1
# Task queue stats
task_stats = get_task_stats(repo_root)
print(f"{Colors.BLUE}=== Multi-Agent Status ==={Colors.NC}")
print(
f" Agents: {Colors.GREEN}{running_count}{Colors.NC} running / {total_agents} registered"
)
print(f" Tasks: {format_task_stats(task_stats)}")
print()
# Process tasks
running_tasks = []
stopped_tasks = []
regular_tasks = []
registry_data = (
read_json(registry_file)
if registry_file and registry_file.is_file()
else None
)
for t in iter_active_tasks(tasks_dir):
name = t.dir_name
status = t.status
assignee = t.assignee or "unassigned"
priority = t.priority
# Filter by assignee
if filter_assignee and assignee != filter_assignee:
continue
# Check agent status
agent_info = None
if registry_data:
for agent in registry_data.get("agents", []):
if name in agent.get("task_dir", ""):
agent_info = agent
break
if agent_info:
pid = agent_info.get("pid")
worktree = agent_info.get("worktree_path", "")
started = agent_info.get("started_at")
agent_platform = agent_info.get("platform", "claude")
if is_running(pid):
# Running agent
task_dir_rel = agent_info.get("task_dir", "")
worktree_task_json = Path(worktree) / task_dir_rel / "task.json"
phase_source = t.directory / "task.json"
if worktree_task_json.is_file():
phase_source = worktree_task_json
phase_info_str = get_phase_info(phase_source)
elapsed = calc_elapsed(started)
modified = count_modified_files(worktree)
worktree_data = read_json(phase_source)
branch = worktree_data.get("branch", "N/A") if worktree_data else "N/A"
log_file = Path(worktree) / ".agent-log"
last_tool = get_last_tool(log_file, platform=agent_platform)
running_tasks.append(
{
"name": name,
"priority": priority,
"assignee": assignee,
"phase_info": phase_info_str,
"elapsed": elapsed,
"branch": branch,
"modified": modified,
"last_tool": last_tool,
"pid": pid,
}
)
else:
# Stopped agent
task_dir_rel = agent_info.get("task_dir", "")
worktree_task_json = Path(worktree) / task_dir_rel / "task.json"
worktree_status = "unknown"
if worktree_task_json.is_file():
wt_data = read_json(worktree_task_json)
if wt_data:
worktree_status = wt_data.get("status", "unknown")
session_id_file = Path(worktree) / ".session-id"
log_file = Path(worktree) / ".agent-log"
stopped_tasks.append(
{
"name": name,
"worktree": worktree,
"status": worktree_status,
"session_id_file": session_id_file,
"log_file": log_file,
"platform": agent_info.get("platform", "claude"),
}
)
else:
# Regular task
regular_tasks.append(
{
"name": name,
"status": status,
"priority": priority,
"assignee": assignee,
}
)
# Output running agents
if running_tasks:
print(f"{Colors.CYAN}Running Agents:{Colors.NC}")
for t in running_tasks:
priority_color = (
Colors.RED
if t["priority"] == "P0"
else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE)
)
print(
f"{Colors.GREEN}{Colors.NC} {Colors.CYAN}{t['name']}{Colors.NC} {Colors.GREEN}[running]{Colors.NC} {priority_color}[{t['priority']}]{Colors.NC} @{t['assignee']}"
)
print(f" Phase: {t['phase_info']}")
print(f" Elapsed: {t['elapsed']}")
print(f" Branch: {Colors.DIM}{t['branch']}{Colors.NC}")
print(f" Modified: {t['modified']} file(s)")
if t["last_tool"]:
print(f" Activity: {Colors.YELLOW}{t['last_tool']}{Colors.NC}")
print(f" PID: {Colors.DIM}{t['pid']}{Colors.NC}")
print()
# Output stopped agents
if stopped_tasks:
print(f"{Colors.RED}Stopped Agents:{Colors.NC}")
for t in stopped_tasks:
if t["status"] == "completed":
print(
f"{Colors.GREEN}{Colors.NC} {t['name']} {Colors.GREEN}[completed]{Colors.NC}"
)
else:
if t["session_id_file"].is_file():
session_id = (
t["session_id_file"].read_text(encoding="utf-8").strip()
)
last_msg = get_last_message(t["log_file"], 150, platform=t.get("platform", "claude"))
print(
f"{Colors.RED}{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC}"
)
if last_msg:
print(f'{Colors.DIM}"{last_msg}"{Colors.NC}')
# Use CLI adapter for platform-specific resume command
adapter = get_cli_adapter(t.get("platform", "claude"))
resume_cmd = adapter.get_resume_command_str(session_id, cwd=t["worktree"])
print(f"{Colors.YELLOW}{resume_cmd}{Colors.NC}")
else:
print(
f"{Colors.RED}{Colors.NC} {t['name']} {Colors.RED}[stopped]{Colors.NC} {Colors.DIM}(no session-id){Colors.NC}"
)
print()
# Separator
if (running_tasks or stopped_tasks) and regular_tasks:
print(f"{Colors.DIM}───────────────────────────────────────{Colors.NC}")
print()
# Output regular tasks grouped by assignee
if regular_tasks:
# Sort by assignee, priority, status
regular_tasks.sort(
key=lambda x: (
x["assignee"],
{"P0": 0, "P1": 1, "P2": 2, "P3": 3}.get(x["priority"], 2),
{"in_progress": 0, "planning": 1, "completed": 2}.get(x["status"], 1),
)
)
current_assignee = None
for t in regular_tasks:
if t["assignee"] != current_assignee:
if current_assignee is not None:
print()
print(f"{Colors.CYAN}@{t['assignee']}:{Colors.NC}")
current_assignee = t["assignee"]
color = status_color(t["status"])
priority_color = (
Colors.RED
if t["priority"] == "P0"
else (Colors.YELLOW if t["priority"] == "P1" else Colors.BLUE)
)
print(
f" {color}{Colors.NC} {t['name']} ({t['status']}) {priority_color}[{t['priority']}]{Colors.NC}"
)
if running_tasks:
print()
print(f"{Colors.DIM}─────────────────────────────────────{Colors.NC}")
print(f"{Colors.DIM}Use --progress <name> for quick activity view{Colors.NC}")
print(f"{Colors.DIM}Use --detail <name> for more info{Colors.NC}")
print()
return 0
def cmd_detail(target: str, repo_root: Path) -> int:
"""Show detailed task status."""
agent = find_agent(target, repo_root)
if not agent:
print(f"Agent not found: {target}")
return 1
agent_id = agent.get("id", "?")
pid = agent.get("pid")
worktree = agent.get("worktree_path", "?")
task_dir = agent.get("task_dir", "?")
started = agent.get("started_at", "?")
platform = agent.get("platform", "claude")
# Check for session-id
session_id = ""
session_id_file = Path(worktree) / ".session-id"
if session_id_file.is_file():
session_id = session_id_file.read_text(encoding="utf-8").strip()
print(f"{Colors.BLUE}=== Agent Detail: {agent_id} ==={Colors.NC}")
print()
print(f" ID: {agent_id}")
print(f" PID: {pid}")
print(f" Session: {session_id or 'N/A'}")
print(f" Worktree: {worktree}")
print(f" Task Dir: {task_dir}")
print(f" Started: {started}")
print()
# Status
if is_running(pid):
print(f" Status: {Colors.GREEN}Running{Colors.NC}")
else:
print(f" Status: {Colors.RED}Stopped{Colors.NC}")
if session_id:
print()
# Use CLI adapter for platform-specific resume command
adapter = get_cli_adapter(platform)
resume_cmd = adapter.get_resume_command_str(session_id, cwd=worktree)
print(f" {Colors.YELLOW}Resume:{Colors.NC} {resume_cmd}")
# Task info
task_json = repo_root / task_dir / "task.json"
if task_json.is_file():
print()
print(f"{Colors.BLUE}=== Task Info ==={Colors.NC}")
print()
data = read_json(task_json)
if data:
print(f" Status: {data.get('status', 'unknown')}")
print(f" Branch: {data.get('branch', 'N/A')}")
print(f" Base Branch: {data.get('base_branch', 'N/A')}")
# Git changes
if Path(worktree).is_dir():
print()
print(f"{Colors.BLUE}=== Git Changes ==={Colors.NC}")
print()
result = subprocess.run(
["git", "status", "--short"],
cwd=worktree,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
changes = result.stdout.strip()
if changes:
for line in changes.splitlines()[:10]:
print(f" {line}")
total = len(changes.splitlines())
if total > 10:
print(f" ... and {total - 10} more")
else:
print(" (no changes)")
print()
return 0
def cmd_registry(repo_root: Path) -> int:
"""Show agent registry."""
registry_file = get_registry_file(repo_root)
print(f"{Colors.BLUE}=== Agent Registry ==={Colors.NC}")
print()
print(f"File: {registry_file}")
print()
if registry_file and registry_file.is_file():
data = read_json(registry_file)
if data:
print(json.dumps(data, indent=2))
else:
print("(registry not found)")
return 0
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""
Multi-Agent Pipeline: Process monitoring and log parsing.
Provides:
tail_follow - Follow a file like 'tail -f'
get_last_tool - Get last tool call from agent log
get_last_message - Get last assistant text from agent log
cmd_watch - Watch agent log in real-time
cmd_log - Show recent log entries
"""
from __future__ import annotations
import json
import time
from pathlib import Path
from common.log import Colors
from .status_display import find_agent
# =============================================================================
# Log Parsing
# =============================================================================
def tail_follow(file_path: Path) -> None:
"""Follow a file like 'tail -f', cross-platform compatible."""
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
# Seek to end of file
f.seek(0, 2)
while True:
line = f.readline()
if line:
print(line, end="", flush=True)
else:
time.sleep(0.1)
def get_last_tool(log_file: Path, platform: str = "claude") -> str | None:
"""Get the last tool call from agent log.
Supports both Claude Code and OpenCode log formats.
Claude Code format:
{"type": "assistant", "message": {"content": [{"type": "tool_use", "name": "Read"}]}}
OpenCode format:
{"type": "tool_use", "tool": "bash", "state": {"status": "completed"}}
"""
if not log_file.is_file():
return None
try:
lines = log_file.read_text(encoding="utf-8").splitlines()
for line in reversed(lines[-100:]):
try:
data = json.loads(line)
if platform == "opencode":
# OpenCode format: {"type": "tool_use", "tool": "bash", ...}
if data.get("type") == "tool_use":
return data.get("tool")
else:
# Claude Code format: {"type": "assistant", "message": {"content": [...]}}
if data.get("type") == "assistant":
content = data.get("message", {}).get("content", [])
for item in content:
if item.get("type") == "tool_use":
return item.get("name")
except json.JSONDecodeError:
continue
except Exception:
pass
return None
def get_last_message(log_file: Path, max_len: int = 100, platform: str = "claude") -> str | None:
"""Get the last assistant text from agent log.
Supports both Claude Code and OpenCode log formats.
Claude Code format:
{"type": "assistant", "message": {"content": [{"type": "text", "text": "..."}]}}
OpenCode format:
{"type": "text", "text": "..."}
"""
if not log_file.is_file():
return None
try:
lines = log_file.read_text(encoding="utf-8").splitlines()
for line in reversed(lines[-100:]):
try:
data = json.loads(line)
if platform == "opencode":
# OpenCode format: {"type": "text", "text": "..."}
if data.get("type") == "text":
text = data.get("text", "")
if text:
return text[:max_len]
else:
# Claude Code format: {"type": "assistant", "message": {"content": [...]}}
if data.get("type") == "assistant":
content = data.get("message", {}).get("content", [])
for item in content:
if item.get("type") == "text":
text = item.get("text", "")
if text:
return text[:max_len]
except json.JSONDecodeError:
continue
except Exception:
pass
return None
# =============================================================================
# Commands
# =============================================================================
def cmd_watch(target: str, repo_root: Path) -> int:
"""Watch agent log in real-time."""
agent = find_agent(target, repo_root)
if not agent:
print(f"Agent not found: {target}")
return 1
worktree = agent.get("worktree_path", "")
log_file = Path(worktree) / ".agent-log"
if not log_file.is_file():
print(f"Log file not found: {log_file}")
return 1
print(f"{Colors.BLUE}Watching:{Colors.NC} {log_file}")
print(f"{Colors.DIM}Press Ctrl+C to stop{Colors.NC}")
print()
try:
tail_follow(log_file)
except KeyboardInterrupt:
print() # Clean newline after Ctrl+C
return 0
def cmd_log(target: str, repo_root: Path) -> int:
"""Show recent log entries."""
agent = find_agent(target, repo_root)
if not agent:
print(f"Agent not found: {target}")
return 1
worktree = agent.get("worktree_path", "")
platform = agent.get("platform", "claude")
log_file = Path(worktree) / ".agent-log"
if not log_file.is_file():
print(f"Log file not found: {log_file}")
return 1
print(f"{Colors.BLUE}=== Recent Log: {target} ==={Colors.NC}")
print(f"{Colors.DIM}Platform: {platform}{Colors.NC}")
print()
lines = log_file.read_text(encoding="utf-8").splitlines()
for line in lines[-50:]:
try:
data = json.loads(line)
msg_type = data.get("type", "")
if platform == "opencode":
# OpenCode format
if msg_type == "text":
text = data.get("text", "")
if text:
display = text[:300]
if len(text) > 300:
display += "..."
print(f"{Colors.BLUE}[TEXT]{Colors.NC} {display}")
elif msg_type == "tool_use":
tool_name = data.get("tool", "unknown")
status = data.get("state", {}).get("status", "")
print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool_name} ({status})")
elif msg_type == "step_start":
print(f"{Colors.CYAN}[STEP]{Colors.NC} Start")
elif msg_type == "step_finish":
reason = data.get("reason", "")
print(f"{Colors.CYAN}[STEP]{Colors.NC} Finish ({reason})")
elif msg_type == "error":
error_msg = data.get("message", "")
print(f"{Colors.RED}[ERROR]{Colors.NC} {error_msg}")
else:
# Claude Code format
if msg_type == "system":
subtype = data.get("subtype", "")
print(f"{Colors.CYAN}[SYSTEM]{Colors.NC} {subtype}")
elif msg_type == "user":
content = data.get("message", {}).get("content", "")
if content:
print(f"{Colors.GREEN}[USER]{Colors.NC} {content[:200]}")
elif msg_type == "assistant":
content = data.get("message", {}).get("content", [])
if content:
item = content[0]
text = item.get("text")
tool = item.get("name")
if text:
display = text[:300]
if len(text) > 300:
display += "..."
print(f"{Colors.BLUE}[ASSISTANT]{Colors.NC} {display}")
elif tool:
print(f"{Colors.YELLOW}[TOOL]{Colors.NC} {tool}")
elif msg_type == "result":
tool_name = data.get("tool", "unknown")
print(f"{Colors.DIM}[RESULT]{Colors.NC} {tool_name} completed")
except json.JSONDecodeError:
continue
return 0