# Agent Orchestrator Plugin Specification

Plugin implementation for LLM agent orchestration system.

## Core Architecture Patterns

This plugin demonstrates several key extensibility patterns that other plugins can reuse:

### DisplayFilterRegistry Pattern

The plugin registers a display filter to strip its XML commands from displayed messages.
This keeps the core message_renderer.py generic while allowing plugins to control what
appears in output.

```python
from core.io.message_renderer import DisplayFilterRegistry, MessageType

# In plugin initialize():
DisplayFilterRegistry.register(
    name="agent_orchestrator",
    filter_fn=self._strip_orchestrator_xml,
    message_types=[MessageType.ASSISTANT],
    priority=100,
)

# In plugin shutdown():
DisplayFilterRegistry.unregister("agent_orchestrator")

# Filter function:
def _strip_orchestrator_xml(self, content: str, message_type: MessageType) -> str:
    """Strip XML commands - they're displayed separately as tool indicators."""
    content = re.sub(r"<agent>.*?</agent>\s*", "", content, flags=re.DOTALL)
    content = re.sub(r"<status>\s*</status>\s*", "", content)
    # ... other patterns
    return content
```

### force_continue Pattern

The plugin can request the LLM to continue processing after commands execute:

```python
async def _on_llm_response(self, context: dict) -> dict:
    # Process commands...

    # Request continuation so LLM sees the results
    context["force_continue"] = True
    return context
```

### Event Timing

The LLM_RESPONSE event fires BEFORE display, allowing plugins to show tool indicators
(like `⏺ spawn_agent(name)`) before the prose response appears.

## XML Command Parsing

### Status Command Detection

The `<status>` command requires BOTH open and close tags to prevent false positives
when LLM mentions commands in prose:

```python
def _parse_status(self, text: str) -> List[ParsedCommand]:
    """Parse <status></status> - requires proper open AND close tag."""
    # Only matches <status></status>, not prose like "use <status> to check"
    if re.search(r"<status>\s*</status>", text):
        return [ParsedCommand(type="status")]
    return []
```

This prevents infinite loops where the LLM explaining commands triggers those commands.

## Overview

```
plugins/
└── agent_orchestrator/
    ├── __init__.py
    ├── plugin.py              # main plugin class
    ├── xml_parser.py          # parse XML commands from LLM response
    ├── orchestrator.py        # spawn/stop/message tmux sessions
    ├── activity_monitor.py    # MD5 polling for completion detection
    ├── message_injector.py    # inject completions back to conversation
    ├── file_attacher.py       # read and format file contents
    └── models.py              # AgentTask, AgentSession dataclasses
```

## Plugin Class

