# Background Tasks Plugin Specification

## Overview

A plugin that enables the LLM to run terminal commands in the background without blocking the main conversation loop. Follows the Claude Code model using subprocess with output buffering rather than tmux.

## Problem Statement

Current `<terminal>` execution blocks until command completes. This causes:
- UI lockup on long-running commands (builds, servers, tests)
- ESC cancellation doesn't work during execution
- No way to run persistent processes (dev servers, watchers)

## Solution

Add background execution capability via:
1. New `<bg>` XML tag for background commands
2. Task registry to track running processes
3. Output buffering for later retrieval
4. Status/kill commands for lifecycle management

## Architecture

```
plugins/background_tasks/
  __init__.py
  plugin.py              # Main plugin, hooks, XML parsing
  models.py              # BackgroundTask, TaskStatus dataclasses
  task_registry.py       # Singleton registry of running tasks
  process_executor.py    # asyncio subprocess management
  output_buffer.py       # Circular buffer for stdout/stderr
```

## Data Models

```python
# models.py

from dataclasses import dataclass, field
from typing import Optional
from enum import Enum
import time
import asyncio


class TaskStatus(Enum):
    PENDING = "pending"
    RUNNING = "running"
    COMPLETED = "completed"
    FAILED = "failed"
    KILLED = "killed"


@dataclass
class BackgroundTask:
    """A background task instance."""

    id: str                                    # User-provided or auto-generated
    command: str                               # Shell command to execute
    status: TaskStatus = TaskStatus.PENDING
    exit_code: Optional[int] = None
    start_time: float = field(default_factory=time.time)
    end_time: Optional[float] = None
    process: Optional[asyncio.subprocess.Process] = None

    # Output buffering
    stdout_buffer: list = field(default_factory=list)
    stderr_buffer: list = field(default_factory=list)
    max_buffer_lines: int = 1000

    @property
    def duration(self) -> str:
        """Formatted duration string."""
        end = self.end_time or time.time()
        elapsed = end - self.start_time
        if elapsed < 60:
            return f"{elapsed:.1f}s"
        minutes = int(elapsed // 60)
        seconds = int(elapsed % 60)
        return f"{minutes}m{seconds:02d}s"

    @property
    def is_running(self) -> bool:
        return self.status == TaskStatus.RUNNING

    def append_stdout(self, line: str) -> None:
        """Append to stdout buffer with overflow protection."""
        self.stdout_buffer.append(line)
        if len(self.stdout_buffer) > self.max_buffer_lines:
            self.stdout_buffer.pop(0)

    def append_stderr(self, line: str) -> None:
        """Append to stderr buffer with overflow protection."""
        self.stderr_buffer.append(line)
        if len(self.stderr_buffer) > self.max_buffer_lines:
            self.stderr_buffer.pop(0)

    def get_output(self, lines: int = 50, stream: str = "both") -> str:
        """Get last N lines of output."""
        if stream == "stdout":
            output = self.stdout_buffer[-lines:]
        elif stream == "stderr":
            output = self.stderr_buffer[-lines:]
        else:
            # Interleave would require timestamps; just concat for now
            combined = self.stdout_buffer + self.stderr_buffer
            output = combined[-lines:]
        return "\n".join(output)
```

## XML Syntax

### Start Background Task

```xml
<bg id="server">npm run dev</bg>
<bg id="build" timeout="5m">npm run build</bg>
<bg>pytest tests/</bg>  <!-- auto-generated ID -->
```

Attributes:
- `id` (optional): Task identifier. Auto-generated if not provided.
- `timeout` (optional): Auto-kill after duration. Format: `30s`, `5m`, `1h`.
- `cwd` (optional): Working directory. Defaults to current.

### Check Status

```xml
<bg-status />           <!-- All tasks -->
<bg-status>server</bg-status>  <!-- Specific task -->
```

### Get Output

```xml
<bg-output>server</bg-output>              <!-- Last 50 lines -->
<bg-output lines="200">server</bg-output>  <!-- Last 200 lines -->
<bg-output stream="stderr">build</bg-output>  <!-- stderr only -->
```

