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:
teernisse
2026-02-26 15:25:02 -05:00
parent 6e566cfe82
commit 2926645b10
2 changed files with 1419 additions and 0 deletions

509
PLAN-slash-autocomplete.md Normal file
View File

@@ -0,0 +1,509 @@
# Plan: Skill Autocomplete for Agent Sessions
## Summary
Add autocomplete functionality to the SimpleInput component that displays available skills when the user types the agent-specific trigger character (`/` for Claude, `$` for Codex). Autocomplete triggers at the start of input or after any whitespace, enabling quick skill discovery and selection mid-message.
## User Workflow
1. User opens a session modal or card with the input field
2. User types the trigger character (`/` for Claude, `$` for Codex):
- At position 0, OR
- After a space/newline (mid-message)
3. Autocomplete dropdown appears showing available skills (alphabetically sorted)
4. User can:
- Continue typing to filter the list
- Use arrow keys to navigate
- Press Enter/Tab to select and insert the skill name
- Press Escape or click outside to dismiss
5. Selected skill replaces the trigger with `{trigger}skill-name ` (e.g., `/commit ` or `$yeet `)
## Acceptance Criteria
### Core Functionality
- **AC-1**: Autocomplete triggers when trigger character is typed at position 0 or after whitespace
- **AC-2**: Claude sessions use `/` trigger; Codex sessions use `$` trigger
- **AC-3**: Wrong trigger character for agent type is ignored (no autocomplete)
- **AC-4**: Dropdown displays skill names with trigger prefix and descriptions
- **AC-5**: Skills are sorted alphabetically by name
- **AC-6**: Typing additional characters filters the skill list (case-insensitive match on name)
- **AC-7**: Arrow up/down navigates the highlighted option
- **AC-8**: Enter or Tab inserts the selected skill name (with trigger) followed by a space
- **AC-9**: Escape, clicking outside, or backspacing over the trigger character dismisses the dropdown without insertion
- **AC-10**: Cursor movement (arrow left/right) is ignored while autocomplete is open; dropdown position is locked to trigger location
- **AC-11**: If no skills match the filter, dropdown shows "No matching skills"
### Data Flow
- **AC-12**: On session open, an agent-specific config is loaded containing: (a) trigger character (`/` for Claude, `$` for Codex), (b) enumerated skills list
- **AC-13**: Claude skills are enumerated from `~/.claude/skills/`
- **AC-14**: Codex skills are loaded from `~/.codex/vendor_imports/skills-curated-cache.json` plus `~/.codex/skills/`
- **AC-15**: If session has no skills, dropdown shows "No skills available" when trigger is typed
### UX Polish
- **AC-16**: Dropdown positions above the input (bottom-anchored), aligned left
- **AC-17**: Dropdown has max height with vertical scroll for long lists
- **AC-18**: Currently highlighted item is visually distinct
- **AC-19**: Dropdown respects the existing color scheme
- **AC-20**: After skill insertion, cursor is positioned after the trailing space, ready to continue typing
### Known Limitations (Out of Scope)
- **Duplicate skill names**: If curated and user skills share a name, both appear (no deduplication)
- **Long skill names**: No truncation; names may overflow if extremely long
- **Accessibility**: ARIA roles, active-descendant, screen reader support deferred to future iteration
- **IME/composition**: Japanese/Korean input edge cases not handled in v1
- **Server-side caching**: Skills re-enumerated on each request; mtime-based cache could improve performance at scale
## Architecture
### Autocomplete Config Per Agent
Each session gets an autocomplete config loaded at modal open:
```typescript
type AutocompleteConfig = {
trigger: '/' | '$';
skills: Array<{ name: string; description: string }>;
}
```
| Agent | Trigger | Skill Sources |
|-------|---------|---------------|
| Claude | `/` | Enumerate `~/.claude/skills/*/` |
| Codex | `$` | `~/.codex/vendor_imports/skills-curated-cache.json` + `~/.codex/skills/*/` |
### Server-Side: New Endpoint for Skills
**Endpoint**: `GET /api/skills?agent={claude|codex}`
**Response**:
```json
{
"trigger": "/",
"skills": [
{ "name": "commit", "description": "Create a git commit with a message" },
{ "name": "review-pr", "description": "Review a pull request" }
]
}
```
**Rationale**: Skills are agent-global, not session-specific. The client already knows `session.agent` from state, so no session_id is needed. Server enumerates skill directories directly.
### Component Structure
```
SimpleInput.js
├── Props: sessionId, status, onRespond, agent, autocompleteConfig
├── State: text, focused, sending, error
├── State: showAutocomplete, selectedIndex
├── Derived: triggerMatch (detects trigger at valid position)
├── Derived: filterText, filteredSkills (alphabetically sorted)
├── onInput: detect trigger character at pos 0 or after whitespace
├── onKeyDown: arrow/enter/escape handling for autocomplete
└── Render: textarea + autocomplete dropdown
```
### Data Flow
```
┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
│ Modal opens │────▶│ GET /api/skills?agent│────▶│ SimpleInput │
│ (session) │ │ (server) │ │ (dropdown) │
└─────────────────┘ └──────────────────────┘ └─────────────────┘
```
Skills are agent-global, so the same response can be cached client-side per agent type.
## Implementation Specifications
### IMP-1: Server-side skill enumeration (fulfills AC-12, AC-13, AC-14, AC-15)
**Location**: `amc_server/mixins/skills.py` (new file)
```python
class SkillsMixin:
def _serve_skills(self, agent):
"""Return autocomplete config for a session."""
if agent == "codex":
trigger = "$"
skills = self._enumerate_codex_skills()
else: # claude
trigger = "/"
skills = self._enumerate_claude_skills()
# Sort alphabetically
skills.sort(key=lambda s: s["name"].lower())
self._send_json(200, {"trigger": trigger, "skills": skills})
def _enumerate_codex_skills(self):
"""Load Codex skills from cache + user directory."""
skills = []
# Curated skills from cache
cache_file = Path.home() / ".codex/vendor_imports/skills-curated-cache.json"
if cache_file.exists():
try:
data = json.loads(cache_file.read_text())
for skill in data.get("skills", []):
skills.append({
"name": skill.get("id", skill.get("name", "")),
"description": skill.get("shortDescription", skill.get("description", ""))[:100]
})
except (json.JSONDecodeError, OSError):
pass
# User-installed skills
user_skills_dir = Path.home() / ".codex/skills"
if user_skills_dir.exists():
for skill_dir in user_skills_dir.iterdir():
if skill_dir.is_dir() and not skill_dir.name.startswith("."):
skill_md = skill_dir / "SKILL.md"
description = ""
if skill_md.exists():
# Parse first non-empty line as description
try:
for line in skill_md.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#"):
description = line[:100]
break
except OSError:
pass
skills.append({
"name": skill_dir.name,
"description": description or f"User skill: {skill_dir.name}"
})
return skills
def _enumerate_claude_skills(self):
"""Load Claude skills from user directory.
Note: Checks SKILL.md first (canonical casing used by Claude Code),
then falls back to lowercase variants for compatibility.
"""
skills = []
skills_dir = Path.home() / ".claude/skills"
if skills_dir.exists():
for skill_dir in skills_dir.iterdir():
if skill_dir.is_dir() and not skill_dir.name.startswith("."):
# Look for SKILL.md (canonical), then fallbacks
description = ""
for md_name in ["SKILL.md", "skill.md", "prompt.md", "README.md"]:
md_file = skill_dir / md_name
if md_file.exists():
try:
content = md_file.read_text()
# Find first meaningful line
for line in content.splitlines():
line = line.strip()
if line and not line.startswith("#") and not line.startswith("<!--"):
description = line[:100]
break
if description:
break
except OSError:
pass
skills.append({
"name": skill_dir.name,
"description": description or f"Skill: {skill_dir.name}"
})
return skills
```
### IMP-2: Add skills endpoint to HttpMixin (fulfills AC-12)
**Location**: `amc_server/mixins/http.py`
```python
# In HttpMixin.do_GET, add route handling:
elif path == "/api/skills":
agent = query_params.get("agent", ["claude"])[0]
self._serve_skills(agent)
```
**Note**: Route goes in `HttpMixin.do_GET` (where all GET routing lives), not `handler.py`. The handler just composes mixins.
### IMP-3: Client-side API call (fulfills AC-12)
**Location**: `dashboard/utils/api.js`
```javascript
export const API_SKILLS = '/api/skills';
export async function fetchSkills(agent) {
const url = `${API_SKILLS}?agent=${encodeURIComponent(agent)}`;
const response = await fetch(url);
if (!response.ok) return null;
return response.json();
}
```
### IMP-4: Autocomplete config loading in Modal (fulfills AC-12)
**Location**: `dashboard/components/Modal.js`
```javascript
const [autocompleteConfig, setAutocompleteConfig] = useState(null);
// Load skills when agent type changes
useEffect(() => {
if (!session) {
setAutocompleteConfig(null);
return;
}
const agent = session.agent || 'claude';
fetchSkills(agent)
.then(config => setAutocompleteConfig(config))
.catch(() => setAutocompleteConfig(null));
}, [session?.agent]);
// Pass to SimpleInput
<${SimpleInput}
...
autocompleteConfig=${autocompleteConfig}
/>
```
### IMP-5: Trigger detection logic (fulfills AC-1, AC-2, AC-3)
**Location**: `dashboard/components/SimpleInput.js`
```javascript
// Detect if we should show autocomplete
const getTriggerInfo = useCallback((value, cursorPos) => {
if (!autocompleteConfig) return null;
const { trigger } = autocompleteConfig;
// Find the start of the current "word" (after last whitespace before cursor)
let wordStart = cursorPos;
while (wordStart > 0 && !/\s/.test(value[wordStart - 1])) {
wordStart--;
}
// Check if word starts with trigger
if (value[wordStart] === trigger) {
return {
trigger,
filterText: value.slice(wordStart + 1, cursorPos).toLowerCase(),
replaceStart: wordStart,
replaceEnd: cursorPos
};
}
return null;
}, [autocompleteConfig]);
```
### IMP-6: Filtered and sorted skills (fulfills AC-5, AC-6)
**Location**: `dashboard/components/SimpleInput.js`
```javascript
const filteredSkills = useMemo(() => {
if (!autocompleteConfig || !triggerInfo) return [];
const { skills } = autocompleteConfig;
const { filterText } = triggerInfo;
let filtered = skills;
if (filterText) {
filtered = skills.filter(s =>
s.name.toLowerCase().includes(filterText)
);
}
// Already sorted by server, but ensure alphabetical
return filtered.sort((a, b) => a.name.localeCompare(b.name));
}, [autocompleteConfig, triggerInfo]);
```
### IMP-7: Keyboard navigation (fulfills AC-7, AC-8, AC-9)
**Location**: `dashboard/components/SimpleInput.js`
**Note**: Enter with empty filter dismisses dropdown (doesn't submit message). This prevents accidental submissions when user types a partial match that has no results.
```javascript
onKeyDown=${(e) => {
if (showAutocomplete) {
// Always handle Escape when dropdown is open
if (e.key === 'Escape') {
e.preventDefault();
setShowAutocomplete(false);
return;
}
// Handle Enter/Tab: select if matches exist, otherwise dismiss (don't submit)
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
if (filteredSkills.length > 0 && filteredSkills[selectedIndex]) {
insertSkill(filteredSkills[selectedIndex]);
} else {
setShowAutocomplete(false);
}
return;
}
// Arrow navigation only when there are matches
if (filteredSkills.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex(i => Math.min(i + 1, filteredSkills.length - 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(i => Math.max(i - 1, 0));
return;
}
}
}
// Existing Enter-to-submit logic (only when dropdown is closed)
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
```
### IMP-8: Skill insertion (fulfills AC-8)
**Location**: `dashboard/components/SimpleInput.js`
```javascript
const insertSkill = useCallback((skill) => {
if (!triggerInfo || !autocompleteConfig) return;
const { trigger } = autocompleteConfig;
const { replaceStart, replaceEnd } = triggerInfo;
const before = text.slice(0, replaceStart);
const after = text.slice(replaceEnd);
const inserted = `${trigger}${skill.name} `;
setText(before + inserted + after);
setShowAutocomplete(false);
// Move cursor after inserted text
const newCursorPos = replaceStart + inserted.length;
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = newCursorPos;
textareaRef.current.selectionEnd = newCursorPos;
textareaRef.current.focus();
}
}, 0);
}, [text, triggerInfo, autocompleteConfig]);
```
### IMP-9: Autocomplete dropdown UI (fulfills AC-4, AC-10, AC-15, AC-16, AC-17, AC-18)
**Location**: `dashboard/components/SimpleInput.js`
**Note**: Uses index as `key` instead of `skill.name` to handle potential duplicate skill names (curated + user skills with same name).
```javascript
${showAutocomplete && html`
<div
ref=${autocompleteRef}
class="absolute left-0 bottom-full mb-1 w-full max-h-48 overflow-y-auto rounded-lg border border-selection/75 bg-surface shadow-lg z-50"
>
${filteredSkills.length === 0 ? html`
<div class="px-3 py-2 text-sm text-dim">No matching skills</div>
` : filteredSkills.map((skill, i) => html`
<div
key=${i}
class="px-3 py-2 cursor-pointer text-sm transition-colors ${
i === selectedIndex
? 'bg-selection/50 text-bright'
: 'text-fg hover:bg-selection/25'
}"
onClick=${() => insertSkill(skill)}
onMouseEnter=${() => setSelectedIndex(i)}
>
<div class="font-medium font-mono text-bright">
${autocompleteConfig.trigger}${skill.name}
</div>
<div class="text-micro text-dim truncate">${skill.description}</div>
</div>
`)}
</div>
`}
```
### IMP-10: Click-outside dismissal (fulfills AC-9)
**Location**: `dashboard/components/SimpleInput.js`
```javascript
useEffect(() => {
if (!showAutocomplete) return;
const handleClickOutside = (e) => {
if (autocompleteRef.current && !autocompleteRef.current.contains(e.target) &&
textareaRef.current && !textareaRef.current.contains(e.target)) {
setShowAutocomplete(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showAutocomplete]);
```
## Testing Considerations
### Manual Testing Checklist
1. Claude session: Type `/` - dropdown appears with Claude skills
2. Codex session: Type `$` - dropdown appears with Codex skills
3. Claude session: Type `$` - nothing happens (wrong trigger)
4. Type `/com` - list filters to skills containing "com"
5. Mid-message: Type "please run /commit" - autocomplete triggers on `/`
6. Arrow keys navigate, Enter selects
7. Escape dismisses without selection
8. Click outside dismisses
9. Selected skill shows as `{trigger}skill-name ` in input
10. Verify alphabetical ordering
11. Verify vertical scroll with many skills
### Edge Cases
- Session without skills (dropdown doesn't appear)
- Single skill (still shows dropdown)
- Very long skill descriptions (truncated with ellipsis)
- Multiple triggers in one message (each can trigger independently)
- Backspace over trigger (dismisses autocomplete)
## Rollout Slices
### Slice 1: Server-side skill enumeration
- Add `SkillsMixin` with `_enumerate_codex_skills()` and `_enumerate_claude_skills()`
- Add `/api/skills?agent=` endpoint in `HttpMixin.do_GET`
- Test endpoint returns correct data for each agent type
### Slice 2: Client-side skill loading
- Add `fetchSkills()` API helper
- Load skills in Modal.js on session open
- Pass `autocompleteConfig` to SimpleInput
### Slice 3: Basic autocomplete trigger
- Add trigger detection logic (position 0 + after whitespace)
- Show/hide dropdown based on trigger
- Basic filtered list display
### Slice 4: Keyboard navigation + selection
- Arrow key navigation
- Enter/Tab selection
- Escape dismissal
- Click-outside dismissal
### Slice 5: Polish
- Mouse hover to select
- Scroll into view for long lists
- Cursor positioning after insertion

910
plans/agent-spawning.md Normal file
View 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?