```python
# plugins/agent_orchestrator/plugin.py

from pathlib import Path
from core.plugins.base import BasePlugin
from core.events.types import EventType
import argparse
import asyncio

from .xml_parser import XMLCommandParser
from .orchestrator import AgentOrchestrator
from .activity_monitor import ActivityMonitor
from .message_injector import MessageInjector


class AgentOrchestratorPlugin(BasePlugin):
    """Plugin for spawning and managing parallel kollab sub-agents."""

    name = "agent_orchestrator"
    version = "1.0.0"

    # -------------------------------------------------------------------------
    # CLI Args (static, called before app init)
    # -------------------------------------------------------------------------

    @staticmethod
    def register_cli_args(parser: argparse.ArgumentParser) -> None:
        """Register CLI arguments for agent management."""
        group = parser.add_argument_group("Agent Orchestrator")
        group.add_argument(
            "--session",
            type=str,
            metavar="NAME",
            help="Agent session name to interact with"
        )
        group.add_argument(
            "--capture",
            type=int,
            metavar="LINES",
            default=None,
            help="Capture N lines from session (requires --session)"
        )
        group.add_argument(
            "--list-agents",
            action="store_true",
            help="List all active agents and exit"
        )

    @staticmethod
    def handle_early_args(args: argparse.Namespace) -> bool:
        """Handle args that exit before app starts."""
        from .orchestrator import AgentOrchestrator

        if args.list_agents:
            orchestrator = AgentOrchestrator()
            agents = orchestrator.list_agents()
            if agents:
                print("[agents]")
                for agent in agents:
                    print(f"  {agent.name:<20} {agent.status:<10} {agent.duration}")
            else:
                print("No active agents")
            return True  # exit

        if args.session and args.capture:
            orchestrator = AgentOrchestrator()
            output = orchestrator.capture_output(args.session, args.capture)
            print(output)
            return True  # exit

        return False  # continue normal startup

    # -------------------------------------------------------------------------
    # Plugin Lifecycle
    # -------------------------------------------------------------------------

    def __init__(self, event_bus, config, renderer, **kwargs):
        super().__init__(event_bus, config, renderer, **kwargs)

        self.xml_parser = XMLCommandParser()
        self.orchestrator = AgentOrchestrator(
            project_name=Path.cwd().name
        )
        self.activity_monitor = None  # initialized in initialize()
        self.message_injector = None  # initialized in initialize()
        self._monitor_task = None

    async def initialize(self, args: argparse.Namespace = None) -> None:
        """Initialize plugin components."""
        self.message_injector = MessageInjector(
            event_bus=self.event_bus,
            conversation_manager=self.sdk.get_service("conversation_manager")
        )

        self.activity_monitor = ActivityMonitor(
            orchestrator=self.orchestrator,
            on_agent_complete=self._on_agent_complete,
            poll_interval=self.config.get("agents.poll_interval", 2),
            idle_threshold=self.config.get("agents.idle_threshold", 3),
            capture_lines=self.config.get("agents.capture_lines", 500)
        )

        # Start background monitoring
        self._monitor_task = asyncio.create_task(
            self.activity_monitor.start()
        )

    async def shutdown(self) -> None:
        """Cleanup on shutdown."""
        if self._monitor_task:
            self._monitor_task.cancel()
            try:
                await self._monitor_task
            except asyncio.CancelledError:
                pass

    # -------------------------------------------------------------------------
    # Hook Registration
    # -------------------------------------------------------------------------

    def register_hooks(self) -> None:
        """Register hooks for LLM response processing."""
        self.event_bus.register(
            EventType.POST_API_RESPONSE,
            self._on_llm_response,
            priority=50  # run after response displayed
        )

    # -------------------------------------------------------------------------
    # Core Logic
    # -------------------------------------------------------------------------

    async def _on_llm_response(self, context: dict) -> dict:
        """Process LLM response for agent XML commands."""
        response_text = context.get("response_text", "")

        # Parse XML commands from response
        commands = self.xml_parser.parse(response_text)

        if not commands:
            return context  # no agent commands

        results = []

        for cmd in commands:
            result = await self._execute_command(cmd)
            results.append(result)

        # Inject results back as system message
        if results:
            await self.message_injector.inject(
                source="agent_orchestrator",
                content="\n".join(results),
                trigger_llm=False  # don't auto-continue, let LLM see results
            )

        return context

    async def _execute_command(self, cmd) -> str:
        """Execute a parsed agent command."""
        if cmd.type == "agent":
            return await self._spawn_agents(cmd.agents)
        elif cmd.type == "message":
            return await self._message_agent(cmd.target, cmd.content)
        elif cmd.type == "stop":
            return await self._stop_agents(cmd.targets)
        elif cmd.type == "status":
            return await self._get_status()
        elif cmd.type == "capture":
            return await self._capture_output(cmd.target, cmd.lines)
        elif cmd.type == "clone":
            return await self._clone_agent(cmd.agent, cmd.conversation)
        elif cmd.type == "team":
            return await self._spawn_team(cmd.lead, cmd.workers, cmd.task)
        elif cmd.type == "broadcast":
            return await self._broadcast(cmd.pattern, cmd.content)

    async def _spawn_agents(self, agents: list) -> str:
        """Spawn multiple agents."""
        spawned = []

        for agent in agents:
            success = await self.orchestrator.spawn(
                name=agent.name,
                task=agent.task,
                files=agent.files
            )
            if success:
                spawned.append(agent.name)
                self.activity_monitor.track(agent.name)

        return f"[spawned: {', '.join(spawned)}]"

    async def _message_agent(self, target: str, content: str) -> str:
        """Send message to agent."""
        success = await self.orchestrator.message(target, content)
        if success:
            return f"[message sent: {target}]"
        return f"[error: agent {target} not found]"

    async def _stop_agents(self, targets: list) -> str:
        """Stop agents and capture final output."""
        results = []

        for target in targets:
            output, duration = await self.orchestrator.stop(target)
            self.activity_monitor.untrack(target)
            results.append(f"[stopped: {target} @ {duration}]\n{output}")

        return "\n".join(results)

    async def _get_status(self) -> str:
        """Get status of all agents."""
        agents = self.orchestrator.list_agents()

        if not agents:
            return "[agents]\n  (none active)"

        lines = ["[agents]"]
        for agent in agents:
            lines.append(f"  {agent.name:<20} {agent.status:<10} {agent.duration}")

        return "\n".join(lines)

    async def _capture_output(self, target: str, lines: int) -> str:
        """Capture output from agent."""
        output = self.orchestrator.capture_output(target, lines)
        agent = self.orchestrator.get_agent(target)
        duration = agent.duration if agent else "?"

        return f"[capture: {target} @ {duration}, {lines} lines]\n{output}"

    async def _clone_agent(self, agent, conversation) -> str:
        """Clone agent with conversation context."""
        # Export conversation
        conv_file = await self._export_conversation()

        success = await self.orchestrator.spawn_clone(
            name=agent.name,
            task=agent.task,
            files=agent.files,
            conversation_file=conv_file
        )

        if success:
            self.activity_monitor.track(agent.name)
            return f"[cloned: {agent.name} with conversation context]"
        return f"[error: failed to clone {agent.name}]"

    async def _spawn_team(self, lead: str, workers: int, task) -> str:
        """Spawn team lead agent."""
        success = await self.orchestrator.spawn_team_lead(
            lead_name=lead,
            max_workers=workers,
            task=task
        )

        if success:
            self.activity_monitor.track(lead)
            return f"[team spawned: {lead} (max {workers} workers)]"
        return f"[error: failed to spawn team {lead}]"

    async def _broadcast(self, pattern: str, content: str) -> str:
        """Broadcast message to agents matching pattern."""
        targets = self.orchestrator.find_agents(pattern)
        count = 0

        for target in targets:
            if await self.orchestrator.message(target, content):
                count += 1

        return f"[broadcast: sent to {count} agents matching '{pattern}']"

    # -------------------------------------------------------------------------
    # Callbacks
    # -------------------------------------------------------------------------

    async def _on_agent_complete(self, name: str, duration: str, output: str) -> None:
        """Called when activity monitor detects agent completion."""
        self.activity_monitor.untrack(name)

        await self.message_injector.inject(
            source=name,
            content=f"[done: {name} @ {duration}]\n{output}",
            trigger_llm=True  # auto-continue conversation
        )

    async def _export_conversation(self) -> str:
        """Export current conversation to temp file."""
        import tempfile
        import json

        conv_manager = self.sdk.get_service("conversation_manager")
        messages = conv_manager.get_messages()

        fd, path = tempfile.mkstemp(suffix=".json", prefix="conv-")
        with open(path, "w") as f:
            json.dump(messages, f)

        return path
```

