# 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 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 ` (ensures tab exists) 7. **Server → Zellij:** `new-pane --cwd -- ` 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: ```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" } ``` **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 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: ```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() ``` --- ### 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 ```python 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`: ```python elif self.path == "/api/projects": self._handle_projects() ``` 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: ```python 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: ```python 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: ```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-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) ```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' }, 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`
e.target === e.currentTarget && handleClose()} >
e.stopPropagation()} >

New Agent

${needsProjectPicker && html` `} ${!needsProjectPicker && html`

Project: ${currentProject}

`} ${error && html`

${error}

`}
`; } ``` ### 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 ```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: // 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`. ```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-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?