# AMC Agent Spawning via Zellij

## Summary

Add the ability to spawn new agent sessions (Claude or Codex) from the AMC dashboard. Users click "New Agent" in the page header, select an agent type, and a new Zellij pane opens in a project-named tab. The spawned agent appears in the dashboard alongside existing sessions.

**Why this matters:** Currently, AMC monitors existing sessions but cannot create new ones. Users must manually open terminal panes and run `claude` or `codex`. This feature enables orchestration workflows where the dashboard becomes a control center for multi-agent coordination.

**Core insight from research:** Zellij's CLI (`zellij --session <name> action new-pane ...`) works from external processes without requiring `ZELLIJ` environment variables.

---

## User Workflows

### Workflow 1: Spawn Agent from Project Tab

**Trigger:** User is viewing a specific project tab in the dashboard sidebar, clicks "New Agent" in header.

**Flow:**
1. User is on "amc" project tab in dashboard sidebar
2. User clicks "+ New Agent" button in page header
3. Modal appears with agent type selector: Claude / Codex
4. User selects "Claude", clicks "Spawn"
5. Server finds or creates Zellij tab named "amc"
6. New pane spawns in that tab with `claude --dangerously-skip-permissions`
7. Dashboard updates: new session card appears (status: "starting")

**Key behavior:** Path is automatically determined from the selected project tab.

### Workflow 2: Spawn Agent from "All Projects" Tab

**Trigger:** User is on "All Projects" tab, clicks "New Agent" in header.

**Flow:**
1. User is on "All Projects" tab (no specific project selected)
2. User clicks "+ New Agent" button in page header
3. Modal appears with:
   - Project dropdown (lists subdirectories of `~/projects/`)
   - Agent type selector: Claude / Codex
4. User selects "mission-control" project, "Codex" agent type, clicks "Spawn"
5. Server finds or creates Zellij tab named "mission-control"
6. New pane spawns with `codex --dangerously-bypass-approvals-and-sandbox`
7. Dashboard updates: new session card appears

**Key behavior:** User must select a project from the dropdown when on "All Projects".

---

## Acceptance Criteria

### Spawn Button Location & Context

- **AC-1:** "New Agent" button is located in the page header, not on session cards
- **AC-2:** When on a specific project tab, the spawn modal does not show a project picker
- **AC-3:** When on "All Projects" tab, the spawn modal shows a project dropdown

### Project Selection (All Projects Tab)

- **AC-4:** The project dropdown lists all immediate subdirectories of `~/projects/`
- **AC-5:** Hidden directories (starting with `.`) are excluded from the dropdown
- **AC-6:** User must select a project before spawning (no default selection)

### Agent Type Selection

- **AC-7:** User can choose between Claude and Codex agent types
- **AC-8:** Claude agents spawn with full autonomous permissions enabled
- **AC-9:** Codex agents spawn with full autonomous permissions enabled

### Zellij Tab Targeting

- **AC-10:** Agents spawn in a Zellij tab named after the project (e.g., "amc" tab for amc project)
- **AC-11:** If the project-named tab does not exist, it is created before spawning
- **AC-12:** All spawns target the "infra" Zellij session

### Pane Spawning

- **AC-13:** The spawned pane's cwd is set to the project directory
- **AC-14:** Spawned panes are named `{agent_type}-{project}` (e.g., "claude-amc")
- **AC-15:** The spawned agent appears in the dashboard within 10 seconds of spawn

### Session Discovery

- **AC-16:** Spawned agent's session data includes correct `zellij_session` and `zellij_pane`
- **AC-17:** Dashboard can send responses to spawned agents (existing functionality works)

### Error Handling

- **AC-18:** Spawning fails gracefully if Zellij binary is not found
- **AC-19:** Spawning fails gracefully if target project directory does not exist
- **AC-20:** Spawn errors display a toast notification showing the server's error message
- **AC-21:** Network errors between dashboard and server show retry option

### Security

- **AC-22:** Server validates project path is within `~/projects/` (resolves symlinks)
- **AC-23:** Server rejects path traversal attempts in project parameter
- **AC-24:** Server binds to localhost only (127.0.0.1), not exposed to network
- **AC-37:** Server generates a one-time auth token on startup and injects it into dashboard HTML
- **AC-38:** `/api/spawn` requires valid auth token in `Authorization` header
- **AC-39:** CORS headers are consistent across all endpoints (`Access-Control-Allow-Origin: *`); localhost-only binding (AC-24) is the security boundary

### Spawn Request Lifecycle

- **AC-25:** The Spawn button is disabled while a spawn request is in progress
- **AC-26:** If the target Zellij session does not exist, spawn fails with error "Zellij session 'infra' not found"
- **AC-27:** Server generates a unique `spawn_id` and passes it to the agent via `AMC_SPAWN_ID` env var
- **AC-28:** `amc-hook` writes `spawn_id` to session file when present in environment
- **AC-29:** Spawn request polls for session file containing the specific `spawn_id` (max 10 second wait)
- **AC-30:** Concurrent spawn requests are serialized via a lock to prevent Zellij race conditions

### Modal Behavior

- **AC-31:** Spawn modal can be dismissed by clicking outside, pressing Escape, or clicking Cancel
- **AC-32:** While fetching the projects list, the project dropdown displays "Loading..." and is disabled

### Projects List Caching

- **AC-33:** Projects list is loaded on server start and cached in memory
- **AC-34:** Projects list can be refreshed via `POST /api/projects/refresh`
- **AC-40:** Projects list auto-refreshes every 5 minutes in background thread

### Rate Limiting

- **AC-35:** Spawn requests for the same project are throttled to 1 per 10 seconds
- **AC-36:** Rate limit errors return `RATE_LIMITED` code with retry-after hint

### Health Check

- **AC-41:** `GET /api/health` returns server status including Zellij session availability
- **AC-42:** Dashboard shows warning banner when Zellij session is unavailable