## XML Parser

```python
# plugins/agent_orchestrator/xml_parser.py

import re
from dataclasses import dataclass, field
from typing import List, Optional


@dataclass
class AgentTask:
    name: str
    task: str
    files: List[str] = field(default_factory=list)


@dataclass
class ParsedCommand:
    type: str  # agent, message, stop, status, capture, clone, team, broadcast
    agents: List[AgentTask] = field(default_factory=list)
    target: str = ""
    targets: List[str] = field(default_factory=list)
    content: str = ""
    lines: int = 50
    pattern: str = ""
    lead: str = ""
    workers: int = 3
    conversation: bool = False


class XMLCommandParser:
    """Parse XML agent commands from LLM response text."""

    def parse(self, text: str) -> List[ParsedCommand]:
        """Extract all agent commands from text."""
        commands = []

        # <agent>...</agent>
        commands.extend(self._parse_agent_blocks(text))

        # <message to="name">content</message>
        commands.extend(self._parse_messages(text))

        # <stop>name</stop>
        commands.extend(self._parse_stops(text))

        # <status></status>
        commands.extend(self._parse_status(text))

        # <capture>name lines</capture>
        commands.extend(self._parse_captures(text))

        # <clone>...</clone>
        commands.extend(self._parse_clones(text))

        # <team>...</team>
        commands.extend(self._parse_teams(text))

        # <broadcast to="pattern">content</broadcast>
        commands.extend(self._parse_broadcasts(text))

        return commands

    def _parse_agent_blocks(self, text: str) -> List[ParsedCommand]:
        """Parse <agent>...</agent> blocks."""
        commands = []
        pattern = r'<agent>(.*?)</agent>'

        for match in re.finditer(pattern, text, re.DOTALL):
            block = match.group(1)
            agents = self._parse_agent_definitions(block)
            if agents:
                commands.append(ParsedCommand(type="agent", agents=agents))

        return commands

    def _parse_agent_definitions(self, block: str) -> List[AgentTask]:
        """Parse individual agent definitions within <agent> block."""
        agents = []

        # Match <name>...</name> pattern (agent name is the tag name)
        pattern = r'<(\w[\w-]*)>(.*?)</\1>'

        for match in re.finditer(pattern, block, re.DOTALL):
            name = match.group(1)
            content = match.group(2)

            # Extract <task>...</task>
            task_match = re.search(r'<task>(.*?)</task>', content, re.DOTALL)
            task = task_match.group(1).strip() if task_match else ""

            # Extract <files>...</files>
            files = []
            files_match = re.search(r'<files>(.*?)</files>', content, re.DOTALL)
            if files_match:
                file_pattern = r'<file>(.*?)</file>'
                files = re.findall(file_pattern, files_match.group(1))

            agents.append(AgentTask(name=name, task=task, files=files))

        return agents

    def _parse_messages(self, text: str) -> List[ParsedCommand]:
        """Parse <message to="name">content</message>."""
        commands = []
        pattern = r'<message\s+to=["\']([^"\']+)["\']>(.*?)</message>'

        for match in re.finditer(pattern, text, re.DOTALL):
            target = match.group(1)
            content = match.group(2).strip()
            commands.append(ParsedCommand(
                type="message",
                target=target,
                content=content
            ))

        return commands

    def _parse_stops(self, text: str) -> List[ParsedCommand]:
        """Parse <stop>name</stop> or <stop>name1, name2</stop>."""
        commands = []
        pattern = r'<stop>(.*?)</stop>'

        for match in re.finditer(pattern, text, re.DOTALL):
            content = match.group(1).strip()
            # Split by comma or whitespace
            targets = [t.strip() for t in re.split(r'[,\s]+', content) if t.strip()]
            if targets:
                commands.append(ParsedCommand(type="stop", targets=targets))

        return commands

    def _parse_status(self, text: str) -> List[ParsedCommand]:
        """Parse <status></status> - requires both open AND close tag.

        This prevents false positives when LLM mentions commands in prose.
        """
        if re.search(r'<status>\s*</status>', text):
            return [ParsedCommand(type="status")]
        return []

    def _parse_captures(self, text: str) -> List[ParsedCommand]:
        """Parse <capture>name [lines]</capture>."""
        commands = []
        pattern = r'<capture>(.*?)</capture>'

        for match in re.finditer(pattern, text, re.DOTALL):
            content = match.group(1).strip()
            parts = content.split()

            target = parts[0] if parts else ""
            lines = int(parts[1]) if len(parts) > 1 else 50

            if target:
                commands.append(ParsedCommand(
                    type="capture",
                    target=target,
                    lines=lines
                ))

        return commands

    def _parse_clones(self, text: str) -> List[ParsedCommand]:
        """Parse <clone>...</clone> blocks."""
        commands = []
        pattern = r'<clone>(.*?)</clone>'

        for match in re.finditer(pattern, text, re.DOTALL):
            block = match.group(1)
            agents = self._parse_agent_definitions(block)
            for agent in agents:
                commands.append(ParsedCommand(
                    type="clone",
                    agents=[agent],
                    conversation=True
                ))

        return commands

    def _parse_teams(self, text: str) -> List[ParsedCommand]:
        """Parse <team lead="name" workers="N">...</team>."""
        commands = []
        pattern = r'<team\s+lead=["\']([^"\']+)["\']\s+workers=["\'](\d+)["\']>(.*?)</team>'

        for match in re.finditer(pattern, text, re.DOTALL):
            lead = match.group(1)
            workers = int(match.group(2))
            content = match.group(3)

            agents = self._parse_agent_definitions(f"<{lead}>{content}</{lead}>")
            task = agents[0] if agents else None

            commands.append(ParsedCommand(
                type="team",
                lead=lead,
                workers=workers,
                agents=agents
            ))

        return commands

    def _parse_broadcasts(self, text: str) -> List[ParsedCommand]:
        """Parse <broadcast to="pattern">content</broadcast>."""
        commands = []
        pattern = r'<broadcast\s+to=["\']([^"\']+)["\']>(.*?)</broadcast>'

        for match in re.finditer(pattern, text, re.DOTALL):
            pat = match.group(1)
            content = match.group(2).strip()
            commands.append(ParsedCommand(
                type="broadcast",
                pattern=pat,
                content=content
            ))

        return commands
```

