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>
This commit is contained in:
910
plans/agent-spawning.md
Normal file
910
plans/agent-spawning.md
Normal file
@@ -0,0 +1,910 @@
|
||||
# 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:
|
||||
```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`
|
||||
<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
|
||||
|
||||
```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)
|
||||
<${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?
|
||||
Reference in New Issue
Block a user