---

## Architecture

### Component Overview

```
┌─────────────────────────────────────────────────────────────┐
│                     Dashboard (Preact)                       │
│                                                              │
│  ┌──────────────────────────────────────────────────────┐   │
│  │ Header                        [+ New Agent]          │   │
│  └──────────────────────────────────────────────────────┘   │
│  ┌────────────┐  ┌──────────────────────────────────────┐   │
│  │ Sidebar    │  │ Main Content                         │   │
│  │            │  │                                      │   │
│  │ All Proj.  │  │  ┌─────────┐ ┌─────────┐            │   │
│  │ > amc      │  │  │ Session │ │ Session │            │   │
│  │ > gitlore  │  │  │  Card   │ │  Card   │            │   │
│  │            │  │  └─────────┘ └─────────┘            │   │
│  └────────────┘  └──────────────────────────────────────┘   │
│                                                              │
│  ┌──────────────────────────────────────────────────────┐   │
│  │ SpawnModal (context-aware)                           │   │
│  │  - If on project tab: just agent type picker         │   │
│  │  - If on All Projects: project dropdown + agent type │   │
│  └──────────────────────────────────────────────────────┘   │
└──────────────────────────────────────────────────────────────┘
                           │
                    POST /api/spawn
                           │
                           ▼
┌──────────────────────────────────────────────────────────────┐
│                     AMC Server (Python)                       │
│  ┌─────────────────────────────────────────────────────────┐ │
│  │                    SpawnMixin                            │ │
│  │  - list_projects()        → ~/projects/* dirs           │ │
│  │  - validate_spawn()       → security checks             │ │
│  │  - ensure_tab_exists()    → create tab if needed        │ │
│  │  - spawn_agent_pane()     → zellij action new-pane      │ │
│  └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
                           │
            subprocess.run(["zellij", ...])
                           │
                           ▼
┌──────────────────────────────────────────────────────────────┐
│                Zellij Session: "infra"                        │
│                                                               │
│  Tab: "amc"              Tab: "gitlore"       Tab: "work"    │
│  ┌──────┬──────┐        ┌──────┐             ┌──────┐       │
│  │claude│claude│        │codex │             │nvim  │       │
│  │ (1)  │ (2)  │        │      │             │      │       │
│  └──────┴──────┘        └──────┘             └──────┘       │
└──────────────────────────────────────────────────────────────┘
```

### Data Flow

1. **Dashboard → Server:** `POST /api/spawn` with project + agent type
2. **Server:** Acquire spawn lock (serializes concurrent requests)
3. **Server:** Validate project path is within `~/projects/` (resolve symlinks)
4. **Server:** Generate unique `spawn_id` (UUID)
5. **Server:** Check Zellij session exists (fail with SESSION_NOT_FOUND if not)
6. **Server → Zellij:** `go-to-tab-name --create <project>` (ensures tab exists)
7. **Server → Zellij:** `new-pane --cwd <path> -- <agent command>` with `AMC_SPAWN_ID` env var
8. **Zellij:** Pane created, agent process starts
9. **Agent → Hook:** `amc-hook` fires on `SessionStart`, writes session JSON including `spawn_id` from env
10. **Server:** Poll for session file containing matching `spawn_id` (up to 10 seconds)
11. **Server → Dashboard:** Return success only after session file with `spawn_id` detected
12. **Server:** Release spawn lock

### API Design

**POST /api/spawn**

Request:
```json
{
  "project": "amc",
  "agent_type": "claude"
}
```

Response (success):
```json
{
  "ok": true,
  "project": "amc",
  "agent_type": "claude"
}
```

Response (error):
```json
{
  "ok": false,
  "error": "Project directory does not exist: /Users/taylor/projects/foo",
  "code": "PROJECT_NOT_FOUND"
}
```

Response (rate limited - AC-35, AC-36):
```json
{
  "ok": false,
  "error": "Rate limited. Try again in 8 seconds.",
  "code": "RATE_LIMITED",
  "retry_after": 8
}
```

**GET /api/projects**

Response:
```json
{
  "projects": ["amc", "gitlore", "mission-control", "work-ghost"]
}
```

**POST /api/projects/refresh**

Response:
```json
{
  "ok": true,
  "projects": ["amc", "gitlore", "mission-control", "work-ghost"]
}
```

### Why This Architecture

1. **Server-side spawning:** Dashboard runs in browser, cannot execute shell commands.

2. **Tab-per-project organization:** Keeps agents for the same project grouped together in Zellij.

3. **`go-to-tab-name --create`:** Idempotent tab creation - creates if missing, switches if exists.

4. **Polling for discovery:** Spawned agents write their own session files via hooks. Dashboard picks them up on next poll.

### Session Discovery Mechanism

**Claude agents** write session files via the `amc-hook` Claude Code hook:
- Hook fires on `SessionStart` event
- Writes JSON to `~/.local/share/amc/sessions/{session_id}.json`
- Contains: session_id, project, status, zellij_session, zellij_pane, etc.
- **Spawn correlation:** If `AMC_SPAWN_ID` env var is set, hook includes it in session JSON

**Codex agents** are discovered via the same hook mechanism as Claude:
- When spawned with `AMC_SPAWN_ID` env var, the hook writes spawn_id to session JSON
- Existing `SessionDiscoveryMixin` also scans `~/.codex/sessions/` as fallback
- **Spawn correlation:** Hook has direct access to env var and writes spawn_id
- Note: Process inspection (`pgrep`, `lsof`) is used for non-spawned agents only

**Prerequisite:** The `amc-hook` must be installed in Claude Code's hooks configuration. See `~/.claude/hooks/` or Claude Code settings.

**Why spawn_id matters:** Without deterministic correlation, polling "any new session file" could return success for unrelated agent activity. The `spawn_id` ensures the server confirms the *specific* agent it spawned is running.