## Orchestrator

```python
# plugins/agent_orchestrator/orchestrator.py

import subprocess
import time
from dataclasses import dataclass
from typing import List, Optional, Tuple
from pathlib import Path

from .file_attacher import FileAttacher


@dataclass
class AgentSession:
    name: str
    full_name: str  # {project}-{name}
    status: str     # running, idle
    start_time: float
    duration: str   # formatted duration

    @property
    def duration(self) -> str:
        elapsed = time.time() - self.start_time
        minutes = int(elapsed // 60)
        seconds = int(elapsed % 60)
        return f"{minutes}m{seconds:02d}s"


class AgentOrchestrator:
    """Manages tmux sessions for agent sub-processes."""

    def __init__(self, project_name: str = None):
        self.project_name = project_name or Path.cwd().name
        self.agents: dict[str, AgentSession] = {}
        self.file_attacher = FileAttacher()

        # Timing configuration
        self.session_init_delay = 1
        self.kollab_init_delay = 4
        self.message_delay = 2

    # -------------------------------------------------------------------------
    # Spawn
    # -------------------------------------------------------------------------

    async def spawn(self, name: str, task: str, files: List[str] = None) -> bool:
        """Spawn a new agent session."""
        full_name = f"{self.project_name}-{name}"

        # Check if already exists
        if self._session_exists(full_name):
            return False

        # Create session
        if not self._create_session(full_name):
            return False

        # Start kollab
        self._send_keys(full_name, "kollab")
        time.sleep(self.kollab_init_delay)

        # Prepare message with attached files
        message = ""
        if files:
            message = self.file_attacher.attach(files)
            message += "\n\n"
        message += task

        # Send task
        self._send_keys(full_name, message)

        # Track agent
        self.agents[name] = AgentSession(
            name=name,
            full_name=full_name,
            status="running",
            start_time=time.time()
        )

        return True

    async def spawn_clone(self, name: str, task: str, files: List[str],
                          conversation_file: str) -> bool:
        """Spawn agent with conversation context."""
        full_name = f"{self.project_name}-{name}"

        if self._session_exists(full_name):
            return False

        if not self._create_session(full_name):
            return False

        # Start kollab with resume
        self._send_keys(full_name, f"kollab --resume {conversation_file}")
        time.sleep(self.kollab_init_delay)

        # Send task with files
        message = ""
        if files:
            message = self.file_attacher.attach(files) + "\n\n"
        message += task

        self._send_keys(full_name, message)

        self.agents[name] = AgentSession(
            name=name,
            full_name=full_name,
            status="running",
            start_time=time.time()
        )

        return True

    async def spawn_team_lead(self, lead_name: str, max_workers: int,
                               task) -> bool:
        """Spawn a team lead agent that can spawn workers."""
        full_name = f"{self.project_name}-{lead_name}"

        if self._session_exists(full_name):
            return False

        if not self._create_session(full_name):
            return False

        self._send_keys(full_name, "kollab")
        time.sleep(self.kollab_init_delay)

        # Inject team lead prompt
        lead_prompt = f"""You are a team lead agent.
You can spawn up to {max_workers} worker agents using <agent> tags.
Coordinate their work and integrate results.
Use <status></status> to check on workers.
Use <capture>worker-name 100</capture> to see their progress.

"""
        message = lead_prompt
        if task.files:
            message += self.file_attacher.attach(task.files) + "\n\n"
        message += task.task

        self._send_keys(full_name, message)

        self.agents[lead_name] = AgentSession(
            name=lead_name,
            full_name=full_name,
            status="running",
            start_time=time.time()
        )

        return True

    # -------------------------------------------------------------------------
    # Message
    # -------------------------------------------------------------------------

    async def message(self, name: str, content: str) -> bool:
        """Send message to agent."""
        full_name = f"{self.project_name}-{name}"

        if not self._session_exists(full_name):
            return False

        self._send_keys(full_name, content)
        return True

    # -------------------------------------------------------------------------
    # Stop
    # -------------------------------------------------------------------------

    async def stop(self, name: str) -> Tuple[str, str]:
        """Stop agent and return final output + duration."""
        full_name = f"{self.project_name}-{name}"

        # Capture final output
        output = self.capture_output(name, 100)

        # Get duration
        agent = self.agents.get(name)
        duration = agent.duration if agent else "?"

        # Kill session
        self._kill_session(full_name)

        # Remove from tracking
        if name in self.agents:
            del self.agents[name]

        return output, duration

    # -------------------------------------------------------------------------
    # Status / Capture
    # -------------------------------------------------------------------------

    def list_agents(self) -> List[AgentSession]:
        """List all active agents."""
        # Refresh status from tmux
        self._refresh_agents()
        return list(self.agents.values())

    def get_agent(self, name: str) -> Optional[AgentSession]:
        """Get specific agent."""
        return self.agents.get(name)

    def find_agents(self, pattern: str) -> List[str]:
        """Find agents matching glob pattern."""
        import fnmatch
        return [name for name in self.agents.keys()
                if fnmatch.fnmatch(name, pattern)]

    def capture_output(self, name: str, lines: int = 50) -> str:
        """Capture last N lines from agent."""
        full_name = f"{self.project_name}-{name}"

        result = subprocess.run(
            ["tmux", "capture-pane", "-t", full_name, "-p", "-S", f"-{lines}"],
            capture_output=True,
            text=True
        )
        return result.stdout

    # -------------------------------------------------------------------------
    # Private Helpers
    # -------------------------------------------------------------------------

    def _session_exists(self, full_name: str) -> bool:
        """Check if tmux session exists."""
        result = subprocess.run(
            ["tmux", "has-session", "-t", full_name],
            capture_output=True
        )
        return result.returncode == 0

    def _create_session(self, full_name: str) -> bool:
        """Create new tmux session."""
        result = subprocess.run(
            ["tmux", "new-session", "-d", "-s", full_name],
            capture_output=True
        )
        if result.returncode != 0:
            return False

        time.sleep(self.session_init_delay)
        return True

    def _send_keys(self, full_name: str, content: str) -> bool:
        """Send keys to tmux session."""
        result = subprocess.run(
            ["tmux", "send-keys", "-t", full_name, content, "C-m"],
            capture_output=True
        )
        time.sleep(self.message_delay)
        return result.returncode == 0

    def _kill_session(self, full_name: str) -> bool:
        """Kill tmux session."""
        result = subprocess.run(
            ["tmux", "kill-session", "-t", full_name],
            capture_output=True
        )
        return result.returncode == 0

    def _refresh_agents(self) -> None:
        """Refresh agent status from tmux."""
        result = subprocess.run(
            ["tmux", "list-sessions", "-F", "#{session_name}"],
            capture_output=True,
            text=True
        )

        active_sessions = set(result.stdout.strip().split("\n"))
        prefix = f"{self.project_name}-"

        # Remove dead agents
        dead = [name for name in self.agents
                if f"{prefix}{name}" not in active_sessions]
        for name in dead:
            del self.agents[name]
```

