Closes bd-3ny. Added mousedown listener that dismisses the dropdown when clicking outside both the dropdown and textarea. Uses early return to avoid registering listeners when dropdown is already closed.
1632 lines
46 KiB
Markdown
1632 lines
46 KiB
Markdown
# Subagent & Agent Team Visibility for AMC
|
|
|
|
> **Version**: 1.0
|
|
> **Status**: Draft
|
|
> **Last Updated**: 2026-02-26
|
|
|
|
## Summary
|
|
|
|
Add comprehensive visibility into Claude Code subagents and agent teams within AMC. This enables users to monitor, understand, and debug multi-agent workflows by surfacing subagent activity, team coordination, and task progress directly in the dashboard.
|
|
|
|
---
|
|
|
|
## Problem Statement
|
|
|
|
Claude Code supports two multi-agent patterns that are currently invisible to AMC:
|
|
|
|
1. **Subagents** (via Task tool): Lightweight, isolated workers that execute in parallel and report results back to a parent session. Their conversations are stored in separate JSONL files but never surfaced.
|
|
|
|
2. **Agent Teams**: Full independent sessions with bidirectional messaging, shared task lists, and coordinated work. Team state lives in separate directories but is never parsed.
|
|
|
|
Without visibility into these, users cannot:
|
|
- See what subagents are doing in real-time
|
|
- Understand which tasks are being worked on by which agents
|
|
- Debug multi-agent coordination failures
|
|
- Monitor team member status and progress
|
|
- Trace the parent-child relationships in complex workflows
|
|
|
|
---
|
|
|
|
## User Workflows
|
|
|
|
### Workflow 1: Monitor Active Subagents
|
|
|
|
**Actor**: User with a Claude Code session that spawned subagents
|
|
|
|
**Flow**:
|
|
1. User views session card in AMC dashboard
|
|
2. Dashboard shows "3 subagents active" indicator
|
|
3. User clicks to expand subagent panel
|
|
4. Each subagent shows: type, status (running/complete), duration, last activity
|
|
5. User clicks a subagent to view its conversation in a modal
|
|
6. Real-time updates show subagent progress without refresh
|
|
|
|
### Workflow 2: Team Dashboard
|
|
|
|
**Actor**: User running an agent team
|
|
|
|
**Flow**:
|
|
1. AMC discovers active team from `~/.claude/teams/`
|
|
2. Dashboard shows team card with: team name, description, member count
|
|
3. User expands to see all team members: name, type, model, status
|
|
4. Task list panel shows: tasks by status (pending/in_progress/completed), assignments, dependencies
|
|
5. User can view any teammate's conversation
|
|
6. Real-time updates show task transitions and team messaging
|
|
|
|
### Workflow 3: Trace Parent-Child Relationships
|
|
|
|
**Actor**: User debugging a multi-agent workflow
|
|
|
|
**Flow**:
|
|
1. User views a subagent's conversation
|
|
2. UI shows "Spawned by: [parent session]" with link
|
|
3. User can navigate between parent and child conversations
|
|
4. Parent conversation shows inline markers: "[Spawned: code-reviewer → completed 2m ago]"
|
|
5. User understands the full workflow lineage
|
|
|
|
---
|
|
|
|
## Acceptance Criteria
|
|
|
|
### Subagent Discovery
|
|
|
|
- **AC-1**: AMC discovers subagent JSONL files in `{session_dir}/subagents/agent-*.jsonl`
|
|
- **AC-2**: Subagent metadata (agentId, type, status) is extracted from JSONL headers
|
|
- **AC-3**: Subagents are associated with their parent session via shared `sessionId`
|
|
- **AC-4**: Subagent discovery runs during state collection (existing poll interval)
|
|
|
|
### Subagent Display
|
|
|
|
- **AC-5**: Session cards show subagent count badge when subagents exist
|
|
- **AC-6**: Expandable panel lists all subagents with: type, status, duration, last activity
|
|
- **AC-7**: Clicking a subagent opens its conversation in a modal
|
|
- **AC-8**: Subagent conversation parsing reuses existing `_parse_claude_conversation` logic
|
|
|
|
### Subagent Status
|
|
|
|
- **AC-9**: Running subagents show animated activity indicator
|
|
- **AC-10**: Completed subagents show completion status with final message summary
|
|
- **AC-11**: Subagent status is derived from: file mtime recency, presence of stop markers
|
|
|
|
### Team Discovery
|
|
|
|
- **AC-12**: AMC discovers teams from `~/.claude/teams/*/config.json`
|
|
- **AC-13**: Team config is parsed: name, description, leadAgentId, members[]
|
|
- **AC-14**: Team tasks are discovered from `~/.claude/tasks/{team_name}/`
|
|
- **AC-15**: Team member sessions are linked to their corresponding session files
|
|
|
|
### Team Display
|
|
|
|
- **AC-16**: Teams appear as distinct cards in the dashboard (separate from individual sessions)
|
|
- **AC-17**: Team card shows: name, description, member count, active task count
|
|
- **AC-18**: Expanded team view shows member list with: name, type, model, status
|
|
- **AC-19**: Task panel shows tasks grouped by status with assignment indicators
|
|
|
|
### Parent-Child Linking
|
|
|
|
- **AC-20**: Subagent conversations show "Spawned by: [parent]" header with navigation link
|
|
- **AC-21**: Parent conversations show inline markers for spawned subagents with status
|
|
- **AC-22**: Users can navigate bidirectionally between parent and child conversations
|
|
|
|
### Real-Time Updates
|
|
|
|
- **AC-23**: Subagent status updates in real-time via existing SSE/polling mechanism
|
|
- **AC-24**: Team state changes (task status, member joins) propagate without refresh
|
|
- **AC-25**: File mtime tracking extends to subagent and team state files
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
### Data Model Extensions
|
|
|
|
```python
|
|
# Existing Session model gains new fields:
|
|
{
|
|
"session_id": "uuid",
|
|
"agent": "claude",
|
|
# ... existing fields ...
|
|
|
|
# NEW: Subagent information
|
|
"subagents": [
|
|
{
|
|
"agent_id": "a510a6b",
|
|
"subagent_type": "Explore", # from Task tool input
|
|
"status": "running|completed",
|
|
"started_at": "iso-timestamp",
|
|
"completed_at": "iso-timestamp|null",
|
|
"last_activity_at": "iso-timestamp",
|
|
"message_count": 42,
|
|
"transcript_path": "/path/to/agent-xxx.jsonl"
|
|
}
|
|
],
|
|
|
|
# NEW: Team membership (if this session is a team member)
|
|
"team": {
|
|
"name": "mission-control",
|
|
"role": "lead|member",
|
|
"member_name": "team-lead" # from team config
|
|
}
|
|
}
|
|
|
|
# NEW: Team model (separate from sessions)
|
|
{
|
|
"type": "team",
|
|
"name": "mission-control",
|
|
"description": "Building Mission Control...",
|
|
"lead_agent_id": "team-lead@mission-control",
|
|
"lead_session_id": "uuid",
|
|
"members": [
|
|
{
|
|
"agent_id": "team-lead@mission-control",
|
|
"name": "team-lead",
|
|
"agent_type": "team-lead",
|
|
"model": "claude-opus-4-5-20251101",
|
|
"status": "active|idle|offline",
|
|
"session_id": "uuid|null" # linked to session if found
|
|
}
|
|
],
|
|
"tasks": {
|
|
"pending": 5,
|
|
"in_progress": 2,
|
|
"completed": 12,
|
|
"items": [...] # optional: full task list
|
|
},
|
|
"created_at": "iso-timestamp",
|
|
"config_mtime_ns": 123456789
|
|
}
|
|
```
|
|
|
|
### File System Locations
|
|
|
|
| Data Type | Path Pattern |
|
|
|-----------|--------------|
|
|
| Parent session | `~/.claude/projects/{project}/{session_id}.jsonl` |
|
|
| Subagent transcript | `~/.claude/projects/{project}/{session_id}/subagents/agent-{agentId}.jsonl` |
|
|
| Team config | `~/.claude/teams/{team_name}/config.json` |
|
|
| Team inboxes | `~/.claude/teams/{team_name}/inboxes/` |
|
|
| Team tasks | `~/.claude/tasks/{team_name}/` |
|
|
|
|
### Parsing Logic
|
|
|
|
#### Subagent Discovery (in StateMixin)
|
|
|
|
```python
|
|
def _discover_subagents(self, session_id, project_dir):
|
|
"""Discover subagent JSONL files for a session."""
|
|
subagents = []
|
|
session_dir = self._get_session_dir(session_id, project_dir)
|
|
subagents_dir = session_dir / "subagents"
|
|
|
|
if not subagents_dir.exists():
|
|
return subagents
|
|
|
|
for jsonl_file in subagents_dir.glob("agent-*.jsonl"):
|
|
# Extract agent_id from filename: agent-{agentId}.jsonl
|
|
agent_id = jsonl_file.stem.replace("agent-", "")
|
|
|
|
# Parse first entry for metadata
|
|
metadata = self._parse_subagent_metadata(jsonl_file)
|
|
|
|
subagents.append({
|
|
"agent_id": agent_id,
|
|
"subagent_type": metadata.get("subagent_type", "unknown"),
|
|
"status": self._determine_subagent_status(jsonl_file),
|
|
"started_at": metadata.get("started_at"),
|
|
"last_activity_at": self._get_file_mtime_iso(jsonl_file),
|
|
"message_count": metadata.get("message_count", 0),
|
|
"transcript_path": str(jsonl_file),
|
|
})
|
|
|
|
return subagents
|
|
```
|
|
|
|
#### Subagent Type Extraction
|
|
|
|
The subagent type is found in the parent session's Task tool invocation:
|
|
|
|
```python
|
|
def _extract_subagent_type_from_parent(self, parent_jsonl, agent_id):
|
|
"""Extract subagent_type from parent's Task tool call that spawned this agent."""
|
|
for line in parent_jsonl.read_text().splitlines():
|
|
entry = json.loads(line)
|
|
|
|
# Look for progress entries with this agent's ID
|
|
if entry.get("type") == "progress":
|
|
data = entry.get("data", {})
|
|
if data.get("agentId") == agent_id:
|
|
# The parentToolUseID links to the Task tool invocation
|
|
parent_tool_use_id = entry.get("parentToolUseID")
|
|
# Now find the Task tool_use with that ID to get subagent_type
|
|
...
|
|
```
|
|
|
|
#### Team Discovery (new mixin: TeamMixin)
|
|
|
|
```python
|
|
class TeamMixin:
|
|
TEAMS_DIR = Path.home() / ".claude" / "teams"
|
|
TASKS_DIR = Path.home() / ".claude" / "tasks"
|
|
|
|
def _discover_teams(self):
|
|
"""Discover all active teams."""
|
|
teams = []
|
|
|
|
if not self.TEAMS_DIR.exists():
|
|
return teams
|
|
|
|
for team_dir in self.TEAMS_DIR.iterdir():
|
|
if not team_dir.is_dir():
|
|
continue
|
|
|
|
config_file = team_dir / "config.json"
|
|
if not config_file.exists():
|
|
continue
|
|
|
|
try:
|
|
config = json.loads(config_file.read_text())
|
|
team = self._build_team_model(config, team_dir.name)
|
|
teams.append(team)
|
|
except (json.JSONDecodeError, OSError):
|
|
continue
|
|
|
|
return teams
|
|
|
|
def _build_team_model(self, config, team_name):
|
|
"""Build team model from config.json."""
|
|
members = []
|
|
for m in config.get("members", []):
|
|
members.append({
|
|
"agent_id": m.get("agentId"),
|
|
"name": m.get("name"),
|
|
"agent_type": m.get("agentType"),
|
|
"model": m.get("model"),
|
|
"status": self._determine_member_status(m),
|
|
"session_id": self._find_member_session(m),
|
|
})
|
|
|
|
return {
|
|
"type": "team",
|
|
"name": config.get("name", team_name),
|
|
"description": config.get("description", ""),
|
|
"lead_agent_id": config.get("leadAgentId"),
|
|
"lead_session_id": config.get("leadSessionId"),
|
|
"members": members,
|
|
"tasks": self._load_team_tasks(team_name),
|
|
"created_at": self._timestamp_to_iso(config.get("createdAt")),
|
|
}
|
|
```
|
|
|
|
### API Extensions
|
|
|
|
```
|
|
GET /api/state
|
|
Response adds:
|
|
{
|
|
"sessions": [...], // existing, now includes subagents[]
|
|
"teams": [...], // NEW: active teams
|
|
"server_time": "..."
|
|
}
|
|
|
|
GET /api/conversation/{session_id}
|
|
Existing endpoint works for subagents too (by agent_id)
|
|
|
|
GET /api/subagent/{session_id}/{agent_id}
|
|
NEW: Get subagent conversation specifically
|
|
|
|
GET /api/team/{team_name}
|
|
NEW: Get detailed team info including tasks
|
|
|
|
GET /api/team/{team_name}/tasks
|
|
NEW: Get full task list for a team
|
|
```
|
|
|
|
### Dashboard Components
|
|
|
|
#### SubagentPanel.js
|
|
|
|
```javascript
|
|
function SubagentPanel({ subagents, onSelectSubagent }) {
|
|
if (!subagents?.length) return null;
|
|
|
|
return (
|
|
<div className="subagent-panel">
|
|
<div className="subagent-header">
|
|
<span className="subagent-count">{subagents.length} subagents</span>
|
|
</div>
|
|
<div className="subagent-list">
|
|
{subagents.map(sa => (
|
|
<SubagentRow
|
|
key={sa.agent_id}
|
|
subagent={sa}
|
|
onClick={() => onSelectSubagent(sa)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SubagentRow({ subagent, onClick }) {
|
|
const isRunning = subagent.status === 'running';
|
|
|
|
return (
|
|
<div className="subagent-row" onClick={onClick}>
|
|
<span className={`subagent-status ${subagent.status}`}>
|
|
{isRunning && <ActivityIndicator />}
|
|
</span>
|
|
<span className="subagent-type">{subagent.subagent_type}</span>
|
|
<span className="subagent-duration">
|
|
{formatDuration(subagent.started_at, subagent.completed_at)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### TeamCard.js
|
|
|
|
```javascript
|
|
function TeamCard({ team, onExpand }) {
|
|
const activeCount = team.members.filter(m => m.status === 'active').length;
|
|
const taskCounts = team.tasks;
|
|
|
|
return (
|
|
<div className="team-card">
|
|
<div className="team-header">
|
|
<span className="team-name">{team.name}</span>
|
|
<span className="team-badge">Team</span>
|
|
</div>
|
|
<div className="team-description">{team.description}</div>
|
|
<div className="team-stats">
|
|
<span>{activeCount}/{team.members.length} members active</span>
|
|
<span>{taskCounts.in_progress} tasks in progress</span>
|
|
</div>
|
|
<button onClick={onExpand}>View Team</button>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Implementation Specs
|
|
|
|
### IMP-1: Subagent Discovery Mixin
|
|
|
|
**Fulfills**: AC-1, AC-2, AC-3, AC-4
|
|
|
|
**File**: `amc_server/mixins/subagent.py` (new)
|
|
|
|
```python
|
|
"""Subagent discovery and parsing mixin for AMC server."""
|
|
|
|
import json
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
|
|
|
|
class SubagentMixin:
|
|
"""Discovers and parses Claude Code subagent sessions."""
|
|
|
|
def _discover_subagents_for_session(self, session_id, project_dir):
|
|
"""Discover all subagent JSONL files for a given parent session.
|
|
|
|
Subagents are stored in: {session_dir}/subagents/agent-{agentId}.jsonl
|
|
They share the parent's sessionId but have their own agentId.
|
|
|
|
Args:
|
|
session_id: Parent session UUID
|
|
project_dir: Parent session's project directory
|
|
|
|
Returns:
|
|
List of subagent dicts with metadata
|
|
"""
|
|
subagents = []
|
|
|
|
# Get session directory path
|
|
session_dir = self._get_claude_session_dir(session_id, project_dir)
|
|
if not session_dir:
|
|
return subagents
|
|
|
|
subagents_dir = session_dir / "subagents"
|
|
if not subagents_dir.exists():
|
|
return subagents
|
|
|
|
for jsonl_file in subagents_dir.glob("agent-*.jsonl"):
|
|
try:
|
|
agent_id = self._extract_agent_id_from_filename(jsonl_file.name)
|
|
metadata = self._parse_subagent_metadata(jsonl_file, session_id, project_dir)
|
|
|
|
subagents.append({
|
|
"agent_id": agent_id,
|
|
"subagent_type": metadata.get("subagent_type", "unknown"),
|
|
"prompt_summary": metadata.get("prompt_summary", ""),
|
|
"status": self._determine_subagent_status(jsonl_file, metadata),
|
|
"started_at": metadata.get("started_at"),
|
|
"completed_at": metadata.get("completed_at"),
|
|
"last_activity_at": self._get_file_mtime_iso(jsonl_file),
|
|
"message_count": metadata.get("message_count", 0),
|
|
"transcript_path": str(jsonl_file),
|
|
"mtime_ns": self._get_file_mtime_ns(jsonl_file),
|
|
})
|
|
except Exception:
|
|
continue
|
|
|
|
# Sort by started_at (most recent first)
|
|
subagents.sort(key=lambda s: s.get("started_at", ""), reverse=True)
|
|
return subagents
|
|
|
|
def _extract_agent_id_from_filename(self, filename):
|
|
"""Extract agentId from filename like 'agent-a510a6b.jsonl'."""
|
|
# Handle both formats: agent-{id}.jsonl and agent-acompact-{id}.jsonl
|
|
stem = filename.replace(".jsonl", "")
|
|
if stem.startswith("agent-"):
|
|
return stem[6:] # Remove "agent-" prefix
|
|
return stem
|
|
|
|
def _parse_subagent_metadata(self, jsonl_file, parent_session_id, parent_project_dir):
|
|
"""Parse subagent JSONL to extract metadata.
|
|
|
|
Reads first and last entries to determine:
|
|
- Agent ID (from agentId field)
|
|
- Subagent type (from parent's Task tool invocation)
|
|
- Start/end timestamps
|
|
- Message count
|
|
"""
|
|
metadata = {
|
|
"message_count": 0,
|
|
"started_at": None,
|
|
"completed_at": None,
|
|
"subagent_type": "unknown",
|
|
"prompt_summary": "",
|
|
}
|
|
|
|
first_entry = None
|
|
last_entry = None
|
|
message_count = 0
|
|
|
|
try:
|
|
with open(jsonl_file, 'r') as f:
|
|
for line in f:
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
entry = json.loads(line)
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
|
|
if first_entry is None:
|
|
first_entry = entry
|
|
last_entry = entry
|
|
|
|
# Count user and assistant messages
|
|
if entry.get("type") in ("user", "assistant"):
|
|
message_count += 1
|
|
except json.JSONDecodeError:
|
|
continue
|
|
except OSError:
|
|
return metadata
|
|
|
|
metadata["message_count"] = message_count
|
|
|
|
if first_entry:
|
|
metadata["started_at"] = first_entry.get("timestamp")
|
|
metadata["agent_id"] = first_entry.get("agentId")
|
|
|
|
if last_entry:
|
|
metadata["completed_at"] = last_entry.get("timestamp")
|
|
|
|
# Extract subagent_type from parent session's Task tool invocation
|
|
agent_id = metadata.get("agent_id")
|
|
if agent_id:
|
|
type_info = self._extract_subagent_type_from_parent(
|
|
parent_session_id, parent_project_dir, agent_id
|
|
)
|
|
if type_info:
|
|
metadata["subagent_type"] = type_info.get("subagent_type", "unknown")
|
|
metadata["prompt_summary"] = type_info.get("prompt_summary", "")
|
|
|
|
return metadata
|
|
|
|
def _extract_subagent_type_from_parent(self, session_id, project_dir, agent_id):
|
|
"""Extract subagent_type and prompt from parent's Task tool invocation.
|
|
|
|
The parent session contains progress entries with:
|
|
- data.agentId matching our subagent
|
|
- parentToolUseID linking to the Task tool_use
|
|
|
|
We find the Task tool_use to extract subagent_type and prompt.
|
|
"""
|
|
parent_file = self._get_claude_conversation_file(session_id, project_dir)
|
|
if not parent_file or not parent_file.exists():
|
|
return None
|
|
|
|
# First pass: find the parentToolUseID for this agent
|
|
parent_tool_use_id = None
|
|
try:
|
|
with open(parent_file, 'r') as f:
|
|
for line in f:
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
entry = json.loads(line)
|
|
if entry.get("type") == "progress":
|
|
data = entry.get("data", {})
|
|
if data.get("agentId") == agent_id:
|
|
parent_tool_use_id = entry.get("parentToolUseID")
|
|
break
|
|
except json.JSONDecodeError:
|
|
continue
|
|
except OSError:
|
|
return None
|
|
|
|
if not parent_tool_use_id:
|
|
return None
|
|
|
|
# Second pass: find the Task tool_use with that ID
|
|
try:
|
|
with open(parent_file, 'r') as f:
|
|
for line in f:
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
entry = json.loads(line)
|
|
if entry.get("type") == "assistant":
|
|
message = entry.get("message", {})
|
|
content = message.get("content", [])
|
|
if not isinstance(content, list):
|
|
continue
|
|
for part in content:
|
|
if not isinstance(part, dict):
|
|
continue
|
|
if part.get("type") == "tool_use" and part.get("name") == "Task":
|
|
if part.get("id") == parent_tool_use_id:
|
|
input_data = part.get("input", {})
|
|
prompt = input_data.get("prompt", "")
|
|
return {
|
|
"subagent_type": input_data.get("subagent_type", "unknown"),
|
|
"prompt_summary": prompt[:100] + "..." if len(prompt) > 100 else prompt,
|
|
}
|
|
except json.JSONDecodeError:
|
|
continue
|
|
except OSError:
|
|
return None
|
|
|
|
return None
|
|
|
|
def _determine_subagent_status(self, jsonl_file, metadata):
|
|
"""Determine if subagent is running or completed.
|
|
|
|
Heuristics:
|
|
- If file modified within last 30 seconds: running
|
|
- If has completion markers: completed
|
|
- Otherwise: completed (assume finished)
|
|
"""
|
|
try:
|
|
mtime = jsonl_file.stat().st_mtime
|
|
age = time.time() - mtime
|
|
|
|
# Recently active = running
|
|
if age < 30:
|
|
return "running"
|
|
|
|
# Check message count - very few messages might indicate still starting
|
|
if metadata.get("message_count", 0) < 2 and age < 60:
|
|
return "running"
|
|
|
|
return "completed"
|
|
except OSError:
|
|
return "completed"
|
|
|
|
def _get_file_mtime_iso(self, path):
|
|
"""Get file mtime as ISO timestamp."""
|
|
try:
|
|
from datetime import datetime, timezone
|
|
mtime = path.stat().st_mtime
|
|
return datetime.fromtimestamp(mtime, timezone.utc).isoformat()
|
|
except OSError:
|
|
return None
|
|
|
|
def _get_file_mtime_ns(self, path):
|
|
"""Get file mtime in nanoseconds for change detection."""
|
|
try:
|
|
return path.stat().st_mtime_ns
|
|
except OSError:
|
|
return None
|
|
|
|
def _get_claude_session_dir(self, session_id, project_dir):
|
|
"""Get the session directory path for a Claude Code session.
|
|
|
|
Claude stores session data at:
|
|
~/.claude/projects/{encoded_project_dir}/{session_id}/
|
|
"""
|
|
if not project_dir:
|
|
return None
|
|
|
|
claude_projects_dir = Path.home() / ".claude" / "projects"
|
|
|
|
# Encode project_dir same way Claude does: replace / with -
|
|
encoded = project_dir.replace("/", "-")
|
|
if encoded.startswith("-"):
|
|
encoded = encoded # Keep leading dash
|
|
|
|
session_dir = claude_projects_dir / encoded / session_id
|
|
if session_dir.exists() and session_dir.is_dir():
|
|
return session_dir
|
|
|
|
return None
|
|
```
|
|
|
|
### IMP-2: Team Discovery Mixin
|
|
|
|
**Fulfills**: AC-12, AC-13, AC-14, AC-15
|
|
|
|
**File**: `amc_server/mixins/team.py` (new)
|
|
|
|
```python
|
|
"""Agent team discovery and parsing mixin for AMC server."""
|
|
|
|
import json
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
|
|
class TeamMixin:
|
|
"""Discovers and parses Claude Code agent teams."""
|
|
|
|
TEAMS_DIR = Path.home() / ".claude" / "teams"
|
|
TASKS_DIR = Path.home() / ".claude" / "tasks"
|
|
|
|
def _discover_teams(self):
|
|
"""Discover all active agent teams.
|
|
|
|
Teams are stored at ~/.claude/teams/{team_name}/config.json
|
|
|
|
Returns:
|
|
List of team dicts with metadata
|
|
"""
|
|
teams = []
|
|
|
|
if not self.TEAMS_DIR.exists():
|
|
return teams
|
|
|
|
for team_dir in self.TEAMS_DIR.iterdir():
|
|
if not team_dir.is_dir():
|
|
continue
|
|
|
|
config_file = team_dir / "config.json"
|
|
if not config_file.exists():
|
|
continue
|
|
|
|
try:
|
|
config = json.loads(config_file.read_text())
|
|
team = self._build_team_model(config, team_dir.name)
|
|
|
|
# Add mtime for change detection
|
|
team["config_mtime_ns"] = config_file.stat().st_mtime_ns
|
|
|
|
teams.append(team)
|
|
except (json.JSONDecodeError, OSError):
|
|
continue
|
|
|
|
# Sort by creation time (most recent first)
|
|
teams.sort(key=lambda t: t.get("created_at", ""), reverse=True)
|
|
return teams
|
|
|
|
def _build_team_model(self, config, team_name):
|
|
"""Build team model from config.json.
|
|
|
|
Config structure:
|
|
{
|
|
"name": "team-name",
|
|
"description": "...",
|
|
"createdAt": 1772054633201,
|
|
"leadAgentId": "team-lead@team-name",
|
|
"leadSessionId": "uuid",
|
|
"members": [
|
|
{
|
|
"agentId": "team-lead@team-name",
|
|
"name": "team-lead",
|
|
"agentType": "team-lead",
|
|
"model": "claude-opus-4-5-20251101",
|
|
"joinedAt": 1772054633201,
|
|
"tmuxPaneId": "",
|
|
"cwd": "/path/to/project",
|
|
"subscriptions": []
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
members = []
|
|
for m in config.get("members", []):
|
|
member = {
|
|
"agent_id": m.get("agentId"),
|
|
"name": m.get("name"),
|
|
"agent_type": m.get("agentType"),
|
|
"model": m.get("model"),
|
|
"joined_at": self._timestamp_to_iso(m.get("joinedAt")),
|
|
"cwd": m.get("cwd"),
|
|
"status": "unknown", # Will be enriched later
|
|
"session_id": None, # Will be linked if found
|
|
}
|
|
members.append(member)
|
|
|
|
# Load task summary
|
|
tasks = self._load_team_tasks_summary(team_name)
|
|
|
|
return {
|
|
"type": "team",
|
|
"name": config.get("name", team_name),
|
|
"description": config.get("description", ""),
|
|
"lead_agent_id": config.get("leadAgentId"),
|
|
"lead_session_id": config.get("leadSessionId"),
|
|
"members": members,
|
|
"member_count": len(members),
|
|
"tasks": tasks,
|
|
"created_at": self._timestamp_to_iso(config.get("createdAt")),
|
|
}
|
|
|
|
def _load_team_tasks_summary(self, team_name):
|
|
"""Load task summary for a team.
|
|
|
|
Tasks are stored at ~/.claude/tasks/{team_name}/
|
|
"""
|
|
tasks_dir = self.TASKS_DIR / team_name
|
|
|
|
summary = {
|
|
"pending": 0,
|
|
"in_progress": 0,
|
|
"completed": 0,
|
|
"total": 0,
|
|
}
|
|
|
|
if not tasks_dir.exists():
|
|
return summary
|
|
|
|
# Count tasks by parsing task files or .highwatermark
|
|
for task_file in tasks_dir.glob("*.json"):
|
|
try:
|
|
task = json.loads(task_file.read_text())
|
|
status = task.get("status", "pending")
|
|
if status in summary:
|
|
summary[status] += 1
|
|
summary["total"] += 1
|
|
except (json.JSONDecodeError, OSError):
|
|
continue
|
|
|
|
return summary
|
|
|
|
def _load_team_tasks_full(self, team_name):
|
|
"""Load full task list for a team (for detailed view)."""
|
|
tasks_dir = self.TASKS_DIR / team_name
|
|
tasks = []
|
|
|
|
if not tasks_dir.exists():
|
|
return tasks
|
|
|
|
for task_file in tasks_dir.glob("*.json"):
|
|
try:
|
|
task = json.loads(task_file.read_text())
|
|
tasks.append({
|
|
"id": task.get("id"),
|
|
"subject": task.get("subject"),
|
|
"description": task.get("description"),
|
|
"status": task.get("status", "pending"),
|
|
"owner": task.get("owner"),
|
|
"blocked_by": task.get("blockedBy", []),
|
|
"blocks": task.get("blocks", []),
|
|
"created_at": task.get("createdAt"),
|
|
})
|
|
except (json.JSONDecodeError, OSError):
|
|
continue
|
|
|
|
# Sort by ID (numeric order if possible)
|
|
tasks.sort(key=lambda t: t.get("id", ""))
|
|
return tasks
|
|
|
|
def _timestamp_to_iso(self, ts):
|
|
"""Convert millisecond timestamp to ISO format."""
|
|
if not ts:
|
|
return None
|
|
try:
|
|
# Handle both milliseconds and seconds
|
|
if ts > 1e12:
|
|
ts = ts / 1000 # Convert from milliseconds
|
|
return datetime.fromtimestamp(ts, timezone.utc).isoformat()
|
|
except (ValueError, OSError):
|
|
return None
|
|
|
|
def _link_team_members_to_sessions(self, team, sessions):
|
|
"""Link team members to their corresponding session records.
|
|
|
|
Updates member status and session_id based on active sessions.
|
|
"""
|
|
# Build session lookup by session_id
|
|
session_lookup = {s.get("session_id"): s for s in sessions}
|
|
|
|
for member in team.get("members", []):
|
|
# Check if lead session matches
|
|
if member.get("agent_id") == team.get("lead_agent_id"):
|
|
lead_session_id = team.get("lead_session_id")
|
|
if lead_session_id in session_lookup:
|
|
session = session_lookup[lead_session_id]
|
|
member["session_id"] = lead_session_id
|
|
member["status"] = "active" if not session.get("is_dead") else "offline"
|
|
else:
|
|
member["status"] = "offline"
|
|
else:
|
|
# Non-lead members: try to find matching session by cwd
|
|
member["status"] = "unknown"
|
|
```
|
|
|
|
### IMP-3: State Mixin Integration
|
|
|
|
**Fulfills**: AC-4, AC-23, AC-24, AC-25
|
|
|
|
**File**: `amc_server/mixins/state.py` (modify)
|
|
|
|
Update `_build_state_payload` to include teams:
|
|
|
|
```python
|
|
def _build_state_payload(self):
|
|
"""Build `/api/state` payload data used by JSON and SSE endpoints."""
|
|
sessions = self._collect_sessions()
|
|
teams = self._discover_teams()
|
|
|
|
# Link team members to sessions
|
|
for team in teams:
|
|
self._link_team_members_to_sessions(team, sessions)
|
|
|
|
return {
|
|
"sessions": sessions,
|
|
"teams": teams,
|
|
"server_time": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
```
|
|
|
|
Update `_collect_sessions` to call subagent discovery:
|
|
|
|
```python
|
|
# In the session processing loop, after loading basic data:
|
|
if data.get("agent") == "claude":
|
|
subagents = self._discover_subagents_for_session(
|
|
data.get("session_id", ""),
|
|
data.get("project_dir", "")
|
|
)
|
|
if subagents:
|
|
data["subagents"] = subagents
|
|
```
|
|
|
|
### IMP-4: API Route Extensions
|
|
|
|
**Fulfills**: AC-7, AC-17
|
|
|
|
**File**: `amc_server/mixins/http.py` (modify)
|
|
|
|
Add new routes:
|
|
|
|
```python
|
|
# In _route_get():
|
|
|
|
elif path.startswith("/api/subagent/"):
|
|
# /api/subagent/{session_id}/{agent_id}
|
|
parts = path.split("/")
|
|
if len(parts) >= 5:
|
|
session_id = parts[3]
|
|
agent_id = parts[4]
|
|
self._serve_subagent_conversation(session_id, agent_id)
|
|
else:
|
|
self._send_error(400, "Invalid subagent path")
|
|
|
|
elif path.startswith("/api/team/"):
|
|
# /api/team/{team_name} or /api/team/{team_name}/tasks
|
|
parts = path.split("/")
|
|
team_name = parts[3] if len(parts) > 3 else ""
|
|
if len(parts) > 4 and parts[4] == "tasks":
|
|
self._serve_team_tasks(team_name)
|
|
else:
|
|
self._serve_team_detail(team_name)
|
|
```
|
|
|
|
### IMP-5: Dashboard SubagentPanel Component
|
|
|
|
**Fulfills**: AC-5, AC-6, AC-7, AC-9, AC-10
|
|
|
|
**File**: `dashboard/components/SubagentPanel.js` (new)
|
|
|
|
```javascript
|
|
import { h } from 'preact';
|
|
import { useState } from 'preact/hooks';
|
|
import { AgentActivityIndicator } from './AgentActivityIndicator.js';
|
|
import { formatDuration, formatRelativeTime } from '../utils/formatting.js';
|
|
|
|
export function SubagentPanel({ subagents, onSelectSubagent }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
if (!subagents?.length) return null;
|
|
|
|
const runningCount = subagents.filter(s => s.status === 'running').length;
|
|
|
|
return (
|
|
<div className="subagent-panel">
|
|
<button
|
|
className="subagent-toggle"
|
|
onClick={() => setExpanded(!expanded)}
|
|
>
|
|
<span className="subagent-icon">
|
|
{runningCount > 0 && <AgentActivityIndicator size="small" />}
|
|
</span>
|
|
<span className="subagent-count">
|
|
{subagents.length} subagent{subagents.length !== 1 ? 's' : ''}
|
|
{runningCount > 0 && ` (${runningCount} running)`}
|
|
</span>
|
|
<span className={`expand-arrow ${expanded ? 'expanded' : ''}`}>▶</span>
|
|
</button>
|
|
|
|
{expanded && (
|
|
<div className="subagent-list">
|
|
{subagents.map(sa => (
|
|
<SubagentRow
|
|
key={sa.agent_id}
|
|
subagent={sa}
|
|
onClick={() => onSelectSubagent(sa)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SubagentRow({ subagent, onClick }) {
|
|
const isRunning = subagent.status === 'running';
|
|
const duration = formatDuration(subagent.started_at, subagent.completed_at);
|
|
|
|
return (
|
|
<div
|
|
className={`subagent-row ${subagent.status}`}
|
|
onClick={onClick}
|
|
role="button"
|
|
tabIndex={0}
|
|
>
|
|
<span className="subagent-status">
|
|
{isRunning ? (
|
|
<AgentActivityIndicator size="tiny" />
|
|
) : (
|
|
<span className="status-dot completed" />
|
|
)}
|
|
</span>
|
|
<span className="subagent-type">{subagent.subagent_type}</span>
|
|
<span className="subagent-meta">
|
|
{subagent.message_count} msgs • {duration}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### IMP-6: Dashboard TeamCard Component
|
|
|
|
**Fulfills**: AC-16, AC-17, AC-18, AC-19
|
|
|
|
**File**: `dashboard/components/TeamCard.js` (new)
|
|
|
|
```javascript
|
|
import { h } from 'preact';
|
|
import { useState } from 'preact/hooks';
|
|
import { formatRelativeTime } from '../utils/formatting.js';
|
|
|
|
export function TeamCard({ team, sessions, onViewMember }) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
const activeMembers = team.members.filter(m => m.status === 'active');
|
|
const tasks = team.tasks;
|
|
|
|
return (
|
|
<div className="team-card">
|
|
<div className="team-header">
|
|
<span className="team-name">{team.name}</span>
|
|
<span className="team-badge">Team</span>
|
|
</div>
|
|
|
|
{team.description && (
|
|
<div className="team-description">{team.description}</div>
|
|
)}
|
|
|
|
<div className="team-stats">
|
|
<span className="stat">
|
|
<span className="stat-value">{activeMembers.length}</span>
|
|
<span className="stat-label">/{team.member_count} active</span>
|
|
</span>
|
|
<span className="stat">
|
|
<span className="stat-value">{tasks.in_progress}</span>
|
|
<span className="stat-label">in progress</span>
|
|
</span>
|
|
<span className="stat">
|
|
<span className="stat-value">{tasks.completed}</span>
|
|
<span className="stat-label">completed</span>
|
|
</span>
|
|
</div>
|
|
|
|
<button
|
|
className="team-expand"
|
|
onClick={() => setExpanded(!expanded)}
|
|
>
|
|
{expanded ? 'Collapse' : 'View Details'}
|
|
</button>
|
|
|
|
{expanded && (
|
|
<div className="team-details">
|
|
<TeamMemberList
|
|
members={team.members}
|
|
onViewMember={onViewMember}
|
|
/>
|
|
<TeamTaskSummary tasks={tasks} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TeamMemberList({ members, onViewMember }) {
|
|
return (
|
|
<div className="team-members">
|
|
<h4>Members</h4>
|
|
{members.map(m => (
|
|
<div
|
|
key={m.agent_id}
|
|
className={`team-member ${m.status}`}
|
|
onClick={() => m.session_id && onViewMember(m.session_id)}
|
|
>
|
|
<span className={`member-status ${m.status}`} />
|
|
<span className="member-name">{m.name}</span>
|
|
<span className="member-type">{m.agent_type}</span>
|
|
<span className="member-model">{m.model?.split('-').slice(-2, -1)[0]}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TeamTaskSummary({ tasks }) {
|
|
return (
|
|
<div className="team-tasks">
|
|
<h4>Tasks</h4>
|
|
<div className="task-progress">
|
|
<div
|
|
className="progress-bar"
|
|
style={{
|
|
'--completed': tasks.completed,
|
|
'--in-progress': tasks.in_progress,
|
|
'--pending': tasks.pending,
|
|
'--total': tasks.total || 1
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="task-counts">
|
|
<span className="pending">{tasks.pending} pending</span>
|
|
<span className="in-progress">{tasks.in_progress} in progress</span>
|
|
<span className="completed">{tasks.completed} done</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### IMP-7: Styles for Subagent/Team UI
|
|
|
|
**Fulfills**: AC-5, AC-6, AC-9, AC-16, AC-17
|
|
|
|
**File**: `dashboard/styles.css` (additions)
|
|
|
|
```css
|
|
/* Subagent Panel */
|
|
.subagent-panel {
|
|
margin-top: 8px;
|
|
border-top: 1px solid var(--border-color, #333);
|
|
padding-top: 8px;
|
|
}
|
|
|
|
.subagent-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-secondary, #999);
|
|
cursor: pointer;
|
|
padding: 4px 0;
|
|
width: 100%;
|
|
text-align: left;
|
|
}
|
|
|
|
.subagent-toggle:hover {
|
|
color: var(--text-primary, #fff);
|
|
}
|
|
|
|
.subagent-count {
|
|
flex: 1;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.expand-arrow {
|
|
font-size: 10px;
|
|
transition: transform 0.2s;
|
|
}
|
|
|
|
.expand-arrow.expanded {
|
|
transform: rotate(90deg);
|
|
}
|
|
|
|
.subagent-list {
|
|
margin-top: 8px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.subagent-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 6px 8px;
|
|
background: var(--surface-secondary, #222);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.subagent-row:hover {
|
|
background: var(--surface-hover, #333);
|
|
}
|
|
|
|
.subagent-row.running {
|
|
border-left: 2px solid var(--color-active, #4CAF50);
|
|
}
|
|
|
|
.subagent-row.completed {
|
|
border-left: 2px solid var(--color-done, #666);
|
|
}
|
|
|
|
.subagent-status {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 16px;
|
|
}
|
|
|
|
.status-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--color-done, #666);
|
|
}
|
|
|
|
.subagent-type {
|
|
font-weight: 500;
|
|
color: var(--text-primary, #fff);
|
|
}
|
|
|
|
.subagent-meta {
|
|
margin-left: auto;
|
|
color: var(--text-secondary, #999);
|
|
}
|
|
|
|
/* Team Card */
|
|
.team-card {
|
|
background: var(--surface-primary, #1a1a1a);
|
|
border: 1px solid var(--border-color, #333);
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.team-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.team-name {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: var(--text-primary, #fff);
|
|
}
|
|
|
|
.team-badge {
|
|
font-size: 10px;
|
|
padding: 2px 6px;
|
|
background: var(--color-team, #9C27B0);
|
|
color: #fff;
|
|
border-radius: 4px;
|
|
text-transform: uppercase;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.team-description {
|
|
font-size: 13px;
|
|
color: var(--text-secondary, #999);
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.team-stats {
|
|
display: flex;
|
|
gap: 16px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.team-stats .stat {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 4px;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
color: var(--text-primary, #fff);
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 12px;
|
|
color: var(--text-secondary, #999);
|
|
}
|
|
|
|
.team-expand {
|
|
background: var(--surface-secondary, #222);
|
|
border: 1px solid var(--border-color, #333);
|
|
color: var(--text-primary, #fff);
|
|
padding: 8px 16px;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
width: 100%;
|
|
}
|
|
|
|
.team-expand:hover {
|
|
background: var(--surface-hover, #333);
|
|
}
|
|
|
|
.team-details {
|
|
margin-top: 16px;
|
|
padding-top: 16px;
|
|
border-top: 1px solid var(--border-color, #333);
|
|
}
|
|
|
|
.team-members h4,
|
|
.team-tasks h4 {
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
color: var(--text-secondary, #999);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.team-member {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px;
|
|
background: var(--surface-secondary, #222);
|
|
border-radius: 4px;
|
|
margin-bottom: 4px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.team-member:hover {
|
|
background: var(--surface-hover, #333);
|
|
}
|
|
|
|
.member-status {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.member-status.active {
|
|
background: var(--color-active, #4CAF50);
|
|
}
|
|
|
|
.member-status.idle {
|
|
background: var(--color-warning, #FF9800);
|
|
}
|
|
|
|
.member-status.offline,
|
|
.member-status.unknown {
|
|
background: var(--color-done, #666);
|
|
}
|
|
|
|
.member-name {
|
|
font-weight: 500;
|
|
color: var(--text-primary, #fff);
|
|
}
|
|
|
|
.member-type {
|
|
color: var(--text-secondary, #999);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.member-model {
|
|
margin-left: auto;
|
|
font-size: 11px;
|
|
color: var(--text-tertiary, #666);
|
|
}
|
|
|
|
.team-tasks {
|
|
margin-top: 16px;
|
|
}
|
|
|
|
.task-progress {
|
|
height: 8px;
|
|
background: var(--surface-secondary, #222);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.progress-bar {
|
|
height: 100%;
|
|
display: flex;
|
|
}
|
|
|
|
.progress-bar::before {
|
|
content: '';
|
|
height: 100%;
|
|
width: calc(var(--completed) / var(--total) * 100%);
|
|
background: var(--color-active, #4CAF50);
|
|
}
|
|
|
|
.progress-bar::after {
|
|
content: '';
|
|
height: 100%;
|
|
width: calc(var(--in-progress) / var(--total) * 100%);
|
|
background: var(--color-warning, #FF9800);
|
|
}
|
|
|
|
.task-counts {
|
|
display: flex;
|
|
gap: 12px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.task-counts .pending {
|
|
color: var(--text-secondary, #999);
|
|
}
|
|
|
|
.task-counts .in-progress {
|
|
color: var(--color-warning, #FF9800);
|
|
}
|
|
|
|
.task-counts .completed {
|
|
color: var(--color-active, #4CAF50);
|
|
}
|
|
```
|
|
|
|
### IMP-8: Parent-Child Navigation
|
|
|
|
**Fulfills**: AC-20, AC-21, AC-22
|
|
|
|
**File**: `dashboard/components/ConversationModal.js` (modifications)
|
|
|
|
Add header showing parent/child relationship:
|
|
|
|
```javascript
|
|
function ConversationHeader({ session, subagent, onNavigateToParent }) {
|
|
if (subagent) {
|
|
return (
|
|
<div className="conversation-header subagent">
|
|
<span className="subagent-badge">{subagent.subagent_type}</span>
|
|
<button
|
|
className="navigate-parent"
|
|
onClick={onNavigateToParent}
|
|
>
|
|
← Back to parent session
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="conversation-header">
|
|
<span className="session-id">{session.session_id.slice(0, 8)}</span>
|
|
{session.subagents?.length > 0 && (
|
|
<span className="subagent-indicator">
|
|
{session.subagents.length} subagent{session.subagents.length !== 1 ? 's' : ''}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### IMP-9: App.js State Integration
|
|
|
|
**Fulfills**: AC-23, AC-24
|
|
|
|
**File**: `dashboard/components/App.js` (modifications)
|
|
|
|
Update state management to include teams:
|
|
|
|
```javascript
|
|
// Add teams to state
|
|
const [teams, setTeams] = useState([]);
|
|
|
|
// Update fetchState to handle teams
|
|
const fetchState = useCallback(async () => {
|
|
const response = await fetch('/api/state');
|
|
const data = await response.json();
|
|
setSessions(data.sessions || []);
|
|
setTeams(data.teams || []); // NEW
|
|
}, []);
|
|
|
|
// Add handler for subagent selection
|
|
const handleSelectSubagent = useCallback((session, subagent) => {
|
|
setSelectedSubagent({ session, subagent });
|
|
setModalOpen(true);
|
|
}, []);
|
|
|
|
// Render teams section
|
|
{teams.length > 0 && (
|
|
<section className="teams-section">
|
|
<h2>Agent Teams</h2>
|
|
{teams.map(team => (
|
|
<TeamCard
|
|
key={team.name}
|
|
team={team}
|
|
sessions={sessions}
|
|
onViewMember={handleViewMember}
|
|
/>
|
|
))}
|
|
</section>
|
|
)}
|
|
```
|
|
|
|
---
|
|
|
|
## Rollout Slices
|
|
|
|
### Slice 1: Subagent Discovery (Backend)
|
|
|
|
**Goal**: Discover and expose subagent metadata through the API
|
|
|
|
**Tasks**:
|
|
1. Create `SubagentMixin` class in `amc_server/mixins/subagent.py`
|
|
2. Implement `_discover_subagents_for_session()`
|
|
3. Implement `_parse_subagent_metadata()`
|
|
4. Implement `_extract_subagent_type_from_parent()`
|
|
5. Integrate with `StateMixin._collect_sessions()`
|
|
6. Add unit tests for subagent parsing
|
|
|
|
**Verification**:
|
|
- `GET /api/state` returns `subagents[]` for sessions with subagents
|
|
- Subagent type is correctly extracted from parent
|
|
- Status detection (running/completed) works
|
|
|
|
### Slice 2: Subagent UI (Frontend)
|
|
|
|
**Goal**: Display subagents in the dashboard
|
|
|
|
**Tasks**:
|
|
1. Create `SubagentPanel.js` component
|
|
2. Add subagent styles to `styles.css`
|
|
3. Integrate panel into `SessionCard.js`
|
|
4. Add subagent conversation endpoint (`/api/subagent/{session_id}/{agent_id}`)
|
|
5. Add modal view for subagent conversations
|
|
6. Test with real sessions
|
|
|
|
**Verification**:
|
|
- Session cards show subagent badge
|
|
- Expanding shows subagent list
|
|
- Clicking subagent opens conversation modal
|
|
- Running subagents show activity indicator
|
|
|
|
### Slice 3: Team Discovery (Backend)
|
|
|
|
**Goal**: Discover and expose team metadata through the API
|
|
|
|
**Tasks**:
|
|
1. Create `TeamMixin` class in `amc_server/mixins/team.py`
|
|
2. Implement `_discover_teams()`
|
|
3. Implement `_build_team_model()`
|
|
4. Implement `_load_team_tasks_summary()`
|
|
5. Implement `_link_team_members_to_sessions()`
|
|
6. Integrate with `StateMixin._build_state_payload()`
|
|
7. Add unit tests
|
|
|
|
**Verification**:
|
|
- `GET /api/state` returns `teams[]` when teams exist
|
|
- Team members are linked to sessions
|
|
- Task counts are accurate
|
|
|
|
### Slice 4: Team UI (Frontend)
|
|
|
|
**Goal**: Display teams in the dashboard
|
|
|
|
**Tasks**:
|
|
1. Create `TeamCard.js` component
|
|
2. Create `TeamMemberList` and `TeamTaskSummary` subcomponents
|
|
3. Add team styles to `styles.css`
|
|
4. Integrate into `App.js`
|
|
5. Add team detail endpoint (`/api/team/{team_name}`)
|
|
6. Add navigation from team member to session
|
|
|
|
**Verification**:
|
|
- Teams appear in dashboard
|
|
- Member list shows status
|
|
- Task progress bar renders correctly
|
|
- Can navigate to member sessions
|
|
|
|
### Slice 5: Polish & Integration
|
|
|
|
**Goal**: Complete integration and edge case handling
|
|
|
|
**Tasks**:
|
|
1. Add parent-child navigation in conversation modals
|
|
2. Handle stale/cleaned-up subagent files gracefully
|
|
3. Add loading states for async data
|
|
4. Test with real multi-agent workflows
|
|
5. Performance testing with many subagents
|
|
6. Documentation update
|
|
|
|
**Verification**:
|
|
- End-to-end workflow works
|
|
- No errors with missing/stale files
|
|
- Performance acceptable with 10+ subagents
|
|
- README updated
|
|
|
|
---
|
|
|
|
## Testing Strategy
|
|
|
|
### Unit Tests
|
|
|
|
1. **Subagent parsing**:
|
|
- Extract agent_id from various filename formats
|
|
- Parse metadata from sample JSONL
|
|
- Extract subagent_type from parent Task tool calls
|
|
- Status detection logic
|
|
|
|
2. **Team parsing**:
|
|
- Parse sample config.json
|
|
- Load task summaries
|
|
- Timestamp conversion
|
|
- Member-session linking
|
|
|
|
### Integration Tests
|
|
|
|
1. **API endpoints**:
|
|
- `/api/state` includes subagents and teams
|
|
- `/api/subagent/{session_id}/{agent_id}` returns conversation
|
|
- `/api/team/{team_name}` returns team details
|
|
|
|
### Manual Testing
|
|
|
|
1. Run Claude Code with Task tool to create subagents
|
|
2. Verify subagents appear in dashboard
|
|
3. Create agent team with TeamCreate
|
|
4. Verify team appears in dashboard
|
|
5. Test navigation between parent and child conversations
|
|
|
|
---
|
|
|
|
## Open Questions
|
|
|
|
1. **Performance**: With many subagents (10+), should we lazy-load conversations or batch?
|
|
|
|
2. **Staleness**: How long should completed subagent data persist? Follow session cleanup rules?
|
|
|
|
3. **Team member sessions**: How to reliably link team members to their sessions when multiple sessions exist?
|
|
|
|
4. **Real-time updates**: Should we add file watchers for subagent/team directories, or rely on polling?
|
|
|
|
---
|
|
|
|
## References
|
|
|
|
- Claude Code Task tool documentation
|
|
- Claude Code agent teams documentation
|
|
- Existing AMC codebase (mixins, dashboard)
|
|
- Sample JSONL files from actual sessions
|