### Integration with Existing Code

| New Code | Integrates With | How |
|----------|-----------------|-----|
| SpawnMixin | HttpMixin | Uses `_send_json()`, `_json_error()` |
| SpawnModal | Modal.js | Follows same patterns (escape, scroll lock, animations) |
| SpawnModal | api.js | Uses `fetchWithTimeout`, API constants |
| Toast calls | Toast.js | Uses existing `showToast(msg, type, duration)` |
| PROJECTS_DIR | context.py | Added alongside other path constants |
| Session polling | SESSIONS_DIR | Watches same directory as discovery mixins |

---

## Implementation Specifications

### IMP-0: Add Constants to context.py

**File:** `amc_server/context.py`

Add after existing path constants:
```python
# Projects directory for spawning agents
PROJECTS_DIR = Path.home() / "projects"

# Default Zellij session for spawning
ZELLIJ_SESSION = "infra"

# Lock for serializing spawn operations (prevents Zellij race conditions)
_spawn_lock = threading.Lock()

# Rate limiting: track last spawn time per project (prevents spam)
_spawn_timestamps: dict[str, float] = {}
SPAWN_COOLDOWN_SEC = 10.0

# Auth token for spawn endpoint (AC-37, AC-38)
# Generated on server start, injected into dashboard HTML
_auth_token: str = ""


def generate_auth_token():
    """Generate a one-time auth token for this server instance."""
    global _auth_token
    import secrets
    _auth_token = secrets.token_urlsafe(32)
    return _auth_token


def validate_auth_token(request_token: str) -> bool:
    """Validate the Authorization header token."""
    return request_token == f"Bearer {_auth_token}"


def start_projects_watcher():
    """Start background thread to refresh projects cache every 5 minutes (AC-40)."""
    import logging
    import threading
    from amc_server.mixins.spawn import load_projects_cache

    def _watch_loop():
        import time
        while True:
            try:
                time.sleep(300)  # 5 minutes
                load_projects_cache()
            except Exception:
                logging.exception("Projects cache refresh failed")

    thread = threading.Thread(target=_watch_loop, daemon=True)
    thread.start()
```

---

### IMP-0b: Auth Token Verification in SpawnMixin (fulfills AC-38)

Add to the beginning of `_handle_spawn()`:
```python
# Verify auth token (AC-38)
auth_header = self.headers.get("Authorization", "")
if not validate_auth_token(auth_header):
    self._send_json(401, {"ok": False, "error": "Unauthorized", "code": "UNAUTHORIZED"})
    return
```

---

### IMP-1: SpawnMixin for Server (fulfills AC-8, AC-9, AC-10, AC-11, AC-12, AC-13, AC-14, AC-18, AC-19, AC-22, AC-23, AC-24, AC-26-AC-30, AC-33-AC-36)

**File:** `amc_server/mixins/spawn.py`

**Integration notes:**
- Uses `_send_json()` from HttpMixin (not a new `_json_response`)
- Uses inline JSON body parsing (same pattern as `control.py:33-47`)
- PROJECTS_DIR, ZELLIJ_SESSION, `_spawn_lock`, `_spawn_timestamps`, `SPAWN_COOLDOWN_SEC` come from context.py
- **Deterministic correlation:** Generates `spawn_id`, passes via env var, polls for matching session file
- **Concurrency safety:** Acquires `_spawn_lock` around Zellij operations to prevent race conditions
- **Rate limiting:** Per-project cooldown prevents spawn spam (AC-35, AC-36)
- **Symlink safety:** Resolves project path and verifies it's still under PROJECTS_DIR
- **TOCTOU mitigation:** Validation returns resolved path; caller uses it directly (no re-resolution)
- **Env var propagation:** Uses shell wrapper to guarantee `AMC_SPAWN_ID` reaches agent process