## Activity Monitor

```python
# plugins/agent_orchestrator/activity_monitor.py

import asyncio
import hashlib
from typing import Callable, Dict
from dataclasses import dataclass


@dataclass
class AgentState:
    name: str
    last_hash: str = ""
    idle_count: int = 0


class ActivityMonitor:
    """Monitor agent panes for completion via MD5 hashing."""

    def __init__(
        self,
        orchestrator,
        on_agent_complete: Callable,
        poll_interval: int = 2,
        idle_threshold: int = 3,
        capture_lines: int = 500
    ):
        self.orchestrator = orchestrator
        self.on_agent_complete = on_agent_complete
        self.poll_interval = poll_interval
        self.idle_threshold = idle_threshold
        self.capture_lines = capture_lines

        self.tracked: Dict[str, AgentState] = {}
        self._running = False

    def track(self, name: str) -> None:
        """Start tracking an agent."""
        self.tracked[name] = AgentState(name=name)

    def untrack(self, name: str) -> None:
        """Stop tracking an agent."""
        if name in self.tracked:
            del self.tracked[name]

    async def start(self) -> None:
        """Start monitoring loop."""
        self._running = True

        while self._running:
            await self._check_agents()
            await asyncio.sleep(self.poll_interval)

    async def stop(self) -> None:
        """Stop monitoring loop."""
        self._running = False

    async def _check_agents(self) -> None:
        """Check all tracked agents for completion."""
        completed = []

        for name, state in self.tracked.items():
            # Capture current pane content
            content = self.orchestrator.capture_output(name, self.capture_lines)
            current_hash = hashlib.md5(content.encode()).hexdigest()

            if current_hash == state.last_hash:
                # No change - increment idle count
                state.idle_count += 1

                if state.idle_count >= self.idle_threshold:
                    # Agent is done
                    completed.append((name, content))
            else:
                # Content changed - reset idle count
                state.idle_count = 0
                state.last_hash = current_hash

        # Notify completions
        for name, content in completed:
            agent = self.orchestrator.get_agent(name)
            duration = agent.duration if agent else "?"

            await self.on_agent_complete(name, duration, content)
```