### Kill Task

```xml
<bg-kill>server</bg-kill>
<bg-kill signal="SIGKILL">server</bg-kill>  <!-- Force kill -->
<bg-kill>*</bg-kill>  <!-- Kill all -->
```

### Wait for Completion

```xml
<bg-wait>build</bg-wait>              <!-- Wait indefinitely -->
<bg-wait timeout="60s">build</bg-wait>  <!-- Wait with timeout -->
```

## Plugin Implementation

```python
# plugin.py

import asyncio
import logging
import re
from typing import Dict, Any, Optional, List

from core.plugins.base import BasePlugin
from core.events.models import EventType, Hook, HookPriority
from core.io.visual_effects import ColorPalette
from core.io.message_renderer import DisplayFilterRegistry, MessageType

from .models import BackgroundTask, TaskStatus
from .task_registry import TaskRegistry
from .process_executor import ProcessExecutor

logger = logging.getLogger(__name__)

BG_INSTRUCTIONS = """
## Background Tasks

Run long-running commands without blocking. Commands execute asynchronously.

### Start Background Task
```xml
<bg id="server">npm run dev</bg>
<bg id="build" timeout="5m">npm run build</bg>
```

### Check Status
```xml
<bg-status />
```

### Get Output
```xml
<bg-output lines="100">server</bg-output>
```

### Kill Task
```xml
<bg-kill>server</bg-kill>
```

Output ONLY the XML tag. Results are injected automatically.
""".strip()

TRIGGER_KEYWORDS = [
    "background",
    "run in background",
    "start server",
    "dev server",
    "long running",
    "<bg",
    "bg-status",
    "bg-output",
    "bg-kill",
]


class BackgroundTasksPlugin(BasePlugin):
    """Plugin for running terminal commands in the background."""

    def __init__(
        self,
        name: str = "background_tasks",
        event_bus=None,
        renderer=None,
        config=None,
    ):
        self.name = name
        self.version = "1.0.0"
        self.description = "Run terminal commands in the background"
        self.enabled = True

        self.event_bus = event_bus
        self.renderer = renderer
        self.config = config
        self.conversation_manager = None

        # Components
        self.registry = TaskRegistry()
        self.executor = ProcessExecutor(self.registry)

        # Injection tracking
        self._last_injection_time: float = 0.0

        self.logger = logger

    async def initialize(self, **kwargs) -> None:
        """Initialize plugin components."""
        if "event_bus" in kwargs:
            self.event_bus = kwargs["event_bus"]
        if "config" in kwargs:
            self.config = kwargs["config"]
        if "conversation_manager" in kwargs:
            self.conversation_manager = kwargs["conversation_manager"]

        # Register display filter
        DisplayFilterRegistry.register(
            name="background_tasks",
            filter_fn=self._strip_bg_xml,
            message_types=[MessageType.ASSISTANT],
            priority=100,
        )

        logger.info("Background tasks plugin initialized")

    def shutdown(self) -> None:
        """Cleanup on shutdown."""
        # Kill all running tasks
        asyncio.create_task(self._cleanup_all_tasks())
        DisplayFilterRegistry.unregister("background_tasks")
        logger.info("Background tasks plugin shutdown")

    async def _cleanup_all_tasks(self) -> None:
        """Kill all running tasks on shutdown."""
        for task in self.registry.list_tasks():
            if task.is_running:
                await self.executor.kill(task.id)

    def _strip_bg_xml(self, content: str, message_type: MessageType) -> str:
        """Strip background task XML from displayed content."""
        content = re.sub(r"<bg[^>]*>.*?</bg>\s*", "", content, flags=re.DOTALL)
        content = re.sub(r"<bg-status[^>]*/>\s*", "", content)
        content = re.sub(r"<bg-status>.*?</bg-status>\s*", "", content, flags=re.DOTALL)
        content = re.sub(r"<bg-output[^>]*>.*?</bg-output>\s*", "", content, flags=re.DOTALL)
        content = re.sub(r"<bg-kill[^>]*>.*?</bg-kill>\s*", "", content, flags=re.DOTALL)
        content = re.sub(r"<bg-wait[^>]*>.*?</bg-wait>\s*", "", content, flags=re.DOTALL)
        return content

    async def register_hooks(self) -> None:
        """Register hooks for LLM response processing."""
        if not self.event_bus:
            return

        response_hook = Hook(
            name="background_tasks_response",
            plugin_name=self.name,
            event_type=EventType.LLM_RESPONSE_POST,
            callback=self._on_llm_response,
            priority=HookPriority.POSTPROCESSING.value,
        )
        await self.event_bus.register_hook(response_hook)

        keyword_hook = Hook(
            name="background_tasks_keyword",
            plugin_name=self.name,
            event_type=EventType.USER_INPUT_PRE,
            callback=self._on_user_input,
            priority=HookPriority.PREPROCESSING.value,
        )
        await self.event_bus.register_hook(keyword_hook)

        logger.info("Registered background tasks hooks")

    @staticmethod
    def get_default_config() -> Dict[str, Any]:
        """Default configuration."""
        return {
            "plugins": {
                "background_tasks": {
                    "enabled": True,
                    "max_tasks": 10,
                    "default_buffer_lines": 1000,
                    "default_timeout": None,  # No timeout by default
                    "enable_mode": "keyword",  # keyword, startup, disabled
                }
            }
        }

    async def _on_user_input(self, data: dict, event) -> dict:
        """Check for trigger keywords and inject instructions."""
        import time

        if not self.config:
            return data

        enable_mode = self.config.get("plugins.background_tasks.enable_mode", "keyword")
        if enable_mode != "keyword":
            return data

        user_input = data.get("message", data.get("input", "")).lower()
        triggered = any(kw in user_input for kw in TRIGGER_KEYWORDS)

        if triggered and self._last_injection_time == 0.0:
            original = data.get("message", data.get("input", ""))
            injected = f"<sys_msg>\n{BG_INSTRUCTIONS}\n</sys_msg>\n\n{original}"
            data["message"] = injected
            if "input" in data:
                data["input"] = injected
            self._last_injection_time = time.time()
            logger.info("Injected background tasks instructions")

        return data

    async def _on_llm_response(self, data: dict, event) -> dict:
        """Process LLM response for background task commands."""
        response_text = data.get("response_text", data.get("content", ""))
        if not response_text:
            return data

        commands = self._parse_commands(response_text)
        if not commands:
            return data

        logger.info(f"Found {len(commands)} background task command(s)")

        results = []
        for cmd in commands:
            try:
                self._display_tool_indicator(cmd)
                result = await self._execute_command(cmd)
                if result:
                    results.append(result)
            except Exception as e:
                logger.error(f"Error executing bg command: {e}")
                results.append(f"[error: {e}]")

        # Inject results back to conversation
        if results and self.conversation_manager:
            result_text = "\n".join(results)
            # Use message injector pattern from agent_orchestrator
            await self._inject_result(result_text)
            data["force_continue"] = True

        return data

    def _parse_commands(self, text: str) -> List[dict]:
        """Parse background task XML commands from text."""
        commands = []

        # <bg id="..." timeout="...">command</bg>
        bg_pattern = re.compile(
            r'<bg(?:\s+id=["\']([^"\']+)["\'])?(?:\s+timeout=["\']([^"\']+)["\'])?(?:\s+cwd=["\']([^"\']+)["\'])?>\s*(.*?)\s*</bg>',
            re.DOTALL | re.IGNORECASE
        )
        for match in bg_pattern.finditer(text):
            commands.append({
                "type": "bg",
                "id": match.group(1),
                "timeout": match.group(2),
                "cwd": match.group(3),
                "command": match.group(4).strip(),
            })

        # <bg-status /> or <bg-status>id</bg-status>
        status_pattern = re.compile(
            r'<bg-status(?:\s*/|>([^<]*)</bg-status)>',
            re.IGNORECASE
        )
        for match in status_pattern.finditer(text):
            commands.append({
                "type": "status",
                "id": match.group(1).strip() if match.group(1) else None,
            })

        # <bg-output lines="...">id</bg-output>
        output_pattern = re.compile(
            r'<bg-output(?:\s+lines=["\'](\d+)["\'])?(?:\s+stream=["\']([^"\']+)["\'])?>([^<]+)</bg-output>',
            re.IGNORECASE
        )
        for match in output_pattern.finditer(text):
            commands.append({
                "type": "output",
                "lines": int(match.group(1)) if match.group(1) else 50,
                "stream": match.group(2) or "both",
                "id": match.group(3).strip(),
            })

        # <bg-kill signal="...">id</bg-kill>
        kill_pattern = re.compile(
            r'<bg-kill(?:\s+signal=["\']([^"\']+)["\'])?>([^<]+)</bg-kill>',
            re.IGNORECASE
        )
        for match in kill_pattern.finditer(text):
            commands.append({
                "type": "kill",
                "signal": match.group(1) or "SIGTERM",
                "id": match.group(2).strip(),
            })

        # <bg-wait timeout="...">id</bg-wait>
        wait_pattern = re.compile(
            r'<bg-wait(?:\s+timeout=["\']([^"\']+)["\'])?>([^<]+)</bg-wait>',
            re.IGNORECASE
        )
        for match in wait_pattern.finditer(text):
            commands.append({
                "type": "wait",
                "timeout": match.group(1),
                "id": match.group(2).strip(),
            })

        return commands

    def _display_tool_indicator(self, cmd: dict) -> None:
        """Display tool call indicator."""
        if not self.renderer:
            return

        indicator = f"{ColorPalette.BRIGHT_LIME}⏺{ColorPalette.RESET}"

        if cmd["type"] == "bg":
            tool_line = f"{indicator} bg({cmd.get('id', 'auto')})"
        elif cmd["type"] == "status":
            tool_line = f"{indicator} bg-status({cmd.get('id', 'all')})"
        elif cmd["type"] == "output":
            tool_line = f"{indicator} bg-output({cmd['id']}, {cmd['lines']})"
        elif cmd["type"] == "kill":
            tool_line = f"{indicator} bg-kill({cmd['id']})"
        elif cmd["type"] == "wait":
            tool_line = f"{indicator} bg-wait({cmd['id']})"
        else:
            tool_line = f"{indicator} bg-{cmd['type']}()"

        self.renderer.message_coordinator.display_message_sequence([
            ("system", tool_line, {})
        ])

    async def _execute_command(self, cmd: dict) -> str:
        """Execute a parsed command."""
        if cmd["type"] == "bg":
            return await self._start_task(cmd)
        elif cmd["type"] == "status":
            return self._get_status(cmd.get("id"))
        elif cmd["type"] == "output":
            return self._get_output(cmd["id"], cmd["lines"], cmd["stream"])
        elif cmd["type"] == "kill":
            return await self._kill_task(cmd["id"], cmd["signal"])
        elif cmd["type"] == "wait":
            return await self._wait_task(cmd["id"], cmd.get("timeout"))
        return f"[error: unknown command type '{cmd['type']}']"

    async def _start_task(self, cmd: dict) -> str:
        """Start a new background task."""
        task_id = cmd.get("id") or self._generate_id()
        command = cmd["command"]
        timeout = cmd.get("timeout")
        cwd = cmd.get("cwd")

        # Check max tasks
        max_tasks = self.config.get("plugins.background_tasks.max_tasks", 10) if self.config else 10
        if len(self.registry.list_running()) >= max_tasks:
            return f"[error: max tasks ({max_tasks}) reached]"

        # Check duplicate ID
        if self.registry.get(task_id):
            return f"[error: task '{task_id}' already exists]"

        # Start the task
        task = await self.executor.start(task_id, command, timeout=timeout, cwd=cwd)
        if task:
            return f"[started: {task_id}] {command[:50]}..."
        return f"[error: failed to start task '{task_id}']"

    def _get_status(self, task_id: str = None) -> str:
        """Get status of tasks."""
        if task_id:
            task = self.registry.get(task_id)
            if not task:
                return f"[error: task '{task_id}' not found]"
            return f"[{task_id}] {task.status.value} ({task.duration})"

        tasks = self.registry.list_tasks()
        if not tasks:
            return "[bg-tasks]\n  (none)"

        lines = ["[bg-tasks]"]
        for task in tasks:
            status_icon = "●" if task.is_running else "○"
            lines.append(f"  {status_icon} {task.id:<15} {task.status.value:<10} {task.duration}")
        return "\n".join(lines)

    def _get_output(self, task_id: str, lines: int, stream: str) -> str:
        """Get output from a task."""
        task = self.registry.get(task_id)
        if not task:
            return f"[error: task '{task_id}' not found]"

        output = task.get_output(lines, stream)
        return f"[output: {task_id}, {lines} lines]\n{output}"

    async def _kill_task(self, task_id: str, signal: str) -> str:
        """Kill a task."""
        if task_id == "*":
            killed = []
            for task in self.registry.list_running():
                await self.executor.kill(task.id, signal)
                killed.append(task.id)
            if killed:
                return f"[killed: {', '.join(killed)}]"
            return "[no running tasks to kill]"

        task = self.registry.get(task_id)
        if not task:
            return f"[error: task '{task_id}' not found]"

        if not task.is_running:
            return f"[error: task '{task_id}' not running]"

        await self.executor.kill(task_id, signal)
        return f"[killed: {task_id}]"

    async def _wait_task(self, task_id: str, timeout: str = None) -> str:
        """Wait for a task to complete."""
        task = self.registry.get(task_id)
        if not task:
            return f"[error: task '{task_id}' not found]"

        if not task.is_running:
            return f"[{task_id}] already {task.status.value} (exit: {task.exit_code})"

        timeout_seconds = self._parse_timeout(timeout) if timeout else None

        try:
            if timeout_seconds:
                await asyncio.wait_for(
                    self.executor.wait(task_id),
                    timeout=timeout_seconds
                )
            else:
                await self.executor.wait(task_id)

            task = self.registry.get(task_id)
            return f"[{task_id}] {task.status.value} (exit: {task.exit_code}, {task.duration})"

        except asyncio.TimeoutError:
            return f"[{task_id}] still running after {timeout}"

    def _generate_id(self) -> str:
        """Generate unique task ID."""
        import random
        import string
        return "task-" + "".join(random.choices(string.ascii_lowercase + string.digits, k=6))

    def _parse_timeout(self, timeout_str: str) -> float:
        """Parse timeout string to seconds."""
        if not timeout_str:
            return None
        timeout_str = timeout_str.lower().strip()
        if timeout_str.endswith("s"):
            return float(timeout_str[:-1])
        elif timeout_str.endswith("m"):
            return float(timeout_str[:-1]) * 60
        elif timeout_str.endswith("h"):
            return float(timeout_str[:-1]) * 3600
        return float(timeout_str)

    async def _inject_result(self, content: str) -> None:
        """Inject result into conversation."""
        if self.conversation_manager:
            # Add as system message for LLM context
            self.conversation_manager.add_message("system", content)
```