```python
import json
import os
import subprocess
import time
import uuid

from amc_server.context import (
    PROJECTS_DIR, SESSIONS_DIR, ZELLIJ_BIN, ZELLIJ_SESSION,
    _spawn_lock, _spawn_timestamps, SPAWN_COOLDOWN_SEC,
)

# Agent commands (AC-8, AC-9: full autonomous permissions)
AGENT_COMMANDS = {
    "claude": ["claude", "--dangerously-skip-permissions"],
    "codex": ["codex", "--dangerously-bypass-approvals-and-sandbox"],
}

# Module-level cache for projects list (AC-33)
_projects_cache: list[str] = []


def load_projects_cache():
    """Scan ~/projects/ and cache the list. Called on server start."""
    global _projects_cache
    try:
        projects = []
        for entry in PROJECTS_DIR.iterdir():
            if entry.is_dir() and not entry.name.startswith("."):
                projects.append(entry.name)
        projects.sort()
        _projects_cache = projects
    except OSError:
        _projects_cache = []


class SpawnMixin:
    def _handle_spawn(self):
        """Handle POST /api/spawn"""
        # Read JSON body (same pattern as control.py)
        try:
            content_length = int(self.headers.get("Content-Length", 0))
            body = json.loads(self.rfile.read(content_length))
            if not isinstance(body, dict):
                self._json_error(400, "Invalid JSON body")
                return
        except (json.JSONDecodeError, ValueError):
            self._json_error(400, "Invalid JSON body")
            return

        project = body.get("project", "").strip()
        agent_type = body.get("agent_type", "claude").strip()

        # Validation returns resolved path to avoid TOCTOU
        validation = self._validate_spawn_params(project, agent_type)
        if "error" in validation:
            self._send_json(400, {"ok": False, "error": validation["error"], "code": validation["code"]})
            return

        resolved_path = validation["resolved_path"]

        # Generate spawn_id for deterministic correlation (AC-27)
        spawn_id = str(uuid.uuid4())

        # Acquire lock to serialize Zellij operations (AC-30)
        # NOTE: Rate limiting check is INSIDE lock to prevent race condition where
        # two concurrent requests both pass the cooldown check before either updates timestamp
        # Use timeout to prevent indefinite blocking if lock is held by hung thread
        if not _spawn_lock.acquire(timeout=15.0):
            self._send_json(503, {
                "ok": False,
                "error": "Server busy, try again shortly",
                "code": "SERVER_BUSY"
            })
            return

        acquire_start = time.time()
        try:
            # Log lock contention for debugging
            acquire_time = time.time() - acquire_start
            if acquire_time > 1.0:
                import logging
                logging.warning(f"Spawn lock contention: waited {acquire_time:.1f}s for {project}")
            # Rate limiting per project (AC-35, AC-36) - must be inside lock
            now = time.time()
            last_spawn = _spawn_timestamps.get(project, 0)
            if now - last_spawn < SPAWN_COOLDOWN_SEC:
                retry_after = int(SPAWN_COOLDOWN_SEC - (now - last_spawn)) + 1
                self._send_json(429, {
                    "ok": False,
                    "error": f"Rate limited. Try again in {retry_after} seconds.",
                    "code": "RATE_LIMITED",
                    "retry_after": retry_after,
                })
                return

            result = self._spawn_agent_in_project_tab(project, resolved_path, agent_type, spawn_id)

            # Update timestamp only on successful spawn (don't waste cooldown on failures)
            if result["ok"]:
                _spawn_timestamps[project] = time.time()
        finally:
            _spawn_lock.release()

        if result["ok"]:
            self._send_json(200, {"ok": True, "project": project, "agent_type": agent_type, "spawn_id": spawn_id})
        else:
            self._send_json(500, {"ok": False, "error": result["error"], "code": result.get("code", "SPAWN_FAILED")})

    def _validate_spawn_params(self, project, agent_type):
        """Validate spawn parameters. Returns resolved_path on success, error dict on failure.

        Returns resolved path to avoid TOCTOU: caller uses this path directly
        instead of re-resolving after validation.
        """
        if not project:
            return {"error": "project is required", "code": "MISSING_PROJECT"}

        # Security: no path traversal in project name
        if "/" in project or "\\" in project or ".." in project:
            return {"error": "Invalid project name", "code": "INVALID_PROJECT"}

        # Resolve symlinks and verify still under PROJECTS_DIR (AC-22)
        project_path = PROJECTS_DIR / project
        try:
            resolved = project_path.resolve()
        except OSError:
            return {"error": f"Project not found: {project}", "code": "PROJECT_NOT_FOUND"}

        # Symlink escape check: resolved path must be under PROJECTS_DIR
        try:
            resolved.relative_to(PROJECTS_DIR.resolve())
        except ValueError:
            return {"error": "Invalid project path", "code": "INVALID_PROJECT"}

        if not resolved.is_dir():
            return {"error": f"Project not found: {project}", "code": "PROJECT_NOT_FOUND"}

        # Agent type must be valid
        if agent_type not in AGENT_COMMANDS:
            return {"error": f"Invalid agent type: {agent_type}", "code": "INVALID_AGENT_TYPE"}

        # Return resolved path to avoid TOCTOU
        return {"resolved_path": resolved}

    def _check_zellij_session_exists(self):
        """Check if the target Zellij session exists (AC-26).

        Uses line-by-line parsing rather than substring check to avoid
        false positives from similarly-named sessions (e.g., "infra2" matching "infra").
        """
        try:
            result = subprocess.run(
                [ZELLIJ_BIN, "list-sessions"],
                capture_output=True,
                text=True,
                timeout=5
            )
            # Parse session names line by line to avoid substring false positives
            # Each line is a session name (may have status suffix like " (current)")
            for line in result.stdout.strip().split("\n"):
                session_name = line.split()[0] if line.strip() else ""
                if session_name == ZELLIJ_SESSION:
                    return True
            return False
        except (FileNotFoundError, subprocess.TimeoutExpired):
            return False

    def _wait_for_session_file(self, spawn_id, timeout=10.0):
        """Poll for session file containing our spawn_id (AC-29).

        Deterministic correlation: we look for the specific spawn_id we passed
        to the agent, not just "any new file". This prevents false positives
        from unrelated agent activity.

        Note: We don't filter by mtime because spawn_id is already unique per
        request - no risk of matching stale files. This also avoids edge cases
        where file is written faster than our timestamp capture.

        Args:
            spawn_id: The UUID we passed to the agent via AMC_SPAWN_ID env var
            timeout: Maximum seconds to poll (10s for cold starts, VM latency)
        """
        poll_start = time.time()
        poll_interval = 0.25

        while time.time() - poll_start < timeout:
            if SESSIONS_DIR.exists():
                for f in SESSIONS_DIR.glob("*.json"):
                    try:
                        data = json.loads(f.read_text())
                        if data.get("spawn_id") == spawn_id:
                            return True
                    except (json.JSONDecodeError, OSError):
                        continue
            time.sleep(poll_interval)
        return False

    def _spawn_agent_in_project_tab(self, project, project_path, agent_type, spawn_id):
        """Ensure project tab exists and spawn agent pane.

        Called with _spawn_lock held to serialize Zellij operations.

        Note: project_path is pre-resolved by _validate_spawn_params to avoid TOCTOU.
        """
        try:
            # Step 0: Check session exists (AC-26)
            if not self._check_zellij_session_exists():
                return {
                    "ok": False,
                    "error": f"Zellij session '{ZELLIJ_SESSION}' not found",
                    "code": "SESSION_NOT_FOUND"
                }

            # Step 1: Go to or create the project tab
            tab_result = subprocess.run(
                [ZELLIJ_BIN, "--session", ZELLIJ_SESSION, "action", "go-to-tab-name", "--create", project],
                capture_output=True,
                text=True,
                timeout=5
            )
            if tab_result.returncode != 0:
                return {"ok": False, "error": f"Failed to create/switch tab: {tab_result.stderr}", "code": "TAB_ERROR"}

            # Step 2: Spawn new pane with agent command (AC-14: naming scheme)
            # Pass AMC_SPAWN_ID via subprocess env dict, merged with inherited environment.
            # This ensures the env var propagates through Zellij's subprocess tree to the agent.
            agent_cmd = AGENT_COMMANDS[agent_type]
            pane_name = f"{agent_type}-{project}"

            spawn_cmd = [
                ZELLIJ_BIN, "--session", ZELLIJ_SESSION, "action", "new-pane",
                "--name", pane_name,
                "--cwd", str(project_path),
                "--",
                *agent_cmd
            ]

            # Merge spawn_id into environment so it reaches the agent process
            spawn_env = os.environ.copy()
            spawn_env["AMC_SPAWN_ID"] = spawn_id

            spawn_result = subprocess.run(
                spawn_cmd,
                capture_output=True,
                text=True,
                timeout=10,
                env=spawn_env,
            )
            if spawn_result.returncode != 0:
                return {"ok": False, "error": f"Failed to spawn pane: {spawn_result.stderr}", "code": "SPAWN_ERROR"}

            # Step 3: Wait for session file with matching spawn_id (AC-29)
            # No mtime filter needed - spawn_id is unique per request
            if not self._wait_for_session_file(spawn_id, timeout=10.0):
                return {
                    "ok": False,
                    "error": "Agent spawned but session file not detected within 10 seconds",
                    "code": "SESSION_FILE_TIMEOUT"
                }

            return {"ok": True}

        except FileNotFoundError:
            return {"ok": False, "error": f"zellij not found at {ZELLIJ_BIN}", "code": "ZELLIJ_NOT_FOUND"}
        except subprocess.TimeoutExpired:
            return {"ok": False, "error": "zellij command timed out", "code": "TIMEOUT"}

    def _handle_projects(self):
        """Handle GET /api/projects - return cached projects list (AC-33)."""
        self._send_json(200, {"projects": _projects_cache})

    def _handle_projects_refresh(self):
        """Handle POST /api/projects/refresh - refresh cache (AC-34)."""
        load_projects_cache()
        self._send_json(200, {"ok": True, "projects": _projects_cache})

    def _handle_health(self):
        """Handle GET /api/health - return server status (AC-41)."""
        zellij_available = self._check_zellij_session_exists()
        self._send_json(200, {
            "ok": True,
            "zellij_session": ZELLIJ_SESSION,
            "zellij_available": zellij_available,
            "projects_count": len(_projects_cache),
        })
```

