feat(dashboard): add click-outside dismissal for autocomplete dropdown

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.
This commit is contained in:
teernisse
2026-02-26 16:52:36 -05:00
parent ba16daac2a
commit db3d2a2e31
35 changed files with 5560 additions and 104 deletions

View File

@@ -76,7 +76,7 @@ Add the ability to spawn new agent sessions (Claude or Codex) from the AMC dashb
- **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
- **AC-15:** The spawned agent appears in the dashboard within 10 seconds of spawn
### Session Discovery
@@ -95,6 +95,9 @@ Add the ability to spawn new agent sessions (Claude or Codex) from the AMC dashb
- **AC-22:** Server validates project path is within `~/projects/` (resolves symlinks)
- **AC-23:** Server rejects path traversal attempts in project parameter
- **AC-24:** Server binds to localhost only (127.0.0.1), not exposed to network
- **AC-37:** Server generates a one-time auth token on startup and injects it into dashboard HTML
- **AC-38:** `/api/spawn` requires valid auth token in `Authorization` header
- **AC-39:** CORS headers are consistent across all endpoints (`Access-Control-Allow-Origin: *`); localhost-only binding (AC-24) is the security boundary
### Spawn Request Lifecycle
@@ -102,7 +105,7 @@ Add the ability to spawn new agent sessions (Claude or Codex) from the AMC dashb
- **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-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
@@ -114,6 +117,17 @@ Add the ability to spawn new agent sessions (Claude or Codex) from the AMC dashb
- **AC-33:** Projects list is loaded on server start and cached in memory
- **AC-34:** Projects list can be refreshed via `POST /api/projects/refresh`
- **AC-40:** Projects list auto-refreshes every 5 minutes in background thread
### Rate Limiting
- **AC-35:** Spawn requests for the same project are throttled to 1 per 10 seconds
- **AC-36:** Rate limit errors return `RATE_LIMITED` code with retry-after hint
### Health Check
- **AC-41:** `GET /api/health` returns server status including Zellij session availability
- **AC-42:** Dashboard shows warning banner when Zellij session is unavailable
---
@@ -183,7 +197,7 @@ Add the ability to spawn new agent sessions (Claude or Codex) from the AMC dashb
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)
10. **Server:** Poll for session file containing matching `spawn_id` (up to 10 seconds)
11. **Server → Dashboard:** Return success only after session file with `spawn_id` detected
12. **Server:** Release spawn lock
@@ -217,6 +231,16 @@ Response (error):
}
```
Response (rate limited - AC-35, AC-36):
```json
{
"ok": false,
"error": "Rate limited. Try again in 8 seconds.",
"code": "RATE_LIMITED",
"retry_after": 8
}
```
**GET /api/projects**
Response:
@@ -254,11 +278,11 @@ Response:
- 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
**Codex agents** are discovered via the same hook mechanism as Claude:
- When spawned with `AMC_SPAWN_ID` env var, the hook writes spawn_id to session JSON
- Existing `SessionDiscoveryMixin` also scans `~/.codex/sessions/` as fallback
- **Spawn correlation:** Hook has direct access to env var and writes spawn_id
- Note: Process inspection (`pgrep`, `lsof`) is used for non-spawned agents only
**Prerequisite:** The `amc-hook` must be installed in Claude Code's hooks configuration. See `~/.claude/hooks/` or Claude Code settings.
@@ -293,26 +317,89 @@ 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-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)
### IMP-0b: Auth Token Verification in SpawnMixin (fulfills AC-38)
Add to the beginning of `_handle_spawn()`:
```python
# Verify auth token (AC-38)
auth_header = self.headers.get("Authorization", "")
if not validate_auth_token(auth_header):
self._send_json(401, {"ok": False, "error": "Unauthorized", "code": "UNAUTHORIZED"})
return
```
---
### IMP-1: SpawnMixin for Server (fulfills AC-8, AC-9, AC-10, AC-11, AC-12, AC-13, AC-14, AC-18, AC-19, AC-22, AC-23, AC-24, AC-26-AC-30, AC-33-AC-36)
**File:** `amc_server/mixins/spawn.py`
**Integration notes:**
- Uses `_send_json()` from HttpMixin (not a new `_json_response`)
- Uses inline JSON body parsing (same pattern as `control.py:33-47`)
- PROJECTS_DIR and ZELLIJ_SESSION come from context.py (centralized constants)
- Session file polling watches SESSIONS_DIR for any new .json by mtime
- PROJECTS_DIR, ZELLIJ_SESSION, `_spawn_lock`, `_spawn_timestamps`, `SPAWN_COOLDOWN_SEC` come from context.py
- **Deterministic correlation:** Generates `spawn_id`, passes via env var, polls for matching session file
- **Concurrency safety:** Acquires `_spawn_lock` around Zellij operations to prevent race conditions
- **Rate limiting:** Per-project cooldown prevents spawn spam (AC-35, AC-36)
- **Symlink safety:** Resolves project path and verifies it's still under PROJECTS_DIR
- **TOCTOU mitigation:** Validation returns resolved path; caller uses it directly (no re-resolution)
- **Env var propagation:** Uses shell wrapper to guarantee `AMC_SPAWN_ID` reaches agent process
```python
import json
import os
import subprocess
import time
import uuid
from amc_server.context import PROJECTS_DIR, SESSIONS_DIR, ZELLIJ_BIN, ZELLIJ_SESSION
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 = {
@@ -320,7 +407,7 @@ AGENT_COMMANDS = {
"codex": ["codex", "--dangerously-bypass-approvals-and-sandbox"],
}
# Module-level cache for projects list (AC-29)
# Module-level cache for projects list (AC-33)
_projects_cache: list[str] = []
@@ -355,44 +442,104 @@ class SpawnMixin:
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"]})
# 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
project_path = PROJECTS_DIR / project
resolved_path = validation["resolved_path"]
# Ensure tab exists, then spawn pane, then wait for session file
result = self._spawn_agent_in_project_tab(project, project_path, agent_type)
# 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})
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 error dict or None."""
"""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 {"message": "project is required", "code": "MISSING_PROJECT"}
return {"error": "project is required", "code": "MISSING_PROJECT"}
# Security: no path traversal
# Security: no path traversal in project name
if "/" in project or "\\" in project or ".." in project:
return {"message": "Invalid project name", "code": "INVALID_PROJECT"}
return {"error": "Invalid project name", "code": "INVALID_PROJECT"}
# Project must exist
# Resolve symlinks and verify still under PROJECTS_DIR (AC-22)
project_path = PROJECTS_DIR / project
if not project_path.is_dir():
return {"message": f"Project not found: {project}", "code": "PROJECT_NOT_FOUND"}
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 {"message": f"Invalid agent type: {agent_type}", "code": "INVALID_AGENT_TYPE"}
return {"error": f"Invalid agent type: {agent_type}", "code": "INVALID_AGENT_TYPE"}
return None
# Return resolved path to avoid TOCTOU
return {"resolved_path": resolved}
def _check_zellij_session_exists(self):
"""Check if the target Zellij session exists (AC-25)."""
"""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"],
@@ -400,38 +547,55 @@ class SpawnMixin:
text=True,
timeout=5
)
return ZELLIJ_SESSION in result.stdout
# 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, timeout=5.0):
"""Poll for any new session file in SESSIONS_DIR (AC-26).
def _wait_for_session_file(self, spawn_id, timeout=10.0):
"""Poll for session file containing our spawn_id (AC-29).
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.
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)
"""
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")}
poll_start = time.time()
poll_interval = 0.25
while time.monotonic() - start < timeout:
while time.time() - poll_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)
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):
"""Ensure project tab exists and spawn agent pane."""
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-25)
# Step 0: Check session exists (AC-26)
if not self._check_zellij_session_exists():
return {
"ok": False,
@@ -450,6 +614,8 @@ class SpawnMixin:
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}"
@@ -461,20 +627,26 @@ class SpawnMixin:
*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
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 (AC-26)
if not self._wait_for_session_file(timeout=5.0):
# 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 5 seconds",
"error": "Agent spawned but session file not detected within 10 seconds",
"code": "SESSION_FILE_TIMEOUT"
}
@@ -486,16 +658,45 @@ class SpawnMixin:
return {"ok": False, "error": "zellij command timed out", "code": "TIMEOUT"}
def _handle_projects(self):
"""Handle GET /api/projects - return cached projects list (AC-29)."""
"""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-30)."""
"""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-2: HTTP Routing (fulfills AC-1, AC-3, AC-4, AC-30)
### IMP-1b: Update amc-hook for spawn_id (fulfills AC-28)
**File:** `bin/amc-hook`
**Integration notes:**
- Check for `AMC_SPAWN_ID` environment variable
- If present, include it in the session JSON written to disk
- This enables deterministic correlation between spawn request and session discovery
Add after reading hook JSON and before writing session file:
```python
# Include spawn_id if present in environment (for spawn correlation)
spawn_id = os.environ.get("AMC_SPAWN_ID")
if spawn_id:
session_data["spawn_id"] = spawn_id
```
---
### IMP-2: HTTP Routing (fulfills AC-1, AC-3, AC-4, AC-34)
**File:** `amc_server/mixins/http.py`
@@ -503,6 +704,8 @@ Add to `do_GET`:
```python
elif self.path == "/api/projects":
self._handle_projects()
elif self.path == "/api/health":
self._handle_health()
```
Add to `do_POST`:
@@ -513,27 +716,53 @@ elif self.path == "/api/projects/refresh":
self._handle_projects_refresh()
```
Update `do_OPTIONS` for CORS preflight on new endpoints:
Update `do_OPTIONS` for CORS preflight on new endpoints (AC-39: consistent CORS):
```python
def do_OPTIONS(self):
# CORS preflight for API endpoints
# AC-39: Keep wildcard CORS consistent with existing endpoints;
# localhost-only binding (AC-24) is the real security boundary
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
self.end_headers()
```
### IMP-2b: Server Startup (fulfills AC-29)
### IMP-2b: Server Startup (fulfills AC-33, AC-37)
**File:** `amc_server/server.py`
Add to server initialization:
```python
from amc_server.mixins.spawn import load_projects_cache
from amc_server.context import generate_auth_token, start_projects_watcher
# In server startup, before starting HTTP server:
load_projects_cache()
auth_token = generate_auth_token() # AC-37: Generate one-time token
start_projects_watcher() # AC-40: Auto-refresh every 5 minutes
# Token is injected into dashboard HTML via template variable
```
### IMP-2d: Inject Auth Token into Dashboard (fulfills AC-37)
**File:** `amc_server/mixins/http.py` (in dashboard HTML serving)
Inject the auth token into the dashboard HTML so JavaScript can use it:
```python
# In the HTML template that serves the dashboard:
html_content = html_content.replace(
"<!-- AMC_AUTH_TOKEN -->",
f'<script>window.AMC_AUTH_TOKEN = "{_auth_token}";</script>'
)
```
**File:** `dashboard/index.html`
Add placeholder in `<head>`:
```html
<!-- AMC_AUTH_TOKEN -->
```
### IMP-2c: API Constants (follows existing pattern)
@@ -569,7 +798,7 @@ class AMCHandler(
"""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)
### 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`
@@ -658,7 +887,10 @@ export function SpawnModal({ isOpen, onClose, onSpawn, currentProject }) {
try {
const response = await fetchWithTimeout(API_SPAWN, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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();
@@ -785,6 +1017,8 @@ export function SpawnModal({ isOpen, onClose, onSpawn, currentProject }) {
3. Add button to existing inline header (lines 331-380)
4. Add SpawnModal component at end of render
**Project identity note:** `selectedProject` in App.js is already the short project name (e.g., "amc"), not the full path. This comes from `groupSessionsByProject()` in `status.js` which uses `projectName` as the key. The modal can pass it directly to `/api/spawn`.
```javascript
// Add import at top
import { SpawnModal } from './SpawnModal.js';
@@ -802,6 +1036,7 @@ const [spawnModalOpen, setSpawnModalOpen] = useState(false);
</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)}
@@ -861,7 +1096,7 @@ curl -X POST http://localhost:7400/api/spawn \
-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
**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
@@ -870,14 +1105,14 @@ curl -X POST http://localhost:7400/api/spawn \
**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
3. Pass `currentProject` from sidebar selection to modal (extract basename if needed)
4. Implement agent type toggle (Claude / Codex)
5. Wire up project dropdown (only shown on "All Projects")
6. Add loading and error states
7. Show toast on spawn result
8. Implement modal dismiss behavior (Escape, click-outside, Cancel)
**ACs covered:** AC-1, AC-2, AC-3, AC-6, AC-7, AC-20, AC-21, AC-24, AC-27, AC-28
**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
@@ -895,16 +1130,83 @@ curl -X POST http://localhost:7400/api/spawn \
## Open Questions
1. **Rate limiting:** Should we limit spawn frequency to prevent accidental spam?
1. ~~**Rate limiting:** Should we limit spawn frequency to prevent accidental spam?~~ **RESOLVED:** Added per-project 10-second cooldown (AC-35, AC-36)
2. **Session cleanup:** When a spawned agent exits, should dashboard offer to close the pane?
3. **Multiple Zellij sessions:** Currently hardcoded to "infra". Future: detect or let user pick?
4. **Agent naming:** Current scheme is `{agent_type}-{project}`. Collision if multiple agents for same project?
4. **Agent naming:** Current scheme is `{agent_type}-{project}`. Collision if multiple agents for same project? (Zellij allows duplicate pane names; could add timestamp suffix)
5. **Spawn limits:** Should we add spawn limits or warnings for resource management?
5. **Spawn limits:** Should we add spawn limits or warnings for resource management? (Rate limiting helps but doesn't cap total)
6. **Dead code cleanup:** `Header.js` exists but isn't used (App.js has inline header). Remove it?
7. **Hook verification:** Should spawn endpoint verify `amc-hook` is installed before spawning Claude agents?
7. **Hook verification:** Should spawn endpoint verify `amc-hook` is installed before spawning Claude agents? (Could add `/api/hook-status` endpoint)
8. **Async spawn confirmation:** Current design returns error if session file not detected in 5s even though pane exists. Future: return spawn_id immediately, let dashboard poll for confirmation? (Suggested by GPT 5.3 review but adds complexity)
9. **Tab focus disruption:** `go-to-tab-name --create` changes active tab globally in "infra" session. Explore `--skip-focus` or similar if available in Zellij CLI?
---
## Design Decisions (from review)
These issues were identified during external review and addressed in the plan:
| Issue | Resolution |
|-------|------------|
| `st_mtime` vs `time.monotonic()` bug | Fixed: Use `time.time()` for wall-clock comparison with file mtime |
| "Any new file" polling could return false success | Fixed: Deterministic `spawn_id` correlation via env var |
| Concurrent spawns race on Zellij tab focus | Fixed: `_spawn_lock` serializes all Zellij operations |
| Symlink escape from `~/projects/` | Fixed: `Path.resolve()` + `relative_to()` check |
| CORS `*` with dangerous agent flags | Accepted: localhost-only binding (AC-24) is sufficient for dev-machine use |
| Project identity mismatch (full path vs basename) | Documented: `selectedProject` from sidebar is already the short name; verify in implementation |
### Additional Issues (from GPT 5.3 second opinion)
| Issue | Resolution |
|-------|------------|
| `AMC_SPAWN_ID` propagation not guaranteed | Fixed: Use shell wrapper (`sh -c "export AMC_SPAWN_ID=...; exec ..."`) to guarantee env var reaches agent process |
| `go-to-tab-name --create` changes active tab globally | Accepted: Dev-machine tool; focus disruption is minor annoyance, not critical. Could explore `--skip-focus` flag in future |
| Process-local lock insufficient for multi-worker | Accepted: AMC is single-process by design; documented as design constraint |
| `SESSION_FILE_TIMEOUT` creates false-failure path | Documented: Pane exists but API returns error; future work could add idempotent retry with spawn_id deduplication |
| Authz/abuse controls missing on `/api/spawn` | Fixed: Added per-project rate limiting (AC-35, AC-36); localhost-only binding provides baseline security |
| TOCTOU: path validated then re-resolved | Fixed: `_validate_spawn_params` returns resolved path; caller uses it directly |
| Hardcoded "infra" session is SPOF | Documented: Single-session design is intentional for v1; multi-session support noted in Open Questions |
| 5s polling timeout brittle under cold starts | Accepted: 5s is generous for typical agent startup; SESSION_FILE_TIMEOUT error is actionable |
| Hook missing/broken causes confirmation failure | Documented: Prerequisite section notes hook must be installed; future work could add hook verification endpoint |
| Pane name collisions reduce debuggability | Accepted: Zellij allows duplicate names; dashboard shows full session context. Could add timestamp suffix in future |
### Issues from GPT 5.3 Third Review (Codex)
| Issue | Resolution |
|-------|------------|
| Rate-limit check outside `_spawn_lock` causes race | Fixed: Moved rate-limit check inside lock to prevent two requests bypassing cooldown simultaneously |
| `start = time.time()` after spawn causes false timeout | Fixed: Capture `spawn_start_time` BEFORE spawn command; pass to `_wait_for_session_file()` |
| CORS `*` + localhost insufficient for security | Fixed: Added AC-37, AC-38, AC-39 for auth token + strict CORS |
| `ZELLIJ_SESSION in stdout` substring check | Fixed: Parse session names line-by-line to avoid false positives (e.g., "infra2" matching "infra") |
| `go-to-tab-name` then `new-pane` not atomic | Accepted: Zellij CLI doesn't support atomic tab+pane creation; race window is small in practice |
### Issues from GPT 5.3 Fourth Review (Codex)
| Issue | Resolution |
|-------|------------|
| Timestamp captured BEFORE spawn creates mtime ambiguity | Fixed: Capture `spawn_complete_time` AFTER subprocess returns; spawn_id correlation handles fast writes |
| 5s polling timeout brittle for cold starts/VMs | Fixed: Increased timeout to 10s (AC-29 updated) |
| CORS inconsistency (wildcard removed only on spawn) | Fixed: Keep wildcard CORS consistent; localhost binding is security boundary (AC-39 updated) |
| Projects cache goes stale between server restarts | Fixed: Added AC-40 for 5-minute background refresh |
| Lock contention could silently delay requests | Fixed: Added contention logging when wait exceeds 1s |
| Auth token via inline script is fragile | Accepted: Works for localhost dev tool; secure cookie alternative documented as future option |
### Issues from GPT 5.3 Fifth Review (Codex)
| Issue | Resolution |
|-------|------------|
| Shell wrapper env var propagation is fragile | Fixed: Use `subprocess.run(..., env=spawn_env)` to pass env dict directly |
| Mtime check creates race if file written during spawn | Fixed: Removed mtime filter entirely; spawn_id is already deterministic |
| Rate-limit timestamp updated before spawn wastes cooldown | Fixed: Update timestamp only after successful spawn |
| Background thread can silently die on exception | Fixed: Added try-except with logging in watch loop |
| Lock acquisition can block indefinitely | Fixed: Added 15s timeout, return SERVER_BUSY on timeout |
| No way to check Zellij session status before spawning | Fixed: Added AC-41/AC-42 for health endpoint and dashboard warning |
| Codex discovery via process inspection unreliable with shell wrapper | Fixed: Clarified Codex uses hook-based discovery (same as Claude) |