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:
509
PLAN-slash-autocomplete.md
Normal file
509
PLAN-slash-autocomplete.md
Normal 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
910
plans/agent-spawning.md
Normal file
@@ -0,0 +1,910 @@
|
|||||||
|
# AMC Agent Spawning via Zellij
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add the ability to spawn new agent sessions (Claude or Codex) from the AMC dashboard. Users click "New Agent" in the page header, select an agent type, and a new Zellij pane opens in a project-named tab. The spawned agent appears in the dashboard alongside existing sessions.
|
||||||
|
|
||||||
|
**Why this matters:** Currently, AMC monitors existing sessions but cannot create new ones. Users must manually open terminal panes and run `claude` or `codex`. This feature enables orchestration workflows where the dashboard becomes a control center for multi-agent coordination.
|
||||||
|
|
||||||
|
**Core insight from research:** Zellij's CLI (`zellij --session <name> action new-pane ...`) works from external processes without requiring `ZELLIJ` environment variables.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Workflows
|
||||||
|
|
||||||
|
### Workflow 1: Spawn Agent from Project Tab
|
||||||
|
|
||||||
|
**Trigger:** User is viewing a specific project tab in the dashboard sidebar, clicks "New Agent" in header.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. User is on "amc" project tab in dashboard sidebar
|
||||||
|
2. User clicks "+ New Agent" button in page header
|
||||||
|
3. Modal appears with agent type selector: Claude / Codex
|
||||||
|
4. User selects "Claude", clicks "Spawn"
|
||||||
|
5. Server finds or creates Zellij tab named "amc"
|
||||||
|
6. New pane spawns in that tab with `claude --dangerously-skip-permissions`
|
||||||
|
7. Dashboard updates: new session card appears (status: "starting")
|
||||||
|
|
||||||
|
**Key behavior:** Path is automatically determined from the selected project tab.
|
||||||
|
|
||||||
|
### Workflow 2: Spawn Agent from "All Projects" Tab
|
||||||
|
|
||||||
|
**Trigger:** User is on "All Projects" tab, clicks "New Agent" in header.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. User is on "All Projects" tab (no specific project selected)
|
||||||
|
2. User clicks "+ New Agent" button in page header
|
||||||
|
3. Modal appears with:
|
||||||
|
- Project dropdown (lists subdirectories of `~/projects/`)
|
||||||
|
- Agent type selector: Claude / Codex
|
||||||
|
4. User selects "mission-control" project, "Codex" agent type, clicks "Spawn"
|
||||||
|
5. Server finds or creates Zellij tab named "mission-control"
|
||||||
|
6. New pane spawns with `codex --dangerously-bypass-approvals-and-sandbox`
|
||||||
|
7. Dashboard updates: new session card appears
|
||||||
|
|
||||||
|
**Key behavior:** User must select a project from the dropdown when on "All Projects".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Spawn Button Location & Context
|
||||||
|
|
||||||
|
- **AC-1:** "New Agent" button is located in the page header, not on session cards
|
||||||
|
- **AC-2:** When on a specific project tab, the spawn modal does not show a project picker
|
||||||
|
- **AC-3:** When on "All Projects" tab, the spawn modal shows a project dropdown
|
||||||
|
|
||||||
|
### Project Selection (All Projects Tab)
|
||||||
|
|
||||||
|
- **AC-4:** The project dropdown lists all immediate subdirectories of `~/projects/`
|
||||||
|
- **AC-5:** Hidden directories (starting with `.`) are excluded from the dropdown
|
||||||
|
- **AC-6:** User must select a project before spawning (no default selection)
|
||||||
|
|
||||||
|
### Agent Type Selection
|
||||||
|
|
||||||
|
- **AC-7:** User can choose between Claude and Codex agent types
|
||||||
|
- **AC-8:** Claude agents spawn with full autonomous permissions enabled
|
||||||
|
- **AC-9:** Codex agents spawn with full autonomous permissions enabled
|
||||||
|
|
||||||
|
### Zellij Tab Targeting
|
||||||
|
|
||||||
|
- **AC-10:** Agents spawn in a Zellij tab named after the project (e.g., "amc" tab for amc project)
|
||||||
|
- **AC-11:** If the project-named tab does not exist, it is created before spawning
|
||||||
|
- **AC-12:** All spawns target the "infra" Zellij session
|
||||||
|
|
||||||
|
### Pane Spawning
|
||||||
|
|
||||||
|
- **AC-13:** The spawned pane's cwd is set to the project directory
|
||||||
|
- **AC-14:** Spawned panes are named `{agent_type}-{project}` (e.g., "claude-amc")
|
||||||
|
- **AC-15:** The spawned agent appears in the dashboard within 5 seconds of spawn
|
||||||
|
|
||||||
|
### Session Discovery
|
||||||
|
|
||||||
|
- **AC-16:** Spawned agent's session data includes correct `zellij_session` and `zellij_pane`
|
||||||
|
- **AC-17:** Dashboard can send responses to spawned agents (existing functionality works)
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- **AC-18:** Spawning fails gracefully if Zellij binary is not found
|
||||||
|
- **AC-19:** Spawning fails gracefully if target project directory does not exist
|
||||||
|
- **AC-20:** Spawn errors display a toast notification showing the server's error message
|
||||||
|
- **AC-21:** Network errors between dashboard and server show retry option
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **AC-22:** Server validates project path is within `~/projects/` (resolves symlinks)
|
||||||
|
- **AC-23:** Server rejects path traversal attempts in project parameter
|
||||||
|
- **AC-24:** Server binds to localhost only (127.0.0.1), not exposed to network
|
||||||
|
|
||||||
|
### Spawn Request Lifecycle
|
||||||
|
|
||||||
|
- **AC-25:** The Spawn button is disabled while a spawn request is in progress
|
||||||
|
- **AC-26:** If the target Zellij session does not exist, spawn fails with error "Zellij session 'infra' not found"
|
||||||
|
- **AC-27:** Server generates a unique `spawn_id` and passes it to the agent via `AMC_SPAWN_ID` env var
|
||||||
|
- **AC-28:** `amc-hook` writes `spawn_id` to session file when present in environment
|
||||||
|
- **AC-29:** Spawn request polls for session file containing the specific `spawn_id` (max 5 second wait)
|
||||||
|
- **AC-30:** Concurrent spawn requests are serialized via a lock to prevent Zellij race conditions
|
||||||
|
|
||||||
|
### Modal Behavior
|
||||||
|
|
||||||
|
- **AC-31:** Spawn modal can be dismissed by clicking outside, pressing Escape, or clicking Cancel
|
||||||
|
- **AC-32:** While fetching the projects list, the project dropdown displays "Loading..." and is disabled
|
||||||
|
|
||||||
|
### Projects List Caching
|
||||||
|
|
||||||
|
- **AC-33:** Projects list is loaded on server start and cached in memory
|
||||||
|
- **AC-34:** Projects list can be refreshed via `POST /api/projects/refresh`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Component Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Dashboard (Preact) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Header [+ New Agent] │ │
|
||||||
|
│ └──────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌────────────┐ ┌──────────────────────────────────────┐ │
|
||||||
|
│ │ Sidebar │ │ Main Content │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ All Proj. │ │ ┌─────────┐ ┌─────────┐ │ │
|
||||||
|
│ │ > amc │ │ │ Session │ │ Session │ │ │
|
||||||
|
│ │ > gitlore │ │ │ Card │ │ Card │ │ │
|
||||||
|
│ │ │ │ └─────────┘ └─────────┘ │ │
|
||||||
|
│ └────────────┘ └──────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ SpawnModal (context-aware) │ │
|
||||||
|
│ │ - If on project tab: just agent type picker │ │
|
||||||
|
│ │ - If on All Projects: project dropdown + agent type │ │
|
||||||
|
│ └──────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
POST /api/spawn
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ AMC Server (Python) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ SpawnMixin │ │
|
||||||
|
│ │ - list_projects() → ~/projects/* dirs │ │
|
||||||
|
│ │ - validate_spawn() → security checks │ │
|
||||||
|
│ │ - ensure_tab_exists() → create tab if needed │ │
|
||||||
|
│ │ - spawn_agent_pane() → zellij action new-pane │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
subprocess.run(["zellij", ...])
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Zellij Session: "infra" │
|
||||||
|
│ │
|
||||||
|
│ Tab: "amc" Tab: "gitlore" Tab: "work" │
|
||||||
|
│ ┌──────┬──────┐ ┌──────┐ ┌──────┐ │
|
||||||
|
│ │claude│claude│ │codex │ │nvim │ │
|
||||||
|
│ │ (1) │ (2) │ │ │ │ │ │
|
||||||
|
│ └──────┴──────┘ └──────┘ └──────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
1. **Dashboard → Server:** `POST /api/spawn` with project + agent type
|
||||||
|
2. **Server:** Acquire spawn lock (serializes concurrent requests)
|
||||||
|
3. **Server:** Validate project path is within `~/projects/` (resolve symlinks)
|
||||||
|
4. **Server:** Generate unique `spawn_id` (UUID)
|
||||||
|
5. **Server:** Check Zellij session exists (fail with SESSION_NOT_FOUND if not)
|
||||||
|
6. **Server → Zellij:** `go-to-tab-name --create <project>` (ensures tab exists)
|
||||||
|
7. **Server → Zellij:** `new-pane --cwd <path> -- <agent command>` with `AMC_SPAWN_ID` env var
|
||||||
|
8. **Zellij:** Pane created, agent process starts
|
||||||
|
9. **Agent → Hook:** `amc-hook` fires on `SessionStart`, writes session JSON including `spawn_id` from env
|
||||||
|
10. **Server:** Poll for session file containing matching `spawn_id` (up to 5 seconds)
|
||||||
|
11. **Server → Dashboard:** Return success only after session file with `spawn_id` detected
|
||||||
|
12. **Server:** Release spawn lock
|
||||||
|
|
||||||
|
### API Design
|
||||||
|
|
||||||
|
**POST /api/spawn**
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"project": "amc",
|
||||||
|
"agent_type": "claude"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response (success):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"project": "amc",
|
||||||
|
"agent_type": "claude"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response (error):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": false,
|
||||||
|
"error": "Project directory does not exist: /Users/taylor/projects/foo",
|
||||||
|
"code": "PROJECT_NOT_FOUND"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**GET /api/projects**
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"projects": ["amc", "gitlore", "mission-control", "work-ghost"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**POST /api/projects/refresh**
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"projects": ["amc", "gitlore", "mission-control", "work-ghost"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why This Architecture
|
||||||
|
|
||||||
|
1. **Server-side spawning:** Dashboard runs in browser, cannot execute shell commands.
|
||||||
|
|
||||||
|
2. **Tab-per-project organization:** Keeps agents for the same project grouped together in Zellij.
|
||||||
|
|
||||||
|
3. **`go-to-tab-name --create`:** Idempotent tab creation - creates if missing, switches if exists.
|
||||||
|
|
||||||
|
4. **Polling for discovery:** Spawned agents write their own session files via hooks. Dashboard picks them up on next poll.
|
||||||
|
|
||||||
|
### Session Discovery Mechanism
|
||||||
|
|
||||||
|
**Claude agents** write session files via the `amc-hook` Claude Code hook:
|
||||||
|
- Hook fires on `SessionStart` event
|
||||||
|
- Writes JSON to `~/.local/share/amc/sessions/{session_id}.json`
|
||||||
|
- Contains: session_id, project, status, zellij_session, zellij_pane, etc.
|
||||||
|
- **Spawn correlation:** If `AMC_SPAWN_ID` env var is set, hook includes it in session JSON
|
||||||
|
|
||||||
|
**Codex agents** are discovered dynamically by `SessionDiscoveryMixin`:
|
||||||
|
- Scans `~/.codex/sessions/` for recently-modified `.jsonl` files
|
||||||
|
- Extracts Zellij pane info via process inspection (`pgrep`, `lsof`)
|
||||||
|
- Creates/updates session JSON in `~/.local/share/amc/sessions/`
|
||||||
|
- **Spawn correlation:** Codex discovery checks for `AMC_SPAWN_ID` in process environment
|
||||||
|
|
||||||
|
**Prerequisite:** The `amc-hook` must be installed in Claude Code's hooks configuration. See `~/.claude/hooks/` or Claude Code settings.
|
||||||
|
|
||||||
|
**Why spawn_id matters:** Without deterministic correlation, polling "any new session file" could return success for unrelated agent activity. The `spawn_id` ensures the server confirms the *specific* agent it spawned is running.
|
||||||
|
|
||||||
|
### Integration with Existing Code
|
||||||
|
|
||||||
|
| New Code | Integrates With | How |
|
||||||
|
|----------|-----------------|-----|
|
||||||
|
| SpawnMixin | HttpMixin | Uses `_send_json()`, `_json_error()` |
|
||||||
|
| SpawnModal | Modal.js | Follows same patterns (escape, scroll lock, animations) |
|
||||||
|
| SpawnModal | api.js | Uses `fetchWithTimeout`, API constants |
|
||||||
|
| Toast calls | Toast.js | Uses existing `showToast(msg, type, duration)` |
|
||||||
|
| PROJECTS_DIR | context.py | Added alongside other path constants |
|
||||||
|
| Session polling | SESSIONS_DIR | Watches same directory as discovery mixins |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Specifications
|
||||||
|
|
||||||
|
### IMP-0: Add Constants to context.py
|
||||||
|
|
||||||
|
**File:** `amc_server/context.py`
|
||||||
|
|
||||||
|
Add after existing path constants:
|
||||||
|
```python
|
||||||
|
# Projects directory for spawning agents
|
||||||
|
PROJECTS_DIR = Path.home() / "projects"
|
||||||
|
|
||||||
|
# Default Zellij session for spawning
|
||||||
|
ZELLIJ_SESSION = "infra"
|
||||||
|
|
||||||
|
# Lock for serializing spawn operations (prevents Zellij race conditions)
|
||||||
|
_spawn_lock = threading.Lock()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### IMP-1: SpawnMixin for Server (fulfills AC-8, AC-9, AC-10, AC-11, AC-12, AC-13, AC-14, AC-18, AC-19, AC-22, AC-23, AC-25, AC-26, AC-29, AC-30)
|
||||||
|
|
||||||
|
**File:** `amc_server/mixins/spawn.py`
|
||||||
|
|
||||||
|
**Integration notes:**
|
||||||
|
- Uses `_send_json()` from HttpMixin (not a new `_json_response`)
|
||||||
|
- Uses inline JSON body parsing (same pattern as `control.py:33-47`)
|
||||||
|
- PROJECTS_DIR and ZELLIJ_SESSION come from context.py (centralized constants)
|
||||||
|
- Session file polling watches SESSIONS_DIR for any new .json by mtime
|
||||||
|
|
||||||
|
```python
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
from amc_server.context import PROJECTS_DIR, SESSIONS_DIR, ZELLIJ_BIN, ZELLIJ_SESSION
|
||||||
|
|
||||||
|
# Agent commands (AC-8, AC-9: full autonomous permissions)
|
||||||
|
AGENT_COMMANDS = {
|
||||||
|
"claude": ["claude", "--dangerously-skip-permissions"],
|
||||||
|
"codex": ["codex", "--dangerously-bypass-approvals-and-sandbox"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Module-level cache for projects list (AC-29)
|
||||||
|
_projects_cache: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def load_projects_cache():
|
||||||
|
"""Scan ~/projects/ and cache the list. Called on server start."""
|
||||||
|
global _projects_cache
|
||||||
|
try:
|
||||||
|
projects = []
|
||||||
|
for entry in PROJECTS_DIR.iterdir():
|
||||||
|
if entry.is_dir() and not entry.name.startswith("."):
|
||||||
|
projects.append(entry.name)
|
||||||
|
projects.sort()
|
||||||
|
_projects_cache = projects
|
||||||
|
except OSError:
|
||||||
|
_projects_cache = []
|
||||||
|
|
||||||
|
|
||||||
|
class SpawnMixin:
|
||||||
|
def _handle_spawn(self):
|
||||||
|
"""Handle POST /api/spawn"""
|
||||||
|
# Read JSON body (same pattern as control.py)
|
||||||
|
try:
|
||||||
|
content_length = int(self.headers.get("Content-Length", 0))
|
||||||
|
body = json.loads(self.rfile.read(content_length))
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
self._json_error(400, "Invalid JSON body")
|
||||||
|
return
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
self._json_error(400, "Invalid JSON body")
|
||||||
|
return
|
||||||
|
|
||||||
|
project = body.get("project", "").strip()
|
||||||
|
agent_type = body.get("agent_type", "claude").strip()
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
error = self._validate_spawn_params(project, agent_type)
|
||||||
|
if error:
|
||||||
|
self._send_json(400, {"ok": False, "error": error["message"], "code": error["code"]})
|
||||||
|
return
|
||||||
|
|
||||||
|
project_path = PROJECTS_DIR / project
|
||||||
|
|
||||||
|
# Ensure tab exists, then spawn pane, then wait for session file
|
||||||
|
result = self._spawn_agent_in_project_tab(project, project_path, agent_type)
|
||||||
|
|
||||||
|
if result["ok"]:
|
||||||
|
self._send_json(200, {"ok": True, "project": project, "agent_type": agent_type})
|
||||||
|
else:
|
||||||
|
self._send_json(500, {"ok": False, "error": result["error"], "code": result.get("code", "SPAWN_FAILED")})
|
||||||
|
|
||||||
|
def _validate_spawn_params(self, project, agent_type):
|
||||||
|
"""Validate spawn parameters. Returns error dict or None."""
|
||||||
|
if not project:
|
||||||
|
return {"message": "project is required", "code": "MISSING_PROJECT"}
|
||||||
|
|
||||||
|
# Security: no path traversal
|
||||||
|
if "/" in project or "\\" in project or ".." in project:
|
||||||
|
return {"message": "Invalid project name", "code": "INVALID_PROJECT"}
|
||||||
|
|
||||||
|
# Project must exist
|
||||||
|
project_path = PROJECTS_DIR / project
|
||||||
|
if not project_path.is_dir():
|
||||||
|
return {"message": f"Project not found: {project}", "code": "PROJECT_NOT_FOUND"}
|
||||||
|
|
||||||
|
# Agent type must be valid
|
||||||
|
if agent_type not in AGENT_COMMANDS:
|
||||||
|
return {"message": f"Invalid agent type: {agent_type}", "code": "INVALID_AGENT_TYPE"}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _check_zellij_session_exists(self):
|
||||||
|
"""Check if the target Zellij session exists (AC-25)."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[ZELLIJ_BIN, "list-sessions"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
return ZELLIJ_SESSION in result.stdout
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _wait_for_session_file(self, timeout=5.0):
|
||||||
|
"""Poll for any new session file in SESSIONS_DIR (AC-26).
|
||||||
|
|
||||||
|
Session files are named {session_id}.json. We don't know the session_id
|
||||||
|
in advance, so we watch for any .json file with mtime after spawn started.
|
||||||
|
"""
|
||||||
|
start = time.monotonic()
|
||||||
|
# Snapshot existing files to detect new ones
|
||||||
|
existing_files = set()
|
||||||
|
if SESSIONS_DIR.exists():
|
||||||
|
existing_files = {f.name for f in SESSIONS_DIR.glob("*.json")}
|
||||||
|
|
||||||
|
while time.monotonic() - start < timeout:
|
||||||
|
if SESSIONS_DIR.exists():
|
||||||
|
for f in SESSIONS_DIR.glob("*.json"):
|
||||||
|
# New file that didn't exist before spawn
|
||||||
|
if f.name not in existing_files:
|
||||||
|
return True
|
||||||
|
# Or existing file with very recent mtime (reused session)
|
||||||
|
if f.stat().st_mtime > start:
|
||||||
|
return True
|
||||||
|
time.sleep(0.25)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _spawn_agent_in_project_tab(self, project, project_path, agent_type):
|
||||||
|
"""Ensure project tab exists and spawn agent pane."""
|
||||||
|
try:
|
||||||
|
# Step 0: Check session exists (AC-25)
|
||||||
|
if not self._check_zellij_session_exists():
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": f"Zellij session '{ZELLIJ_SESSION}' not found",
|
||||||
|
"code": "SESSION_NOT_FOUND"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 1: Go to or create the project tab
|
||||||
|
tab_result = subprocess.run(
|
||||||
|
[ZELLIJ_BIN, "--session", ZELLIJ_SESSION, "action", "go-to-tab-name", "--create", project],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
if tab_result.returncode != 0:
|
||||||
|
return {"ok": False, "error": f"Failed to create/switch tab: {tab_result.stderr}", "code": "TAB_ERROR"}
|
||||||
|
|
||||||
|
# Step 2: Spawn new pane with agent command (AC-14: naming scheme)
|
||||||
|
agent_cmd = AGENT_COMMANDS[agent_type]
|
||||||
|
pane_name = f"{agent_type}-{project}"
|
||||||
|
|
||||||
|
spawn_cmd = [
|
||||||
|
ZELLIJ_BIN, "--session", ZELLIJ_SESSION, "action", "new-pane",
|
||||||
|
"--name", pane_name,
|
||||||
|
"--cwd", str(project_path),
|
||||||
|
"--",
|
||||||
|
*agent_cmd
|
||||||
|
]
|
||||||
|
|
||||||
|
spawn_result = subprocess.run(
|
||||||
|
spawn_cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
if spawn_result.returncode != 0:
|
||||||
|
return {"ok": False, "error": f"Failed to spawn pane: {spawn_result.stderr}", "code": "SPAWN_ERROR"}
|
||||||
|
|
||||||
|
# Step 3: Wait for session file (AC-26)
|
||||||
|
if not self._wait_for_session_file(timeout=5.0):
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "Agent spawned but session file not detected within 5 seconds",
|
||||||
|
"code": "SESSION_FILE_TIMEOUT"
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"ok": False, "error": f"zellij not found at {ZELLIJ_BIN}", "code": "ZELLIJ_NOT_FOUND"}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {"ok": False, "error": "zellij command timed out", "code": "TIMEOUT"}
|
||||||
|
|
||||||
|
def _handle_projects(self):
|
||||||
|
"""Handle GET /api/projects - return cached projects list (AC-29)."""
|
||||||
|
self._send_json(200, {"projects": _projects_cache})
|
||||||
|
|
||||||
|
def _handle_projects_refresh(self):
|
||||||
|
"""Handle POST /api/projects/refresh - refresh cache (AC-30)."""
|
||||||
|
load_projects_cache()
|
||||||
|
self._send_json(200, {"ok": True, "projects": _projects_cache})
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-2: HTTP Routing (fulfills AC-1, AC-3, AC-4, AC-30)
|
||||||
|
|
||||||
|
**File:** `amc_server/mixins/http.py`
|
||||||
|
|
||||||
|
Add to `do_GET`:
|
||||||
|
```python
|
||||||
|
elif self.path == "/api/projects":
|
||||||
|
self._handle_projects()
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to `do_POST`:
|
||||||
|
```python
|
||||||
|
elif self.path == "/api/spawn":
|
||||||
|
self._handle_spawn()
|
||||||
|
elif self.path == "/api/projects/refresh":
|
||||||
|
self._handle_projects_refresh()
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `do_OPTIONS` for CORS preflight on new endpoints:
|
||||||
|
```python
|
||||||
|
def do_OPTIONS(self):
|
||||||
|
# CORS preflight for API endpoints
|
||||||
|
self.send_response(204)
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
self.end_headers()
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-2b: Server Startup (fulfills AC-29)
|
||||||
|
|
||||||
|
**File:** `amc_server/server.py`
|
||||||
|
|
||||||
|
Add to server initialization:
|
||||||
|
```python
|
||||||
|
from amc_server.mixins.spawn import load_projects_cache
|
||||||
|
|
||||||
|
# In server startup, before starting HTTP server:
|
||||||
|
load_projects_cache()
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-2c: API Constants (follows existing pattern)
|
||||||
|
|
||||||
|
**File:** `dashboard/utils/api.js`
|
||||||
|
|
||||||
|
Add to existing exports:
|
||||||
|
```javascript
|
||||||
|
// Spawn API endpoints
|
||||||
|
export const API_SPAWN = '/api/spawn';
|
||||||
|
export const API_PROJECTS = '/api/projects';
|
||||||
|
export const API_PROJECTS_REFRESH = '/api/projects/refresh';
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-3: Handler Integration (fulfills AC-2, AC-3)
|
||||||
|
|
||||||
|
**File:** `amc_server/handler.py`
|
||||||
|
|
||||||
|
Add SpawnMixin to handler inheritance chain:
|
||||||
|
```python
|
||||||
|
from amc_server.mixins.spawn import SpawnMixin
|
||||||
|
|
||||||
|
class AMCHandler(
|
||||||
|
HttpMixin,
|
||||||
|
StateMixin,
|
||||||
|
ConversationMixin,
|
||||||
|
SessionControlMixin,
|
||||||
|
SessionDiscoveryMixin,
|
||||||
|
SessionParsingMixin,
|
||||||
|
SpawnMixin, # Add this
|
||||||
|
BaseHTTPRequestHandler,
|
||||||
|
):
|
||||||
|
"""HTTP handler composed from focused mixins."""
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-4: SpawnModal Component (fulfills AC-2, AC-3, AC-6, AC-7, AC-20, AC-24, AC-27, AC-28)
|
||||||
|
|
||||||
|
**File:** `dashboard/components/SpawnModal.js`
|
||||||
|
|
||||||
|
**Integration notes:**
|
||||||
|
- Uses `fetchWithTimeout` and API constants from `api.js` (consistent with codebase)
|
||||||
|
- Follows `Modal.js` patterns: escape key, click-outside, body scroll lock, animated close
|
||||||
|
- Uses `html` tagged template (Preact pattern used throughout dashboard)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { html, useState, useEffect, useCallback } from '../lib/preact.js';
|
||||||
|
import { API_PROJECTS, API_SPAWN, fetchWithTimeout } from '../utils/api.js';
|
||||||
|
|
||||||
|
export function SpawnModal({ isOpen, onClose, onSpawn, currentProject }) {
|
||||||
|
const [projects, setProjects] = useState([]);
|
||||||
|
const [selectedProject, setSelectedProject] = useState('');
|
||||||
|
const [agentType, setAgentType] = useState('claude');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||||
|
const [closing, setClosing] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const needsProjectPicker = !currentProject;
|
||||||
|
|
||||||
|
// Animated close handler (matches Modal.js pattern)
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (loading) return;
|
||||||
|
setClosing(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setClosing(false);
|
||||||
|
onClose();
|
||||||
|
}, 200);
|
||||||
|
}, [loading, onClose]);
|
||||||
|
|
||||||
|
// Body scroll lock (matches Modal.js pattern)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => { document.body.style.overflow = ''; };
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Escape key to close (matches Modal.js pattern)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Escape') handleClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, handleClose]);
|
||||||
|
|
||||||
|
// Fetch projects when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && needsProjectPicker) {
|
||||||
|
setLoadingProjects(true);
|
||||||
|
fetchWithTimeout(API_PROJECTS)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
setProjects(data.projects || []);
|
||||||
|
setSelectedProject('');
|
||||||
|
})
|
||||||
|
.catch(err => setError(err.message))
|
||||||
|
.finally(() => setLoadingProjects(false));
|
||||||
|
}
|
||||||
|
}, [isOpen, needsProjectPicker]);
|
||||||
|
|
||||||
|
// Reset state when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setAgentType('claude');
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
setClosing(false);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleSpawn = async () => {
|
||||||
|
const project = currentProject || selectedProject;
|
||||||
|
if (!project) {
|
||||||
|
setError('Please select a project');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithTimeout(API_SPAWN, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ project, agent_type: agentType })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.ok) {
|
||||||
|
onSpawn({ success: true, project, agentType });
|
||||||
|
handleClose();
|
||||||
|
} else {
|
||||||
|
setError(data.error || 'Spawn failed');
|
||||||
|
onSpawn({ error: data.error });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.name === 'AbortError' ? 'Request timed out' : err.message;
|
||||||
|
setError(msg);
|
||||||
|
onSpawn({ error: msg });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-[#02050d]/84 p-4 backdrop-blur-sm ${closing ? 'modal-backdrop-out' : 'modal-backdrop-in'}"
|
||||||
|
onClick=${(e) => e.target === e.currentTarget && handleClose()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="glass-panel w-full max-w-md rounded-2xl p-6 ${closing ? 'modal-panel-out' : 'modal-panel-in'}"
|
||||||
|
onClick=${(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 class="mb-4 font-display text-lg font-semibold text-bright">New Agent</h2>
|
||||||
|
|
||||||
|
${needsProjectPicker && html`
|
||||||
|
<label class="mb-4 block">
|
||||||
|
<span class="mb-1 block text-sm text-dim">Project</span>
|
||||||
|
<select
|
||||||
|
class="w-full rounded-lg border border-selection/50 bg-surface px-3 py-2 text-bright"
|
||||||
|
value=${selectedProject}
|
||||||
|
onChange=${(e) => setSelectedProject(e.target.value)}
|
||||||
|
disabled=${loadingProjects}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
${loadingProjects ? 'Loading...' : 'Select a project...'}
|
||||||
|
</option>
|
||||||
|
${projects.map(p => html`
|
||||||
|
<option key=${p} value=${p}>${p}</option>
|
||||||
|
`)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
`}
|
||||||
|
|
||||||
|
${!needsProjectPicker && html`
|
||||||
|
<p class="mb-4 text-sm text-dim">
|
||||||
|
Project: <span class="font-medium text-bright">${currentProject}</span>
|
||||||
|
</p>
|
||||||
|
`}
|
||||||
|
|
||||||
|
<label class="mb-4 block">
|
||||||
|
<span class="mb-2 block text-sm text-dim">Agent Type</span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
agentType === 'claude'
|
||||||
|
? 'border-active bg-active/20 text-active'
|
||||||
|
: 'border-selection/50 text-dim hover:border-selection hover:text-bright'
|
||||||
|
}"
|
||||||
|
onClick=${() => setAgentType('claude')}
|
||||||
|
>
|
||||||
|
Claude
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 rounded-lg border px-4 py-2 text-sm font-medium transition-colors ${
|
||||||
|
agentType === 'codex'
|
||||||
|
? 'border-active bg-active/20 text-active'
|
||||||
|
: 'border-selection/50 text-dim hover:border-selection hover:text-bright'
|
||||||
|
}"
|
||||||
|
onClick=${() => setAgentType('codex')}
|
||||||
|
>
|
||||||
|
Codex
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
${error && html`
|
||||||
|
<p class="mb-4 rounded-lg border border-attention/50 bg-attention/10 px-3 py-2 text-sm text-attention">
|
||||||
|
${error}
|
||||||
|
</p>
|
||||||
|
`}
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border border-selection/50 px-4 py-2 text-sm text-dim transition-colors hover:border-selection hover:text-bright"
|
||||||
|
onClick=${handleClose}
|
||||||
|
disabled=${loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-active px-4 py-2 text-sm font-medium text-surface transition-opacity disabled:opacity-50"
|
||||||
|
onClick=${handleSpawn}
|
||||||
|
disabled=${loading || (needsProjectPicker && !selectedProject)}
|
||||||
|
>
|
||||||
|
${loading ? 'Spawning...' : 'Spawn'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-5: Header New Agent Button (fulfills AC-1)
|
||||||
|
|
||||||
|
**File:** `dashboard/components/App.js`
|
||||||
|
|
||||||
|
**Integration points:**
|
||||||
|
1. Add import for SpawnModal
|
||||||
|
2. Add state for modal visibility
|
||||||
|
3. Add button to existing inline header (lines 331-380)
|
||||||
|
4. Add SpawnModal component at end of render
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Add import at top
|
||||||
|
import { SpawnModal } from './SpawnModal.js';
|
||||||
|
|
||||||
|
// Add state in App component (around line 14)
|
||||||
|
const [spawnModalOpen, setSpawnModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Add button to existing header section (inside the flex container, around line 341)
|
||||||
|
// After the status summary chips div, add:
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-active/40 bg-active/12 px-3 py-2 text-sm font-medium text-active transition-colors hover:bg-active/20"
|
||||||
|
onClick=${() => setSpawnModalOpen(true)}
|
||||||
|
>
|
||||||
|
+ New Agent
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// Add modal before closing fragment (after ToastContainer, around line 426)
|
||||||
|
<${SpawnModal}
|
||||||
|
isOpen=${spawnModalOpen}
|
||||||
|
onClose=${() => setSpawnModalOpen(false)}
|
||||||
|
onSpawn=${handleSpawnResult}
|
||||||
|
currentProject=${selectedProject}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### IMP-6: Toast Notifications for Spawn Results (fulfills AC-20, AC-21)
|
||||||
|
|
||||||
|
**File:** `dashboard/components/App.js`
|
||||||
|
|
||||||
|
**Integration note:** Uses existing `showToast(message, type, duration)` signature from `Toast.js`.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { showToast } from './Toast.js'; // Already imported in App.js
|
||||||
|
|
||||||
|
// Add this callback in App component
|
||||||
|
const handleSpawnResult = useCallback((result) => {
|
||||||
|
if (result.success) {
|
||||||
|
// showToast(message, type, duration) - matches Toast.js signature
|
||||||
|
showToast(`${result.agentType} agent spawned for ${result.project}`, 'success');
|
||||||
|
} else if (result.error) {
|
||||||
|
showToast(result.error, 'error');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollout Slices
|
||||||
|
|
||||||
|
### Slice 1: Server-Side Spawning (Backend Only)
|
||||||
|
|
||||||
|
**Goal:** Spawn agents via curl/API without UI.
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
1. Create `SpawnMixin` with `_handle_spawn()`, `_validate_spawn_params()`, `_spawn_agent_in_project_tab()`
|
||||||
|
2. Create `_handle_projects()` for listing `~/projects/` subdirectories
|
||||||
|
3. Add `/api/spawn` (POST) and `/api/projects` (GET) routes to HTTP handler
|
||||||
|
4. Add SpawnMixin to handler inheritance chain
|
||||||
|
5. Write tests for spawn validation and subprocess calls
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
```bash
|
||||||
|
# List projects
|
||||||
|
curl http://localhost:7400/api/projects
|
||||||
|
|
||||||
|
# Spawn claude agent
|
||||||
|
curl -X POST http://localhost:7400/api/spawn \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"project":"amc","agent_type":"claude"}'
|
||||||
|
|
||||||
|
# Spawn codex agent
|
||||||
|
curl -X POST http://localhost:7400/api/spawn \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"project":"gitlore","agent_type":"codex"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**ACs covered:** AC-4, AC-5, AC-8, AC-9, AC-10, AC-11, AC-12, AC-13, AC-14, AC-18, AC-19, AC-22, AC-23, AC-25, AC-26, AC-29, AC-30
|
||||||
|
|
||||||
|
### Slice 2: Spawn Modal UI
|
||||||
|
|
||||||
|
**Goal:** Complete UI for spawning agents.
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
1. Create `SpawnModal` component with context-aware behavior
|
||||||
|
2. Add "+ New Agent" button to page header
|
||||||
|
3. Pass `currentProject` from sidebar selection to modal
|
||||||
|
4. Implement agent type toggle (Claude / Codex)
|
||||||
|
5. Wire up project dropdown (only shown on "All Projects")
|
||||||
|
6. Add loading and error states
|
||||||
|
7. Show toast on spawn result
|
||||||
|
8. Implement modal dismiss behavior (Escape, click-outside, Cancel)
|
||||||
|
|
||||||
|
**ACs covered:** AC-1, AC-2, AC-3, AC-6, AC-7, AC-20, AC-21, AC-24, AC-27, AC-28
|
||||||
|
|
||||||
|
### Slice 3: Polish & Edge Cases
|
||||||
|
|
||||||
|
**Goal:** Handle edge cases and improve UX.
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
1. Handle case where `~/projects/` doesn't exist or is empty
|
||||||
|
2. Add visual feedback when agent appears in dashboard after spawn
|
||||||
|
3. Test with projects that have special characters in name
|
||||||
|
4. Ensure spawned agents' hooks write correct metadata
|
||||||
|
|
||||||
|
**ACs covered:** AC-15, AC-16, AC-17
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Rate limiting:** Should we limit spawn frequency to prevent accidental spam?
|
||||||
|
|
||||||
|
2. **Session cleanup:** When a spawned agent exits, should dashboard offer to close the pane?
|
||||||
|
|
||||||
|
3. **Multiple Zellij sessions:** Currently hardcoded to "infra". Future: detect or let user pick?
|
||||||
|
|
||||||
|
4. **Agent naming:** Current scheme is `{agent_type}-{project}`. Collision if multiple agents for same project?
|
||||||
|
|
||||||
|
5. **Spawn limits:** Should we add spawn limits or warnings for resource management?
|
||||||
|
|
||||||
|
6. **Dead code cleanup:** `Header.js` exists but isn't used (App.js has inline header). Remove it?
|
||||||
|
|
||||||
|
7. **Hook verification:** Should spawn endpoint verify `amc-hook` is installed before spawning Claude agents?
|
||||||
Reference in New Issue
Block a user