Files
amc/plans/agent-spawning.md
teernisse 2926645b10 docs: add implementation plans for upcoming features
Planning documents for future AMC features:

PLAN-slash-autocomplete.md:
- Slash-command autocomplete for SimpleInput
- Skills API endpoint, SlashMenu dropdown, keyboard navigation
- 8 implementation steps with file locations and dependencies

plans/agent-spawning.md:
- Agent spawning acceptance criteria documentation
- Spawn command integration, status tracking, error handling
- Written as testable acceptance criteria (AC-1 through AC-10)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-26 15:25:09 -05:00

34 KiB

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 5 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

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 5 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

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 5 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:

{
  "project": "amc",
  "agent_type": "claude"
}

Response (success):

{
  "ok": true,
  "project": "amc",
  "agent_type": "claude"
}

Response (error):

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

GET /api/projects

Response:

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

POST /api/projects/refresh

Response:

{
  "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 dynamically by SessionDiscoveryMixin:

  • Scans ~/.codex/sessions/ for recently-modified .jsonl files
  • Extracts Zellij pane info via process inspection (pgrep, lsof)
  • Creates/updates session JSON in ~/.local/share/amc/sessions/
  • Spawn correlation: Codex discovery checks for AMC_SPAWN_ID in process environment

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:

# 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()

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-25, AC-26, AC-29, AC-30)

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 and ZELLIJ_SESSION come from context.py (centralized constants)
  • Session file polling watches SESSIONS_DIR for any new .json by mtime
import json
import subprocess
import time

from amc_server.context import PROJECTS_DIR, SESSIONS_DIR, ZELLIJ_BIN, ZELLIJ_SESSION

# 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-29)
_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
        error = self._validate_spawn_params(project, agent_type)
        if error:
            self._send_json(400, {"ok": False, "error": error["message"], "code": error["code"]})
            return

        project_path = PROJECTS_DIR / project

        # Ensure tab exists, then spawn pane, then wait for session file
        result = self._spawn_agent_in_project_tab(project, project_path, agent_type)

        if result["ok"]:
            self._send_json(200, {"ok": True, "project": project, "agent_type": agent_type})
        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 error dict or None."""
        if not project:
            return {"message": "project is required", "code": "MISSING_PROJECT"}

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

        # Project must exist
        project_path = PROJECTS_DIR / project
        if not project_path.is_dir():
            return {"message": f"Project not found: {project}", "code": "PROJECT_NOT_FOUND"}

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

        return None

    def _check_zellij_session_exists(self):
        """Check if the target Zellij session exists (AC-25)."""
        try:
            result = subprocess.run(
                [ZELLIJ_BIN, "list-sessions"],
                capture_output=True,
                text=True,
                timeout=5
            )
            return ZELLIJ_SESSION in result.stdout
        except (FileNotFoundError, subprocess.TimeoutExpired):
            return False

    def _wait_for_session_file(self, timeout=5.0):
        """Poll for any new session file in SESSIONS_DIR (AC-26).

        Session files are named {session_id}.json. We don't know the session_id
        in advance, so we watch for any .json file with mtime after spawn started.
        """
        start = time.monotonic()
        # Snapshot existing files to detect new ones
        existing_files = set()
        if SESSIONS_DIR.exists():
            existing_files = {f.name for f in SESSIONS_DIR.glob("*.json")}

        while time.monotonic() - start < timeout:
            if SESSIONS_DIR.exists():
                for f in SESSIONS_DIR.glob("*.json"):
                    # New file that didn't exist before spawn
                    if f.name not in existing_files:
                        return True
                    # Or existing file with very recent mtime (reused session)
                    if f.stat().st_mtime > start:
                        return True
            time.sleep(0.25)
        return False

    def _spawn_agent_in_project_tab(self, project, project_path, agent_type):
        """Ensure project tab exists and spawn agent pane."""
        try:
            # Step 0: Check session exists (AC-25)
            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)
            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
            ]

            spawn_result = subprocess.run(
                spawn_cmd,
                capture_output=True,
                text=True,
                timeout=10
            )
            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 (AC-26)
            if not self._wait_for_session_file(timeout=5.0):
                return {
                    "ok": False,
                    "error": "Agent spawned but session file not detected within 5 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-29)."""
        self._send_json(200, {"projects": _projects_cache})

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

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

File: amc_server/mixins/http.py

Add to do_GET:

elif self.path == "/api/projects":
    self._handle_projects()

Add to do_POST:

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:

def do_OPTIONS(self):
    # CORS preflight for API endpoints
    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")
    self.end_headers()

IMP-2b: Server Startup (fulfills AC-29)

File: amc_server/server.py

Add to server initialization:

from amc_server.mixins.spawn import load_projects_cache

# In server startup, before starting HTTP server:
load_projects_cache()

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

File: dashboard/utils/api.js

Add to existing exports:

// 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:

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-24, AC-27, AC-28)

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)
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' },
        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
// 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)
<${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.

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:

# 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-25, AC-26, AC-29, AC-30

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
  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-24, AC-27, AC-28

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?

  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?

  5. Spawn limits: Should we add spawn limits or warnings for resource management?

  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?