### IMP-1b: Update amc-hook for spawn_id (fulfills AC-28)

**File:** `bin/amc-hook`

**Integration notes:**
- Check for `AMC_SPAWN_ID` environment variable
- If present, include it in the session JSON written to disk
- This enables deterministic correlation between spawn request and session discovery

Add after reading hook JSON and before writing session file:
```python
# Include spawn_id if present in environment (for spawn correlation)
spawn_id = os.environ.get("AMC_SPAWN_ID")
if spawn_id:
    session_data["spawn_id"] = spawn_id
```

---

### IMP-2: HTTP Routing (fulfills AC-1, AC-3, AC-4, AC-34)

**File:** `amc_server/mixins/http.py`

Add to `do_GET`:
```python
elif self.path == "/api/projects":
    self._handle_projects()
elif self.path == "/api/health":
    self._handle_health()
```

Add to `do_POST`:
```python
elif self.path == "/api/spawn":
    self._handle_spawn()
elif self.path == "/api/projects/refresh":
    self._handle_projects_refresh()
```

Update `do_OPTIONS` for CORS preflight on new endpoints (AC-39: consistent CORS):
```python
def do_OPTIONS(self):
    # CORS preflight for API endpoints
    # AC-39: Keep wildcard CORS consistent with existing endpoints;
    # localhost-only binding (AC-24) is the real security boundary
    self.send_response(204)
    self.send_header("Access-Control-Allow-Origin", "*")
    self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
    self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
    self.end_headers()
```

### IMP-2b: Server Startup (fulfills AC-33, AC-37)

**File:** `amc_server/server.py`

Add to server initialization:
```python
from amc_server.mixins.spawn import load_projects_cache
from amc_server.context import generate_auth_token, start_projects_watcher

# In server startup, before starting HTTP server:
load_projects_cache()
auth_token = generate_auth_token()  # AC-37: Generate one-time token
start_projects_watcher()  # AC-40: Auto-refresh every 5 minutes
# Token is injected into dashboard HTML via template variable
```

### IMP-2d: Inject Auth Token into Dashboard (fulfills AC-37)

**File:** `amc_server/mixins/http.py` (in dashboard HTML serving)

Inject the auth token into the dashboard HTML so JavaScript can use it:
```python
# In the HTML template that serves the dashboard:
html_content = html_content.replace(
    "<!-- AMC_AUTH_TOKEN -->",
    f'<script>window.AMC_AUTH_TOKEN = "{_auth_token}";</script>'
)
```

**File:** `dashboard/index.html`

Add placeholder in `<head>`:
```html
<!-- AMC_AUTH_TOKEN -->
```

### IMP-2c: API Constants (follows existing pattern)

**File:** `dashboard/utils/api.js`

Add to existing exports:
```javascript
// Spawn API endpoints
export const API_SPAWN = '/api/spawn';
export const API_PROJECTS = '/api/projects';
export const API_PROJECTS_REFRESH = '/api/projects/refresh';
```

### IMP-3: Handler Integration (fulfills AC-2, AC-3)

**File:** `amc_server/handler.py`