## Message Injector

```python
# plugins/agent_orchestrator/message_injector.py

from core.events.types import EventType


class MessageInjector:
    """Inject messages back into conversation as user role."""

    def __init__(self, event_bus, conversation_manager):
        self.event_bus = event_bus
        self.conversation_manager = conversation_manager

    async def inject(
        self,
        source: str,
        content: str,
        trigger_llm: bool = True
    ) -> None:
        """Inject message into conversation."""

        # Format with delimiters
        formatted = f"""--sys_msg_start--
{content}
--sys_msg_end--"""

        # Emit pre-injection event (plugins can modify/block)
        context = {
            "source": source,
            "content": formatted,
            "trigger_llm": trigger_llm
        }
        context = await self.event_bus.emit(
            EventType.PRE_MESSAGE_INJECT,
            context
        )

        if context.get("blocked"):
            return

        # Add to conversation as user message
        self.conversation_manager.add_message(
            role="user",
            content=context["content"]
        )

        # Emit post-injection event
        await self.event_bus.emit(
            EventType.POST_MESSAGE_INJECT,
            context
        )

        # Trigger LLM to continue
        if context.get("trigger_llm", True):
            await self.event_bus.emit(
                EventType.TRIGGER_LLM_CONTINUE,
                {}
            )
```

