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.
46 KiB
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:
-
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.
-
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:
- User views session card in AMC dashboard
- Dashboard shows "3 subagents active" indicator
- User clicks to expand subagent panel
- Each subagent shows: type, status (running/complete), duration, last activity
- User clicks a subagent to view its conversation in a modal
- Real-time updates show subagent progress without refresh
Workflow 2: Team Dashboard
Actor: User running an agent team
Flow:
- AMC discovers active team from
~/.claude/teams/ - Dashboard shows team card with: team name, description, member count
- User expands to see all team members: name, type, model, status
- Task list panel shows: tasks by status (pending/in_progress/completed), assignments, dependencies
- User can view any teammate's conversation
- Real-time updates show task transitions and team messaging
Workflow 3: Trace Parent-Child Relationships
Actor: User debugging a multi-agent workflow
Flow:
- User views a subagent's conversation
- UI shows "Spawned by: [parent session]" with link
- User can navigate between parent and child conversations
- Parent conversation shows inline markers: "[Spawned: code-reviewer → completed 2m ago]"
- 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_conversationlogic
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
# 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)
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:
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)
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
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
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)
"""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)
"""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:
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:
# 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:
# 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)
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)
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)
/* 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:
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:
// 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:
- Create
SubagentMixinclass inamc_server/mixins/subagent.py - Implement
_discover_subagents_for_session() - Implement
_parse_subagent_metadata() - Implement
_extract_subagent_type_from_parent() - Integrate with
StateMixin._collect_sessions() - Add unit tests for subagent parsing
Verification:
GET /api/statereturnssubagents[]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:
- Create
SubagentPanel.jscomponent - Add subagent styles to
styles.css - Integrate panel into
SessionCard.js - Add subagent conversation endpoint (
/api/subagent/{session_id}/{agent_id}) - Add modal view for subagent conversations
- 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:
- Create
TeamMixinclass inamc_server/mixins/team.py - Implement
_discover_teams() - Implement
_build_team_model() - Implement
_load_team_tasks_summary() - Implement
_link_team_members_to_sessions() - Integrate with
StateMixin._build_state_payload() - Add unit tests
Verification:
GET /api/statereturnsteams[]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:
- Create
TeamCard.jscomponent - Create
TeamMemberListandTeamTaskSummarysubcomponents - Add team styles to
styles.css - Integrate into
App.js - Add team detail endpoint (
/api/team/{team_name}) - 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:
- Add parent-child navigation in conversation modals
- Handle stale/cleaned-up subagent files gracefully
- Add loading states for async data
- Test with real multi-agent workflows
- Performance testing with many subagents
- 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
-
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
-
Team parsing:
- Parse sample config.json
- Load task summaries
- Timestamp conversion
- Member-session linking
Integration Tests
- API endpoints:
/api/stateincludes subagents and teams/api/subagent/{session_id}/{agent_id}returns conversation/api/team/{team_name}returns team details
Manual Testing
- Run Claude Code with Task tool to create subagents
- Verify subagents appear in dashboard
- Create agent team with TeamCreate
- Verify team appears in dashboard
- Test navigation between parent and child conversations
Open Questions
-
Performance: With many subagents (10+), should we lazy-load conversations or batch?
-
Staleness: How long should completed subagent data persist? Follow session cleanup rules?
-
Team member sessions: How to reliably link team members to their sessions when multiple sessions exist?
-
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