Add SpawnMixin to handler inheritance chain:
```python
from amc_server.mixins.spawn import SpawnMixin

class AMCHandler(
    HttpMixin,
    StateMixin,
    ConversationMixin,
    SessionControlMixin,
    SessionDiscoveryMixin,
    SessionParsingMixin,
    SpawnMixin,  # Add this
    BaseHTTPRequestHandler,
):
    """HTTP handler composed from focused mixins."""
```

### IMP-4: SpawnModal Component (fulfills AC-2, AC-3, AC-6, AC-7, AC-20, AC-25, AC-31, AC-32)

**File:** `dashboard/components/SpawnModal.js`

**Integration notes:**
- Uses `fetchWithTimeout` and API constants from `api.js` (consistent with codebase)
- Follows `Modal.js` patterns: escape key, click-outside, body scroll lock, animated close
- Uses `html` tagged template (Preact pattern used throughout dashboard)

```javascript
import { html, useState, useEffect, useCallback } from '../lib/preact.js';
import { API_PROJECTS, API_SPAWN, fetchWithTimeout } from '../utils/api.js';

export function SpawnModal({ isOpen, onClose, onSpawn, currentProject }) {
  const [projects, setProjects] = useState([]);
  const [selectedProject, setSelectedProject] = useState('');
  const [agentType, setAgentType] = useState('claude');
  const [loading, setLoading] = useState(false);
  const [loadingProjects, setLoadingProjects] = useState(false);
  const [closing, setClosing] = useState(false);
  const [error, setError] = useState(null);

  const needsProjectPicker = !currentProject;

  // Animated close handler (matches Modal.js pattern)
  const handleClose = useCallback(() => {
    if (loading) return;
    setClosing(true);
    setTimeout(() => {
      setClosing(false);
      onClose();
    }, 200);
  }, [loading, onClose]);

  // Body scroll lock (matches Modal.js pattern)
  useEffect(() => {
    if (!isOpen) return;
    document.body.style.overflow = 'hidden';
    return () => { document.body.style.overflow = ''; };
  }, [isOpen]);

  // Escape key to close (matches Modal.js pattern)
  useEffect(() => {
    if (!isOpen) return;
    const handleKeyDown = (e) => {
      if (e.key === 'Escape') handleClose();
    };
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, handleClose]);

  // Fetch projects when modal opens
  useEffect(() => {
    if (isOpen && needsProjectPicker) {
      setLoadingProjects(true);
      fetchWithTimeout(API_PROJECTS)
        .then(r => r.json())
        .then(data => {
          setProjects(data.projects || []);
          setSelectedProject('');
        })
        .catch(err => setError(err.message))
        .finally(() => setLoadingProjects(false));
    }
  }, [isOpen, needsProjectPicker]);

  // Reset state when modal opens
  useEffect(() => {
    if (isOpen) {
      setAgentType('claude');
      setError(null);
      setLoading(false);
      setClosing(false);
    }
  }, [isOpen]);

  const handleSpawn = async () => {
    const project = currentProject || selectedProject;
    if (!project) {
      setError('Please select a project');
      return;
    }

    setLoading(true);
    setError(null);

    try {
      const response = await fetchWithTimeout(API_SPAWN, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${window.AMC_AUTH_TOKEN}`,  // AC-38
        },
        body: JSON.stringify({ project, agent_type: agentType })
      });
      const data = await response.json();

      if (data.ok) {
        onSpawn({ success: true, project, agentType });
        handleClose();
      } else {
        setError(data.error || 'Spawn failed');
        onSpawn({ error: data.error });
      }
    } catch (err) {
      const msg = err.name === 'AbortError' ? 'Request timed out' : err.message;
      setError(msg);
      onSpawn({ error: msg });
    } finally {
      setLoading(false);
    }
  };

  if (!isOpen) return null;

  return html`
    <div
      class="fixed inset-0 z-50 flex items-center justify-center bg-[#02050d]/84 p-4 backdrop-blur-sm ${closing ? 'modal-backdrop-out' : 'modal-backdrop-in'}"
      onClick=${(e) => e.target === e.currentTarget && handleClose()}
    >
      <div
        class="glass-panel w-full max-w-md rounded-2xl p-6 ${closing ? 'modal-panel-out' : 'modal-panel-in'}"
        onClick=${(e) => e.stopPropagation()}
      >
        <h2 class="mb-4 font-display text-lg font-semibold text-bright">New Agent</h2>

        ${needsProjectPicker && html`
          <label class="mb-4 block">
            <span class="mb-1 block text-sm text-dim">Project</span>
            <select
              class="w-full rounded-lg border border-selection/50 bg-surface px-3 py-2 text-bright"
              value=${selectedProject}
              onChange=${(e) => setSelectedProject(e.target.value)}
              disabled=${loadingProjects}
            >
              <option value="">
                ${loadingProjects ? 'Loading...' : 'Select a project...'}
              </option>
              ${projects.map(p => html`
                <option key=${p} value=${p}>${p}</option>
              `)}
            </select>
          </label>
        `}

        ${!needsProjectPicker && html`
          <p class="mb-4 text-sm text-dim">
            Project: <span class="font-medium text-bright">${currentProject}</span>
          </p>
        `}

        <label class="mb-4 block">
          <span class="mb-2 block text-sm text-dim">Agent Type</span>
          <div class="flex gap-2">
            <button
              type="button"
              class="flex-1 rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
                agentType === 'claude'
                  ? 'border-active bg-active/20 text-active'
                  : 'border-selection/50 text-dim hover:border-selection hover:text-bright'
              }"
              onClick=${() => setAgentType('claude')}
            >
              Claude
            </button>
            <button
              type="button"
              class="flex-1 rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
                agentType === 'codex'
                  ? 'border-active bg-active/20 text-active'
                  : 'border-selection/50 text-dim hover:border-selection hover:text-bright'
              }"
              onClick=${() => setAgentType('codex')}
            >
              Codex
            </button>
          </div>
        </label>

        ${error && html`
          <p class="mb-4 rounded-lg border border-attention/50 bg-attention/10 px-3 py-2 text-sm text-attention">
            ${error}
          </p>
        `}

        <div class="flex justify-end gap-2">
          <button
            type="button"
            class="rounded-lg border border-selection/50 px-4 py-2 text-sm text-dim transition-colors hover:border-selection hover:text-bright"
            onClick=${handleClose}
            disabled=${loading}
          >
            Cancel
          </button>
          <button
            type="button"
            class="rounded-lg bg-active px-4 py-2 text-sm font-medium text-surface transition-opacity disabled:opacity-50"
            onClick=${handleSpawn}
            disabled=${loading || (needsProjectPicker && !selectedProject)}
          >
            ${loading ? 'Spawning...' : 'Spawn'}
          </button>
        </div>
      </div>
    </div>
  `;
}
```

### IMP-5: Header New Agent Button (fulfills AC-1)

**File:** `dashboard/components/App.js`

**Integration points:**
1. Add import for SpawnModal
2. Add state for modal visibility
3. Add button to existing inline header (lines 331-380)
4. Add SpawnModal component at end of render

**Project identity note:** `selectedProject` in App.js is already the short project name (e.g., "amc"), not the full path. This comes from `groupSessionsByProject()` in `status.js` which uses `projectName` as the key. The modal can pass it directly to `/api/spawn`.

```javascript
// Add import at top
import { SpawnModal } from './SpawnModal.js';