## File Attacher

```python
# plugins/agent_orchestrator/file_attacher.py

from pathlib import Path
from typing import List


class FileAttacher:
    """Read and format files for attachment to agent tasks."""

    def attach(self, file_paths: List[str]) -> str:
        """Read files and format with delimiters."""
        parts = []

        for file_path in file_paths:
            content = self._read_file(file_path)
            if content is not None:
                parts.append(self._format_file(file_path, content))

        return "\n\n".join(parts)

    def _read_file(self, file_path: str) -> str | None:
        """Read file contents."""
        path = Path(file_path)

        if not path.exists():
            return None

        try:
            return path.read_text()
        except Exception:
            return None

    def _format_file(self, file_path: str, content: str) -> str:
        """Format file with delimiters."""
        return f"""--file_start {file_path}--
{content}
--file_end--"""
```

## Models

```python
# plugins/agent_orchestrator/models.py

from dataclasses import dataclass, field
from typing import List


@dataclass
class AgentTask:
    """Parsed agent task from XML."""
    name: str
    task: str
    files: List[str] = field(default_factory=list)


@dataclass
class AgentSession:
    """Running agent session."""
    name: str
    full_name: str
    status: str
    start_time: float

    @property
    def duration(self) -> str:
        import time
        elapsed = time.time() - self.start_time
        minutes = int(elapsed // 60)
        seconds = int(elapsed % 60)
        return f"{minutes}m{seconds:02d}s"
```