## Process Executor

```python
# process_executor.py

import asyncio
import logging
import signal
import os
from typing import Optional

from .models import BackgroundTask, TaskStatus
from .task_registry import TaskRegistry

logger = logging.getLogger(__name__)


class ProcessExecutor:
    """Manages asyncio subprocess execution."""

    def __init__(self, registry: TaskRegistry):
        self.registry = registry
        self._output_tasks: dict = {}  # task_id -> output reader task

    async def start(
        self,
        task_id: str,
        command: str,
        timeout: str = None,
        cwd: str = None,
    ) -> Optional[BackgroundTask]:
        """Start a background task.

        Args:
            task_id: Unique task identifier.
            command: Shell command to execute.
            timeout: Optional auto-kill timeout (e.g., "5m").
            cwd: Working directory.

        Returns:
            BackgroundTask instance if successful.
        """
        try:
            # Create task object
            task = BackgroundTask(id=task_id, command=command)

            # Start subprocess
            process = await asyncio.create_subprocess_shell(
                command,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
                cwd=cwd or os.getcwd(),
                preexec_fn=os.setsid,  # Create new process group for clean kill
            )

            task.process = process
            task.status = TaskStatus.RUNNING

            # Register task
            self.registry.register(task)

            # Start output readers
            self._output_tasks[task_id] = asyncio.create_task(
                self._read_output(task)
            )

            # Start timeout watcher if specified
            if timeout:
                timeout_seconds = self._parse_timeout(timeout)
                asyncio.create_task(self._timeout_watcher(task_id, timeout_seconds))

            logger.info(f"Started background task: {task_id}")
            return task

        except Exception as e:
            logger.error(f"Failed to start task {task_id}: {e}")
            return None

    async def _read_output(self, task: BackgroundTask) -> None:
        """Read stdout/stderr and buffer output."""
        async def read_stream(stream, append_fn):
            while True:
                line = await stream.readline()
                if not line:
                    break
                append_fn(line.decode("utf-8", errors="replace").rstrip())

        try:
            await asyncio.gather(
                read_stream(task.process.stdout, task.append_stdout),
                read_stream(task.process.stderr, task.append_stderr),
            )

            # Wait for process to complete
            exit_code = await task.process.wait()
            task.exit_code = exit_code
            task.status = TaskStatus.COMPLETED if exit_code == 0 else TaskStatus.FAILED
            task.end_time = __import__("time").time()

            logger.info(f"Task {task.id} completed with exit code {exit_code}")

        except asyncio.CancelledError:
            logger.debug(f"Output reader cancelled for {task.id}")
        except Exception as e:
            logger.error(f"Error reading output for {task.id}: {e}")
            task.status = TaskStatus.FAILED

    async def _timeout_watcher(self, task_id: str, timeout: float) -> None:
        """Kill task after timeout."""
        await asyncio.sleep(timeout)
        task = self.registry.get(task_id)
        if task and task.is_running:
            logger.info(f"Task {task_id} timed out after {timeout}s")
            await self.kill(task_id)

    async def kill(self, task_id: str, sig: str = "SIGTERM") -> bool:
        """Kill a running task.

        Args:
            task_id: Task to kill.
            sig: Signal name (SIGTERM, SIGKILL, SIGINT).

        Returns:
            True if killed successfully.
        """
        task = self.registry.get(task_id)
        if not task or not task.process:
            return False

        try:
            # Get signal number
            sig_num = getattr(signal, sig, signal.SIGTERM)

            # Kill process group (includes children)
            os.killpg(os.getpgid(task.process.pid), sig_num)

            task.status = TaskStatus.KILLED
            task.end_time = __import__("time").time()

            # Cancel output reader
            if task_id in self._output_tasks:
                self._output_tasks[task_id].cancel()
                del self._output_tasks[task_id]

            logger.info(f"Killed task {task_id} with {sig}")
            return True

        except ProcessLookupError:
            # Process already dead
            task.status = TaskStatus.COMPLETED
            return True
        except Exception as e:
            logger.error(f"Failed to kill task {task_id}: {e}")
            return False

    async def wait(self, task_id: str) -> None:
        """Wait for task to complete."""
        task = self.registry.get(task_id)
        if not task or not task.process:
            return

        await task.process.wait()

    def _parse_timeout(self, timeout_str: str) -> float:
        """Parse timeout string to seconds."""
        timeout_str = timeout_str.lower().strip()
        if timeout_str.endswith("s"):
            return float(timeout_str[:-1])
        elif timeout_str.endswith("m"):
            return float(timeout_str[:-1]) * 60
        elif timeout_str.endswith("h"):
            return float(timeout_str[:-1]) * 3600
        return float(timeout_str)
```

