Closes bd-3ny. Added mousedown listener that dismisses the dropdown when clicking outside both the dropdown and textarea. Uses early return to avoid registering listeners when dropdown is already closed.
49 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:
- User is on "amc" project tab in dashboard sidebar
- User clicks "+ New Agent" button in page header
- Modal appears with agent type selector: Claude / Codex
- User selects "Claude", clicks "Spawn"
- Server finds or creates Zellij tab named "amc"
- New pane spawns in that tab with
claude --dangerously-skip-permissions - 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:
- User is on "All Projects" tab (no specific project selected)
- User clicks "+ New Agent" button in page header
- Modal appears with:
- Project dropdown (lists subdirectories of
~/projects/) - Agent type selector: Claude / Codex
- Project dropdown (lists subdirectories of
- User selects "mission-control" project, "Codex" agent type, clicks "Spawn"
- Server finds or creates Zellij tab named "mission-control"
- New pane spawns with
codex --dangerously-bypass-approvals-and-sandbox - Dashboard updates: new session card appears
Key behavior: User must select a project from the dropdown when on "All Projects".
Acceptance Criteria
Spawn Button Location & Context
- AC-1: "New Agent" button is located in the page header, not on session cards
- AC-2: When on a specific project tab, the spawn modal does not show a project picker
- AC-3: When on "All Projects" tab, the spawn modal shows a project dropdown
Project Selection (All Projects Tab)
- AC-4: The project dropdown lists all immediate subdirectories of
~/projects/ - AC-5: Hidden directories (starting with
.) are excluded from the dropdown - AC-6: User must select a project before spawning (no default selection)
Agent Type Selection
- AC-7: User can choose between Claude and Codex agent types
- AC-8: Claude agents spawn with full autonomous permissions enabled
- AC-9: Codex agents spawn with full autonomous permissions enabled
Zellij Tab Targeting
- AC-10: Agents spawn in a Zellij tab named after the project (e.g., "amc" tab for amc project)
- AC-11: If the project-named tab does not exist, it is created before spawning
- AC-12: All spawns target the "infra" Zellij session
Pane Spawning
- AC-13: The spawned pane's cwd is set to the project directory
- AC-14: Spawned panes are named
{agent_type}-{project}(e.g., "claude-amc") - AC-15: The spawned agent appears in the dashboard within 10 seconds of spawn
Session Discovery
- AC-16: Spawned agent's session data includes correct
zellij_sessionandzellij_pane - AC-17: Dashboard can send responses to spawned agents (existing functionality works)
Error Handling
- AC-18: Spawning fails gracefully if Zellij binary is not found
- AC-19: Spawning fails gracefully if target project directory does not exist
- AC-20: Spawn errors display a toast notification showing the server's error message
- AC-21: Network errors between dashboard and server show retry option
Security
- AC-22: Server validates project path is within
~/projects/(resolves symlinks) - AC-23: Server rejects path traversal attempts in project parameter
- AC-24: Server binds to localhost only (127.0.0.1), not exposed to network
- AC-37: Server generates a one-time auth token on startup and injects it into dashboard HTML
- AC-38:
/api/spawnrequires valid auth token inAuthorizationheader - AC-39: CORS headers are consistent across all endpoints (
Access-Control-Allow-Origin: *); localhost-only binding (AC-24) is the security boundary
Spawn Request Lifecycle
- AC-25: The Spawn button is disabled while a spawn request is in progress
- AC-26: If the target Zellij session does not exist, spawn fails with error "Zellij session 'infra' not found"
- AC-27: Server generates a unique
spawn_idand passes it to the agent viaAMC_SPAWN_IDenv var - AC-28:
amc-hookwritesspawn_idto session file when present in environment - AC-29: Spawn request polls for session file containing the specific
spawn_id(max 10 second wait) - AC-30: Concurrent spawn requests are serialized via a lock to prevent Zellij race conditions
Modal Behavior
- AC-31: Spawn modal can be dismissed by clicking outside, pressing Escape, or clicking Cancel
- AC-32: While fetching the projects list, the project dropdown displays "Loading..." and is disabled
Projects List Caching
- AC-33: Projects list is loaded on server start and cached in memory
- AC-34: Projects list can be refreshed via
POST /api/projects/refresh - AC-40: Projects list auto-refreshes every 5 minutes in background thread
Rate Limiting
- AC-35: Spawn requests for the same project are throttled to 1 per 10 seconds
- AC-36: Rate limit errors return
RATE_LIMITEDcode with retry-after hint
Health Check
- AC-41:
GET /api/healthreturns server status including Zellij session availability - AC-42: Dashboard shows warning banner when Zellij session is unavailable
Architecture
Component Overview
┌─────────────────────────────────────────────────────────────┐
│ Dashboard (Preact) │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Header [+ New Agent] │ │
│ └──────────────────────────────────────────────────────┘ │
│ ┌────────────┐ ┌──────────────────────────────────────┐ │
│ │ Sidebar │ │ Main Content │ │
│ │ │ │ │ │
│ │ All Proj. │ │ ┌─────────┐ ┌─────────┐ │ │
│ │ > amc │ │ │ Session │ │ Session │ │ │
│ │ > gitlore │ │ │ Card │ │ Card │ │ │
│ │ │ │ └─────────┘ └─────────┘ │ │
│ └────────────┘ └──────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ SpawnModal (context-aware) │ │
│ │ - If on project tab: just agent type picker │ │
│ │ - If on All Projects: project dropdown + agent type │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
│
POST /api/spawn
│
▼
┌──────────────────────────────────────────────────────────────┐
│ AMC Server (Python) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ SpawnMixin │ │
│ │ - list_projects() → ~/projects/* dirs │ │
│ │ - validate_spawn() → security checks │ │
│ │ - ensure_tab_exists() → create tab if needed │ │
│ │ - spawn_agent_pane() → zellij action new-pane │ │
│ └─────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
│
subprocess.run(["zellij", ...])
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Zellij Session: "infra" │
│ │
│ Tab: "amc" Tab: "gitlore" Tab: "work" │
│ ┌──────┬──────┐ ┌──────┐ ┌──────┐ │
│ │claude│claude│ │codex │ │nvim │ │
│ │ (1) │ (2) │ │ │ │ │ │
│ └──────┴──────┘ └──────┘ └──────┘ │
└──────────────────────────────────────────────────────────────┘
Data Flow
- Dashboard → Server:
POST /api/spawnwith project + agent type - Server: Acquire spawn lock (serializes concurrent requests)
- Server: Validate project path is within
~/projects/(resolve symlinks) - Server: Generate unique
spawn_id(UUID) - Server: Check Zellij session exists (fail with SESSION_NOT_FOUND if not)
- Server → Zellij:
go-to-tab-name --create <project>(ensures tab exists) - Server → Zellij:
new-pane --cwd <path> -- <agent command>withAMC_SPAWN_IDenv var - Zellij: Pane created, agent process starts
- Agent → Hook:
amc-hookfires onSessionStart, writes session JSON includingspawn_idfrom env - Server: Poll for session file containing matching
spawn_id(up to 10 seconds) - Server → Dashboard: Return success only after session file with
spawn_iddetected - 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"
}
Response (rate limited - AC-35, AC-36):
{
"ok": false,
"error": "Rate limited. Try again in 8 seconds.",
"code": "RATE_LIMITED",
"retry_after": 8
}
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
-
Server-side spawning: Dashboard runs in browser, cannot execute shell commands.
-
Tab-per-project organization: Keeps agents for the same project grouped together in Zellij.
-
go-to-tab-name --create: Idempotent tab creation - creates if missing, switches if exists. -
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
SessionStartevent - 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_IDenv var is set, hook includes it in session JSON
Codex agents are discovered via the same hook mechanism as Claude:
- When spawned with
AMC_SPAWN_IDenv var, the hook writes spawn_id to session JSON - Existing
SessionDiscoveryMixinalso scans~/.codex/sessions/as fallback - Spawn correlation: Hook has direct access to env var and writes spawn_id
- Note: Process inspection (
pgrep,lsof) is used for non-spawned agents only
Prerequisite: The amc-hook must be installed in Claude Code's hooks configuration. See ~/.claude/hooks/ or Claude Code settings.
Why spawn_id matters: Without deterministic correlation, polling "any new session file" could return success for unrelated agent activity. The spawn_id ensures the server confirms the specific agent it spawned is running.
Integration with Existing Code
| New Code | Integrates With | How |
|---|---|---|
| SpawnMixin | HttpMixin | Uses _send_json(), _json_error() |
| SpawnModal | Modal.js | Follows same patterns (escape, scroll lock, animations) |
| SpawnModal | api.js | Uses fetchWithTimeout, API constants |
| Toast calls | Toast.js | Uses existing showToast(msg, type, duration) |
| PROJECTS_DIR | context.py | Added alongside other path constants |
| Session polling | SESSIONS_DIR | Watches same directory as discovery mixins |
Implementation Specifications
IMP-0: Add Constants to context.py
File: amc_server/context.py
Add after existing path constants:
# Projects directory for spawning agents
PROJECTS_DIR = Path.home() / "projects"
# Default Zellij session for spawning
ZELLIJ_SESSION = "infra"
# Lock for serializing spawn operations (prevents Zellij race conditions)
_spawn_lock = threading.Lock()
# Rate limiting: track last spawn time per project (prevents spam)
_spawn_timestamps: dict[str, float] = {}
SPAWN_COOLDOWN_SEC = 10.0
# Auth token for spawn endpoint (AC-37, AC-38)
# Generated on server start, injected into dashboard HTML
_auth_token: str = ""
def generate_auth_token():
"""Generate a one-time auth token for this server instance."""
global _auth_token
import secrets
_auth_token = secrets.token_urlsafe(32)
return _auth_token
def validate_auth_token(request_token: str) -> bool:
"""Validate the Authorization header token."""
return request_token == f"Bearer {_auth_token}"
def start_projects_watcher():
"""Start background thread to refresh projects cache every 5 minutes (AC-40)."""
import logging
import threading
from amc_server.mixins.spawn import load_projects_cache
def _watch_loop():
import time
while True:
try:
time.sleep(300) # 5 minutes
load_projects_cache()
except Exception:
logging.exception("Projects cache refresh failed")
thread = threading.Thread(target=_watch_loop, daemon=True)
thread.start()
IMP-0b: Auth Token Verification in SpawnMixin (fulfills AC-38)
Add to the beginning of _handle_spawn():
# Verify auth token (AC-38)
auth_header = self.headers.get("Authorization", "")
if not validate_auth_token(auth_header):
self._send_json(401, {"ok": False, "error": "Unauthorized", "code": "UNAUTHORIZED"})
return
IMP-1: SpawnMixin for Server (fulfills AC-8, AC-9, AC-10, AC-11, AC-12, AC-13, AC-14, AC-18, AC-19, AC-22, AC-23, AC-24, AC-26-AC-30, AC-33-AC-36)
File: amc_server/mixins/spawn.py
Integration notes:
- Uses
_send_json()from HttpMixin (not a new_json_response) - Uses inline JSON body parsing (same pattern as
control.py:33-47) - PROJECTS_DIR, ZELLIJ_SESSION,
_spawn_lock,_spawn_timestamps,SPAWN_COOLDOWN_SECcome from context.py - Deterministic correlation: Generates
spawn_id, passes via env var, polls for matching session file - Concurrency safety: Acquires
_spawn_lockaround Zellij operations to prevent race conditions - Rate limiting: Per-project cooldown prevents spawn spam (AC-35, AC-36)
- Symlink safety: Resolves project path and verifies it's still under PROJECTS_DIR
- TOCTOU mitigation: Validation returns resolved path; caller uses it directly (no re-resolution)
- Env var propagation: Uses shell wrapper to guarantee
AMC_SPAWN_IDreaches agent process
import json
import os
import subprocess
import time
import uuid
from amc_server.context import (
PROJECTS_DIR, SESSIONS_DIR, ZELLIJ_BIN, ZELLIJ_SESSION,
_spawn_lock, _spawn_timestamps, SPAWN_COOLDOWN_SEC,
)
# Agent commands (AC-8, AC-9: full autonomous permissions)
AGENT_COMMANDS = {
"claude": ["claude", "--dangerously-skip-permissions"],
"codex": ["codex", "--dangerously-bypass-approvals-and-sandbox"],
}
# Module-level cache for projects list (AC-33)
_projects_cache: list[str] = []
def load_projects_cache():
"""Scan ~/projects/ and cache the list. Called on server start."""
global _projects_cache
try:
projects = []
for entry in PROJECTS_DIR.iterdir():
if entry.is_dir() and not entry.name.startswith("."):
projects.append(entry.name)
projects.sort()
_projects_cache = projects
except OSError:
_projects_cache = []
class SpawnMixin:
def _handle_spawn(self):
"""Handle POST /api/spawn"""
# Read JSON body (same pattern as control.py)
try:
content_length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(content_length))
if not isinstance(body, dict):
self._json_error(400, "Invalid JSON body")
return
except (json.JSONDecodeError, ValueError):
self._json_error(400, "Invalid JSON body")
return
project = body.get("project", "").strip()
agent_type = body.get("agent_type", "claude").strip()
# Validation returns resolved path to avoid TOCTOU
validation = self._validate_spawn_params(project, agent_type)
if "error" in validation:
self._send_json(400, {"ok": False, "error": validation["error"], "code": validation["code"]})
return
resolved_path = validation["resolved_path"]
# Generate spawn_id for deterministic correlation (AC-27)
spawn_id = str(uuid.uuid4())
# Acquire lock to serialize Zellij operations (AC-30)
# NOTE: Rate limiting check is INSIDE lock to prevent race condition where
# two concurrent requests both pass the cooldown check before either updates timestamp
# Use timeout to prevent indefinite blocking if lock is held by hung thread
if not _spawn_lock.acquire(timeout=15.0):
self._send_json(503, {
"ok": False,
"error": "Server busy, try again shortly",
"code": "SERVER_BUSY"
})
return
acquire_start = time.time()
try:
# Log lock contention for debugging
acquire_time = time.time() - acquire_start
if acquire_time > 1.0:
import logging
logging.warning(f"Spawn lock contention: waited {acquire_time:.1f}s for {project}")
# Rate limiting per project (AC-35, AC-36) - must be inside lock
now = time.time()
last_spawn = _spawn_timestamps.get(project, 0)
if now - last_spawn < SPAWN_COOLDOWN_SEC:
retry_after = int(SPAWN_COOLDOWN_SEC - (now - last_spawn)) + 1
self._send_json(429, {
"ok": False,
"error": f"Rate limited. Try again in {retry_after} seconds.",
"code": "RATE_LIMITED",
"retry_after": retry_after,
})
return
result = self._spawn_agent_in_project_tab(project, resolved_path, agent_type, spawn_id)
# Update timestamp only on successful spawn (don't waste cooldown on failures)
if result["ok"]:
_spawn_timestamps[project] = time.time()
finally:
_spawn_lock.release()
if result["ok"]:
self._send_json(200, {"ok": True, "project": project, "agent_type": agent_type, "spawn_id": spawn_id})
else:
self._send_json(500, {"ok": False, "error": result["error"], "code": result.get("code", "SPAWN_FAILED")})
def _validate_spawn_params(self, project, agent_type):
"""Validate spawn parameters. Returns resolved_path on success, error dict on failure.
Returns resolved path to avoid TOCTOU: caller uses this path directly
instead of re-resolving after validation.
"""
if not project:
return {"error": "project is required", "code": "MISSING_PROJECT"}
# Security: no path traversal in project name
if "/" in project or "\\" in project or ".." in project:
return {"error": "Invalid project name", "code": "INVALID_PROJECT"}
# Resolve symlinks and verify still under PROJECTS_DIR (AC-22)
project_path = PROJECTS_DIR / project
try:
resolved = project_path.resolve()
except OSError:
return {"error": f"Project not found: {project}", "code": "PROJECT_NOT_FOUND"}
# Symlink escape check: resolved path must be under PROJECTS_DIR
try:
resolved.relative_to(PROJECTS_DIR.resolve())
except ValueError:
return {"error": "Invalid project path", "code": "INVALID_PROJECT"}
if not resolved.is_dir():
return {"error": f"Project not found: {project}", "code": "PROJECT_NOT_FOUND"}
# Agent type must be valid
if agent_type not in AGENT_COMMANDS:
return {"error": f"Invalid agent type: {agent_type}", "code": "INVALID_AGENT_TYPE"}
# Return resolved path to avoid TOCTOU
return {"resolved_path": resolved}
def _check_zellij_session_exists(self):
"""Check if the target Zellij session exists (AC-26).
Uses line-by-line parsing rather than substring check to avoid
false positives from similarly-named sessions (e.g., "infra2" matching "infra").
"""
try:
result = subprocess.run(
[ZELLIJ_BIN, "list-sessions"],
capture_output=True,
text=True,
timeout=5
)
# Parse session names line by line to avoid substring false positives
# Each line is a session name (may have status suffix like " (current)")
for line in result.stdout.strip().split("\n"):
session_name = line.split()[0] if line.strip() else ""
if session_name == ZELLIJ_SESSION:
return True
return False
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
def _wait_for_session_file(self, spawn_id, timeout=10.0):
"""Poll for session file containing our spawn_id (AC-29).
Deterministic correlation: we look for the specific spawn_id we passed
to the agent, not just "any new file". This prevents false positives
from unrelated agent activity.
Note: We don't filter by mtime because spawn_id is already unique per
request - no risk of matching stale files. This also avoids edge cases
where file is written faster than our timestamp capture.
Args:
spawn_id: The UUID we passed to the agent via AMC_SPAWN_ID env var
timeout: Maximum seconds to poll (10s for cold starts, VM latency)
"""
poll_start = time.time()
poll_interval = 0.25
while time.time() - poll_start < timeout:
if SESSIONS_DIR.exists():
for f in SESSIONS_DIR.glob("*.json"):
try:
data = json.loads(f.read_text())
if data.get("spawn_id") == spawn_id:
return True
except (json.JSONDecodeError, OSError):
continue
time.sleep(poll_interval)
return False
def _spawn_agent_in_project_tab(self, project, project_path, agent_type, spawn_id):
"""Ensure project tab exists and spawn agent pane.
Called with _spawn_lock held to serialize Zellij operations.
Note: project_path is pre-resolved by _validate_spawn_params to avoid TOCTOU.
"""
try:
# Step 0: Check session exists (AC-26)
if not self._check_zellij_session_exists():
return {
"ok": False,
"error": f"Zellij session '{ZELLIJ_SESSION}' not found",
"code": "SESSION_NOT_FOUND"
}
# Step 1: Go to or create the project tab
tab_result = subprocess.run(
[ZELLIJ_BIN, "--session", ZELLIJ_SESSION, "action", "go-to-tab-name", "--create", project],
capture_output=True,
text=True,
timeout=5
)
if tab_result.returncode != 0:
return {"ok": False, "error": f"Failed to create/switch tab: {tab_result.stderr}", "code": "TAB_ERROR"}
# Step 2: Spawn new pane with agent command (AC-14: naming scheme)
# Pass AMC_SPAWN_ID via subprocess env dict, merged with inherited environment.
# This ensures the env var propagates through Zellij's subprocess tree to the agent.
agent_cmd = AGENT_COMMANDS[agent_type]
pane_name = f"{agent_type}-{project}"
spawn_cmd = [
ZELLIJ_BIN, "--session", ZELLIJ_SESSION, "action", "new-pane",
"--name", pane_name,
"--cwd", str(project_path),
"--",
*agent_cmd
]
# Merge spawn_id into environment so it reaches the agent process
spawn_env = os.environ.copy()
spawn_env["AMC_SPAWN_ID"] = spawn_id
spawn_result = subprocess.run(
spawn_cmd,
capture_output=True,
text=True,
timeout=10,
env=spawn_env,
)
if spawn_result.returncode != 0:
return {"ok": False, "error": f"Failed to spawn pane: {spawn_result.stderr}", "code": "SPAWN_ERROR"}
# Step 3: Wait for session file with matching spawn_id (AC-29)
# No mtime filter needed - spawn_id is unique per request
if not self._wait_for_session_file(spawn_id, timeout=10.0):
return {
"ok": False,
"error": "Agent spawned but session file not detected within 10 seconds",
"code": "SESSION_FILE_TIMEOUT"
}
return {"ok": True}
except FileNotFoundError:
return {"ok": False, "error": f"zellij not found at {ZELLIJ_BIN}", "code": "ZELLIJ_NOT_FOUND"}
except subprocess.TimeoutExpired:
return {"ok": False, "error": "zellij command timed out", "code": "TIMEOUT"}
def _handle_projects(self):
"""Handle GET /api/projects - return cached projects list (AC-33)."""
self._send_json(200, {"projects": _projects_cache})
def _handle_projects_refresh(self):
"""Handle POST /api/projects/refresh - refresh cache (AC-34)."""
load_projects_cache()
self._send_json(200, {"ok": True, "projects": _projects_cache})
def _handle_health(self):
"""Handle GET /api/health - return server status (AC-41)."""
zellij_available = self._check_zellij_session_exists()
self._send_json(200, {
"ok": True,
"zellij_session": ZELLIJ_SESSION,
"zellij_available": zellij_available,
"projects_count": len(_projects_cache),
})
IMP-1b: Update amc-hook for spawn_id (fulfills AC-28)
File: bin/amc-hook
Integration notes:
- Check for
AMC_SPAWN_IDenvironment variable - If present, include it in the session JSON written to disk
- This enables deterministic correlation between spawn request and session discovery
Add after reading hook JSON and before writing session file:
# Include spawn_id if present in environment (for spawn correlation)
spawn_id = os.environ.get("AMC_SPAWN_ID")
if spawn_id:
session_data["spawn_id"] = spawn_id
IMP-2: HTTP Routing (fulfills AC-1, AC-3, AC-4, AC-34)
File: amc_server/mixins/http.py
Add to do_GET:
elif self.path == "/api/projects":
self._handle_projects()
elif self.path == "/api/health":
self._handle_health()
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 (AC-39: consistent CORS):
def do_OPTIONS(self):
# CORS preflight for API endpoints
# AC-39: Keep wildcard CORS consistent with existing endpoints;
# localhost-only binding (AC-24) is the real security boundary
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
self.end_headers()
IMP-2b: Server Startup (fulfills AC-33, AC-37)
File: amc_server/server.py
Add to server initialization:
from amc_server.mixins.spawn import load_projects_cache
from amc_server.context import generate_auth_token, start_projects_watcher
# In server startup, before starting HTTP server:
load_projects_cache()
auth_token = generate_auth_token() # AC-37: Generate one-time token
start_projects_watcher() # AC-40: Auto-refresh every 5 minutes
# Token is injected into dashboard HTML via template variable
IMP-2d: Inject Auth Token into Dashboard (fulfills AC-37)
File: amc_server/mixins/http.py (in dashboard HTML serving)
Inject the auth token into the dashboard HTML so JavaScript can use it:
# In the HTML template that serves the dashboard:
html_content = html_content.replace(
"<!-- AMC_AUTH_TOKEN -->",
f'<script>window.AMC_AUTH_TOKEN = "{_auth_token}";</script>'
)
File: dashboard/index.html
Add placeholder in <head>:
<!-- AMC_AUTH_TOKEN -->
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-25, AC-31, AC-32)
File: dashboard/components/SpawnModal.js
Integration notes:
- Uses
fetchWithTimeoutand API constants fromapi.js(consistent with codebase) - Follows
Modal.jspatterns: escape key, click-outside, body scroll lock, animated close - Uses
htmltagged 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',
'Authorization': `Bearer ${window.AMC_AUTH_TOKEN}`, // AC-38
},
body: JSON.stringify({ project, agent_type: agentType })
});
const data = await response.json();
if (data.ok) {
onSpawn({ success: true, project, agentType });
handleClose();
} else {
setError(data.error || 'Spawn failed');
onSpawn({ error: data.error });
}
} catch (err) {
const msg = err.name === 'AbortError' ? 'Request timed out' : err.message;
setError(msg);
onSpawn({ error: msg });
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return html`
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-[#02050d]/84 p-4 backdrop-blur-sm ${closing ? 'modal-backdrop-out' : 'modal-backdrop-in'}"
onClick=${(e) => e.target === e.currentTarget && handleClose()}
>
<div
class="glass-panel w-full max-w-md rounded-2xl p-6 ${closing ? 'modal-panel-out' : 'modal-panel-in'}"
onClick=${(e) => e.stopPropagation()}
>
<h2 class="mb-4 font-display text-lg font-semibold text-bright">New Agent</h2>
${needsProjectPicker && html`
<label class="mb-4 block">
<span class="mb-1 block text-sm text-dim">Project</span>
<select
class="w-full rounded-lg border border-selection/50 bg-surface px-3 py-2 text-bright"
value=${selectedProject}
onChange=${(e) => setSelectedProject(e.target.value)}
disabled=${loadingProjects}
>
<option value="">
${loadingProjects ? 'Loading...' : 'Select a project...'}
</option>
${projects.map(p => html`
<option key=${p} value=${p}>${p}</option>
`)}
</select>
</label>
`}
${!needsProjectPicker && html`
<p class="mb-4 text-sm text-dim">
Project: <span class="font-medium text-bright">${currentProject}</span>
</p>
`}
<label class="mb-4 block">
<span class="mb-2 block text-sm text-dim">Agent Type</span>
<div class="flex gap-2">
<button
type="button"
class="flex-1 rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
agentType === 'claude'
? 'border-active bg-active/20 text-active'
: 'border-selection/50 text-dim hover:border-selection hover:text-bright'
}"
onClick=${() => setAgentType('claude')}
>
Claude
</button>
<button
type="button"
class="flex-1 rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
agentType === 'codex'
? 'border-active bg-active/20 text-active'
: 'border-selection/50 text-dim hover:border-selection hover:text-bright'
}"
onClick=${() => setAgentType('codex')}
>
Codex
</button>
</div>
</label>
${error && html`
<p class="mb-4 rounded-lg border border-attention/50 bg-attention/10 px-3 py-2 text-sm text-attention">
${error}
</p>
`}
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-lg border border-selection/50 px-4 py-2 text-sm text-dim transition-colors hover:border-selection hover:text-bright"
onClick=${handleClose}
disabled=${loading}
>
Cancel
</button>
<button
type="button"
class="rounded-lg bg-active px-4 py-2 text-sm font-medium text-surface transition-opacity disabled:opacity-50"
onClick=${handleSpawn}
disabled=${loading || (needsProjectPicker && !selectedProject)}
>
${loading ? 'Spawning...' : 'Spawn'}
</button>
</div>
</div>
</div>
`;
}
IMP-5: Header New Agent Button (fulfills AC-1)
File: dashboard/components/App.js
Integration points:
- Add import for SpawnModal
- Add state for modal visibility
- Add button to existing inline header (lines 331-380)
- Add SpawnModal component at end of render
Project identity note: selectedProject in App.js is already the short project name (e.g., "amc"), not the full path. This comes from groupSessionsByProject() in status.js which uses projectName as the key. The modal can pass it directly to /api/spawn.
// Add import at top
import { SpawnModal } from './SpawnModal.js';
// Add state in App component (around line 14)
const [spawnModalOpen, setSpawnModalOpen] = useState(false);
// Add button to existing header section (inside the flex container, around line 341)
// After the status summary chips div, add:
<button
class="rounded-lg border border-active/40 bg-active/12 px-3 py-2 text-sm font-medium text-active transition-colors hover:bg-active/20"
onClick=${() => setSpawnModalOpen(true)}
>
+ New Agent
</button>
// Add modal before closing fragment (after ToastContainer, around line 426)
// selectedProject is already the short name (e.g., "amc"), not a path
<${SpawnModal}
isOpen=${spawnModalOpen}
onClose=${() => setSpawnModalOpen(false)}
onSpawn=${handleSpawnResult}
currentProject=${selectedProject}
/>
IMP-6: Toast Notifications for Spawn Results (fulfills AC-20, AC-21)
File: dashboard/components/App.js
Integration note: Uses existing showToast(message, type, duration) signature from Toast.js.
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:
- Create
SpawnMixinwith_handle_spawn(),_validate_spawn_params(),_spawn_agent_in_project_tab() - Create
_handle_projects()for listing~/projects/subdirectories - Add
/api/spawn(POST) and/api/projects(GET) routes to HTTP handler - Add SpawnMixin to handler inheritance chain
- 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-24, AC-26, AC-27, AC-28, AC-29, AC-30, AC-33, AC-34, AC-35, AC-36, AC-40, AC-41
Slice 2: Spawn Modal UI
Goal: Complete UI for spawning agents.
Tasks:
- Create
SpawnModalcomponent with context-aware behavior - Add "+ New Agent" button to page header
- Pass
currentProjectfrom sidebar selection to modal (extract basename if needed) - Implement agent type toggle (Claude / Codex)
- Wire up project dropdown (only shown on "All Projects")
- Add loading and error states
- Show toast on spawn result
- Implement modal dismiss behavior (Escape, click-outside, Cancel)
ACs covered: AC-1, AC-2, AC-3, AC-6, AC-7, AC-20, AC-21, AC-25, AC-31, AC-32, AC-42
Slice 3: Polish & Edge Cases
Goal: Handle edge cases and improve UX.
Tasks:
- Handle case where
~/projects/doesn't exist or is empty - Add visual feedback when agent appears in dashboard after spawn
- Test with projects that have special characters in name
- Ensure spawned agents' hooks write correct metadata
ACs covered: AC-15, AC-16, AC-17
Open Questions
-
Rate limiting: Should we limit spawn frequency to prevent accidental spam?RESOLVED: Added per-project 10-second cooldown (AC-35, AC-36) -
Session cleanup: When a spawned agent exits, should dashboard offer to close the pane?
-
Multiple Zellij sessions: Currently hardcoded to "infra". Future: detect or let user pick?
-
Agent naming: Current scheme is
{agent_type}-{project}. Collision if multiple agents for same project? (Zellij allows duplicate pane names; could add timestamp suffix) -
Spawn limits: Should we add spawn limits or warnings for resource management? (Rate limiting helps but doesn't cap total)
-
Dead code cleanup:
Header.jsexists but isn't used (App.js has inline header). Remove it? -
Hook verification: Should spawn endpoint verify
amc-hookis installed before spawning Claude agents? (Could add/api/hook-statusendpoint) -
Async spawn confirmation: Current design returns error if session file not detected in 5s even though pane exists. Future: return spawn_id immediately, let dashboard poll for confirmation? (Suggested by GPT 5.3 review but adds complexity)
-
Tab focus disruption:
go-to-tab-name --createchanges active tab globally in "infra" session. Explore--skip-focusor similar if available in Zellij CLI?
Design Decisions (from review)
These issues were identified during external review and addressed in the plan:
| Issue | Resolution |
|---|---|
st_mtime vs time.monotonic() bug |
Fixed: Use time.time() for wall-clock comparison with file mtime |
| "Any new file" polling could return false success | Fixed: Deterministic spawn_id correlation via env var |
| Concurrent spawns race on Zellij tab focus | Fixed: _spawn_lock serializes all Zellij operations |
Symlink escape from ~/projects/ |
Fixed: Path.resolve() + relative_to() check |
CORS * with dangerous agent flags |
Accepted: localhost-only binding (AC-24) is sufficient for dev-machine use |
| Project identity mismatch (full path vs basename) | Documented: selectedProject from sidebar is already the short name; verify in implementation |
Additional Issues (from GPT 5.3 second opinion)
| Issue | Resolution |
|---|---|
AMC_SPAWN_ID propagation not guaranteed |
Fixed: Use shell wrapper (sh -c "export AMC_SPAWN_ID=...; exec ...") to guarantee env var reaches agent process |
go-to-tab-name --create changes active tab globally |
Accepted: Dev-machine tool; focus disruption is minor annoyance, not critical. Could explore --skip-focus flag in future |
| Process-local lock insufficient for multi-worker | Accepted: AMC is single-process by design; documented as design constraint |
SESSION_FILE_TIMEOUT creates false-failure path |
Documented: Pane exists but API returns error; future work could add idempotent retry with spawn_id deduplication |
Authz/abuse controls missing on /api/spawn |
Fixed: Added per-project rate limiting (AC-35, AC-36); localhost-only binding provides baseline security |
| TOCTOU: path validated then re-resolved | Fixed: _validate_spawn_params returns resolved path; caller uses it directly |
| Hardcoded "infra" session is SPOF | Documented: Single-session design is intentional for v1; multi-session support noted in Open Questions |
| 5s polling timeout brittle under cold starts | Accepted: 5s is generous for typical agent startup; SESSION_FILE_TIMEOUT error is actionable |
| Hook missing/broken causes confirmation failure | Documented: Prerequisite section notes hook must be installed; future work could add hook verification endpoint |
| Pane name collisions reduce debuggability | Accepted: Zellij allows duplicate names; dashboard shows full session context. Could add timestamp suffix in future |
Issues from GPT 5.3 Third Review (Codex)
| Issue | Resolution |
|---|---|
Rate-limit check outside _spawn_lock causes race |
Fixed: Moved rate-limit check inside lock to prevent two requests bypassing cooldown simultaneously |
start = time.time() after spawn causes false timeout |
Fixed: Capture spawn_start_time BEFORE spawn command; pass to _wait_for_session_file() |
CORS * + localhost insufficient for security |
Fixed: Added AC-37, AC-38, AC-39 for auth token + strict CORS |
ZELLIJ_SESSION in stdout substring check |
Fixed: Parse session names line-by-line to avoid false positives (e.g., "infra2" matching "infra") |
go-to-tab-name then new-pane not atomic |
Accepted: Zellij CLI doesn't support atomic tab+pane creation; race window is small in practice |
Issues from GPT 5.3 Fourth Review (Codex)
| Issue | Resolution |
|---|---|
| Timestamp captured BEFORE spawn creates mtime ambiguity | Fixed: Capture spawn_complete_time AFTER subprocess returns; spawn_id correlation handles fast writes |
| 5s polling timeout brittle for cold starts/VMs | Fixed: Increased timeout to 10s (AC-29 updated) |
| CORS inconsistency (wildcard removed only on spawn) | Fixed: Keep wildcard CORS consistent; localhost binding is security boundary (AC-39 updated) |
| Projects cache goes stale between server restarts | Fixed: Added AC-40 for 5-minute background refresh |
| Lock contention could silently delay requests | Fixed: Added contention logging when wait exceeds 1s |
| Auth token via inline script is fragile | Accepted: Works for localhost dev tool; secure cookie alternative documented as future option |
Issues from GPT 5.3 Fifth Review (Codex)
| Issue | Resolution |
|---|---|
| Shell wrapper env var propagation is fragile | Fixed: Use subprocess.run(..., env=spawn_env) to pass env dict directly |
| Mtime check creates race if file written during spawn | Fixed: Removed mtime filter entirely; spawn_id is already deterministic |
| Rate-limit timestamp updated before spawn wastes cooldown | Fixed: Update timestamp only after successful spawn |
| Background thread can silently die on exception | Fixed: Added try-except with logging in watch loop |
| Lock acquisition can block indefinitely | Fixed: Added 15s timeout, return SERVER_BUSY on timeout |
| No way to check Zellij session status before spawning | Fixed: Added AC-41/AC-42 for health endpoint and dashboard warning |
| Codex discovery via process inspection unreliable with shell wrapper | Fixed: Clarified Codex uses hook-based discovery (same as Claude) |