## New Event Types

```python
# Add to core/events/types.py

class EventType(Enum):
    # ... existing events ...

    # SDK extension events (in core EventType enum)
    PRE_MESSAGE_INJECT = "pre_message_inject"
    POST_MESSAGE_INJECT = "post_message_inject"
    TRIGGER_LLM_CONTINUE = "trigger_llm_continue"

# Plugin-specific events (string literals, NOT in core EventType)
# Used via: await event_bus.emit_with_hooks("agent_spawned", {...}, "agent_orchestrator")
AGENT_SPAWNED = "agent_spawned"      # String literal
AGENT_COMPLETED = "agent_completed"  # String literal
AGENT_STOPPED = "agent_stopped"      # String literal
```

## Config

```json
{
  "agents": {
    "enabled": true,
    "poll_interval": 2,
    "idle_threshold": 3,
    "capture_lines": 500,
    "max_concurrent": 10
  }
}
```

## Testing

```python
# tests/test_agent_orchestrator.py

import pytest
from plugins.agent_orchestrator.xml_parser import XMLCommandParser


class TestXMLParser:

    def test_parse_agent_block(self):
        parser = XMLCommandParser()
        text = """
        <agent>
          <lint-fixer>
            <task>Fix lint errors</task>
            <files>
              <file>core/io/input.py</file>
            </files>
          </lint-fixer>
        </agent>
        """
        commands = parser.parse(text)
        assert len(commands) == 1
        assert commands[0].type == "agent"
        assert len(commands[0].agents) == 1
        assert commands[0].agents[0].name == "lint-fixer"
        assert commands[0].agents[0].files == ["core/io/input.py"]

    def test_parse_message(self):
        parser = XMLCommandParser()
        text = '<message to="lint-fixer">Check imports</message>'
        commands = parser.parse(text)
        assert len(commands) == 1
        assert commands[0].type == "message"
        assert commands[0].target == "lint-fixer"
        assert commands[0].content == "Check imports"

    def test_parse_capture(self):
        parser = XMLCommandParser()
        text = '<capture>lint-fixer 200</capture>'
        commands = parser.parse(text)
        assert len(commands) == 1
        assert commands[0].type == "capture"
        assert commands[0].target == "lint-fixer"
        assert commands[0].lines == 200

    def test_parse_stop_multiple(self):
        parser = XMLCommandParser()
        text = '<stop>lint-0, lint-1, lint-2</stop>'
        commands = parser.parse(text)
        assert len(commands) == 1
        assert commands[0].targets == ["lint-0", "lint-1", "lint-2"]
```

## Implementation Order

1. Create plugin directory structure
2. Implement models.py (dataclasses)
3. Implement xml_parser.py with tests
4. Implement file_attacher.py
5. Implement orchestrator.py (tmux operations)
6. Implement activity_monitor.py
7. Implement message_injector.py
8. Add new EventTypes
9. Implement plugin.py (main class)
10. Add CLI arg registration support to base plugin
11. Integration testing