## Task Registry

```python
# task_registry.py

import threading
from typing import Dict, List, Optional

from .models import BackgroundTask


class TaskRegistry:
    """Thread-safe registry of background tasks."""

    def __init__(self):
        self._tasks: Dict[str, BackgroundTask] = {}
        self._lock = threading.Lock()

    def register(self, task: BackgroundTask) -> None:
        """Register a new task."""
        with self._lock:
            self._tasks[task.id] = task

    def unregister(self, task_id: str) -> None:
        """Remove a task from registry."""
        with self._lock:
            self._tasks.pop(task_id, None)

    def get(self, task_id: str) -> Optional[BackgroundTask]:
        """Get task by ID."""
        with self._lock:
            return self._tasks.get(task_id)

    def list_tasks(self) -> List[BackgroundTask]:
        """List all tasks."""
        with self._lock:
            return list(self._tasks.values())

    def list_running(self) -> List[BackgroundTask]:
        """List only running tasks."""
        with self._lock:
            return [t for t in self._tasks.values() if t.is_running]

    def clear_completed(self) -> int:
        """Remove completed/failed tasks. Returns count removed."""
        with self._lock:
            to_remove = [
                tid for tid, task in self._tasks.items()
                if not task.is_running
            ]
            for tid in to_remove:
                del self._tasks[tid]
            return len(to_remove)
```