// Add state in App component (around line 14)
const [spawnModalOpen, setSpawnModalOpen] = useState(false);

// Add button to existing header section (inside the flex container, around line 341)
// After the status summary chips div, add:
<button
  class="rounded-lg border border-active/40 bg-active/12 px-3 py-2 text-sm font-medium text-active transition-colors hover:bg-active/20"
  onClick=${() => setSpawnModalOpen(true)}
>
  + New Agent
</button>

// Add modal before closing fragment (after ToastContainer, around line 426)
// selectedProject is already the short name (e.g., "amc"), not a path
<${SpawnModal}
  isOpen=${spawnModalOpen}
  onClose=${() => setSpawnModalOpen(false)}
  onSpawn=${handleSpawnResult}
  currentProject=${selectedProject}
/>
```

### IMP-6: Toast Notifications for Spawn Results (fulfills AC-20, AC-21)

**File:** `dashboard/components/App.js`

**Integration note:** Uses existing `showToast(message, type, duration)` signature from `Toast.js`.

```javascript
import { showToast } from './Toast.js';  // Already imported in App.js

// Add this callback in App component
const handleSpawnResult = useCallback((result) => {
  if (result.success) {
    // showToast(message, type, duration) - matches Toast.js signature
    showToast(`${result.agentType} agent spawned for ${result.project}`, 'success');
  } else if (result.error) {
    showToast(result.error, 'error');
  }
}, []);
```

---

## Rollout Slices

### Slice 1: Server-Side Spawning (Backend Only)

**Goal:** Spawn agents via curl/API without UI.

**Tasks:**
1. Create `SpawnMixin` with `_handle_spawn()`, `_validate_spawn_params()`, `_spawn_agent_in_project_tab()`
2. Create `_handle_projects()` for listing `~/projects/` subdirectories
3. Add `/api/spawn` (POST) and `/api/projects` (GET) routes to HTTP handler
4. Add SpawnMixin to handler inheritance chain
5. Write tests for spawn validation and subprocess calls

**Verification:**
```bash
# List projects
curl http://localhost:7400/api/projects

# Spawn claude agent
curl -X POST http://localhost:7400/api/spawn \
  -H "Content-Type: application/json" \
  -d '{"project":"amc","agent_type":"claude"}'

# Spawn codex agent
curl -X POST http://localhost:7400/api/spawn \
  -H "Content-Type: application/json" \
  -d '{"project":"gitlore","agent_type":"codex"}'
```

**ACs covered:** AC-4, AC-5, AC-8, AC-9, AC-10, AC-11, AC-12, AC-13, AC-14, AC-18, AC-19, AC-22, AC-23, AC-24, AC-26, AC-27, AC-28, AC-29, AC-30, AC-33, AC-34, AC-35, AC-36, AC-40, AC-41

### Slice 2: Spawn Modal UI

**Goal:** Complete UI for spawning agents.

**Tasks:**
1. Create `SpawnModal` component with context-aware behavior
2. Add "+ New Agent" button to page header
3. Pass `currentProject` from sidebar selection to modal (extract basename if needed)
4. Implement agent type toggle (Claude / Codex)
5. Wire up project dropdown (only shown on "All Projects")
6. Add loading and error states
7. Show toast on spawn result
8. Implement modal dismiss behavior (Escape, click-outside, Cancel)

**ACs covered:** AC-1, AC-2, AC-3, AC-6, AC-7, AC-20, AC-21, AC-25, AC-31, AC-32, AC-42

### Slice 3: Polish & Edge Cases

**Goal:** Handle edge cases and improve UX.

**Tasks:**
1. Handle case where `~/projects/` doesn't exist or is empty
2. Add visual feedback when agent appears in dashboard after spawn
3. Test with projects that have special characters in name
4. Ensure spawned agents' hooks write correct metadata

**ACs covered:** AC-15, AC-16, AC-17

---

## Open Questions

1. ~~**Rate limiting:** Should we limit spawn frequency to prevent accidental spam?~~ **RESOLVED:** Added per-project 10-second cooldown (AC-35, AC-36)

2. **Session cleanup:** When a spawned agent exits, should dashboard offer to close the pane?

3. **Multiple Zellij sessions:** Currently hardcoded to "infra". Future: detect or let user pick?

4. **Agent naming:** Current scheme is `{agent_type}-{project}`. Collision if multiple agents for same project? (Zellij allows duplicate pane names; could add timestamp suffix)

5. **Spawn limits:** Should we add spawn limits or warnings for resource management? (Rate limiting helps but doesn't cap total)

6. **Dead code cleanup:** `Header.js` exists but isn't used (App.js has inline header). Remove it?

7. **Hook verification:** Should spawn endpoint verify `amc-hook` is installed before spawning Claude agents? (Could add `/api/hook-status` endpoint)

8. **Async spawn confirmation:** Current design returns error if session file not detected in 5s even though pane exists. Future: return spawn_id immediately, let dashboard poll for confirmation? (Suggested by GPT 5.3 review but adds complexity)

9. **Tab focus disruption:** `go-to-tab-name --create` changes active tab globally in "infra" session. Explore `--skip-focus` or similar if available in Zellij CLI?

---

## Design Decisions (from review)

These issues were identified during external review and addressed in the plan:

| Issue | Resolution |
|-------|------------|
| `st_mtime` vs `time.monotonic()` bug | Fixed: Use `time.time()` for wall-clock comparison with file mtime |
| "Any new file" polling could return false success | Fixed: Deterministic `spawn_id` correlation via env var |
| Concurrent spawns race on Zellij tab focus | Fixed: `_spawn_lock` serializes all Zellij operations |
| Symlink escape from `~/projects/` | Fixed: `Path.resolve()` + `relative_to()` check |
| CORS `*` with dangerous agent flags | Accepted: localhost-only binding (AC-24) is sufficient for dev-machine use |
| Project identity mismatch (full path vs basename) | Documented: `selectedProject` from sidebar is already the short name; verify in implementation |

### Additional Issues (from GPT 5.3 second opinion)

| Issue | Resolution |
|-------|------------|
| `AMC_SPAWN_ID` propagation not guaranteed | Fixed: Use shell wrapper (`sh -c "export AMC_SPAWN_ID=...; exec ..."`) to guarantee env var reaches agent process |
| `go-to-tab-name --create` changes active tab globally | Accepted: Dev-machine tool; focus disruption is minor annoyance, not critical. Could explore `--skip-focus` flag in future |
| Process-local lock insufficient for multi-worker | Accepted: AMC is single-process by design; documented as design constraint |
| `SESSION_FILE_TIMEOUT` creates false-failure path | Documented: Pane exists but API returns error; future work could add idempotent retry with spawn_id deduplication |
| Authz/abuse controls missing on `/api/spawn` | Fixed: Added per-project rate limiting (AC-35, AC-36); localhost-only binding provides baseline security |
| TOCTOU: path validated then re-resolved | Fixed: `_validate_spawn_params` returns resolved path; caller uses it directly |
| Hardcoded "infra" session is SPOF | Documented: Single-session design is intentional for v1; multi-session support noted in Open Questions |
| 5s polling timeout brittle under cold starts | Accepted: 5s is generous for typical agent startup; SESSION_FILE_TIMEOUT error is actionable |
| Hook missing/broken causes confirmation failure | Documented: Prerequisite section notes hook must be installed; future work could add hook verification endpoint |
| Pane name collisions reduce debuggability | Accepted: Zellij allows duplicate names; dashboard shows full session context. Could add timestamp suffix in future |

### Issues from GPT 5.3 Third Review (Codex)

| Issue | Resolution |
|-------|------------|
| Rate-limit check outside `_spawn_lock` causes race | Fixed: Moved rate-limit check inside lock to prevent two requests bypassing cooldown simultaneously |
| `start = time.time()` after spawn causes false timeout | Fixed: Capture `spawn_start_time` BEFORE spawn command; pass to `_wait_for_session_file()` |
| CORS `*` + localhost insufficient for security | Fixed: Added AC-37, AC-38, AC-39 for auth token + strict CORS |
| `ZELLIJ_SESSION in stdout` substring check | Fixed: Parse session names line-by-line to avoid false positives (e.g., "infra2" matching "infra") |
| `go-to-tab-name` then `new-pane` not atomic | Accepted: Zellij CLI doesn't support atomic tab+pane creation; race window is small in practice |

### Issues from GPT 5.3 Fourth Review (Codex)

| Issue | Resolution |
|-------|------------|
| Timestamp captured BEFORE spawn creates mtime ambiguity | Fixed: Capture `spawn_complete_time` AFTER subprocess returns; spawn_id correlation handles fast writes |
| 5s polling timeout brittle for cold starts/VMs | Fixed: Increased timeout to 10s (AC-29 updated) |
| CORS inconsistency (wildcard removed only on spawn) | Fixed: Keep wildcard CORS consistent; localhost binding is security boundary (AC-39 updated) |
| Projects cache goes stale between server restarts | Fixed: Added AC-40 for 5-minute background refresh |
| Lock contention could silently delay requests | Fixed: Added contention logging when wait exceeds 1s |
| Auth token via inline script is fragile | Accepted: Works for localhost dev tool; secure cookie alternative documented as future option |

### Issues from GPT 5.3 Fifth Review (Codex)

| Issue | Resolution |
|-------|------------|
| Shell wrapper env var propagation is fragile | Fixed: Use `subprocess.run(..., env=spawn_env)` to pass env dict directly |
| Mtime check creates race if file written during spawn | Fixed: Removed mtime filter entirely; spawn_id is already deterministic |
| Rate-limit timestamp updated before spawn wastes cooldown | Fixed: Update timestamp only after successful spawn |
| Background thread can silently die on exception | Fixed: Added try-except with logging in watch loop |
| Lock acquisition can block indefinitely | Fixed: Added 15s timeout, return SERVER_BUSY on timeout |
| No way to check Zellij session status before spawning | Fixed: Added AC-41/AC-42 for health endpoint and dashboard warning |
| Codex discovery via process inspection unreliable with shell wrapper | Fixed: Clarified Codex uses hook-based discovery (same as Claude) |