## Slash Command Integration

Register `/bg` command for user interaction:

```python
# In plugin.py initialize()

if "command_registry" in kwargs:
    self._register_commands(kwargs["command_registry"])

def _register_commands(self, registry) -> None:
    """Register slash commands."""
    from core.events.models import CommandDefinition, CommandCategory, SubcommandInfo

    bg_command = CommandDefinition(
        name="bg",
        description="Manage background tasks",
        category=CommandCategory.TOOLS,
        aliases=["background", "tasks"],
        handler=self._handle_bg_command,
        subcommands=[
            SubcommandInfo("list", "", "List all background tasks"),
            SubcommandInfo("kill", "<id>", "Kill a background task"),
            SubcommandInfo("output", "<id>", "Show task output"),
            SubcommandInfo("clear", "", "Clear completed tasks"),
        ]
    )
    registry.register_command(bg_command)
```

## Configuration

```json
{
  "plugins": {
    "background_tasks": {
      "enabled": true,
      "max_tasks": 10,
      "default_buffer_lines": 1000,
      "default_timeout": null,
      "enable_mode": "keyword",
      "cleanup_completed_after": "5m"
    }
  }
}
```

## Event Integration

Emit events for other plugins to react:

```python
# Events emitted (string literals, not in core EventType)
"bg_task_started"   # {"id": str, "command": str}
"bg_task_completed" # {"id": str, "exit_code": int, "duration": str}
"bg_task_failed"    # {"id": str, "exit_code": int, "error": str}
"bg_task_killed"    # {"id": str}
```

## User Interaction Flow

```
User: "start a dev server in the background"

[keyword trigger injects BG_INSTRUCTIONS]

LLM: <bg id="dev">npm run dev</bg>

[Plugin displays: ⏺ bg(dev)]
[Plugin starts subprocess, buffers output]
[Plugin injects: "[started: dev] npm run dev..."]

LLM: I've started the dev server. You can check its status with <bg-status />
     or view output with <bg-output>dev</bg-output>.

User: "show me the server output"

LLM: <bg-output lines="50">dev</bg-output>

[Plugin displays: ⏺ bg-output(dev, 50)]
[Plugin injects: "[output: dev, 50 lines]\n...server logs..."]

LLM: Here's the recent server output:
     [displays formatted logs]
```

## Testing Strategy

1. Unit tests for each component (models, registry, executor)
2. Integration tests for XML parsing
3. Mock subprocess tests for process lifecycle
4. End-to-end tests with real commands (echo, sleep)

## Implementation Phases

1. **Phase A**: Core models and registry
2. **Phase B**: Process executor with output buffering
3. **Phase C**: Plugin with XML parsing and hooks
4. **Phase D**: Slash command integration
5. **Phase E**: Tests and documentation
