Files
amc/plans/subagent-visibility.md
teernisse db3d2a2e31 feat(dashboard): add click-outside dismissal for autocomplete dropdown
Closes bd-3ny. Added mousedown listener that dismisses the dropdown when
clicking outside both the dropdown and textarea. Uses early return to avoid
registering listeners when dropdown is already closed.
2026-02-26 16:54:40 -05:00

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:

  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

# 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:

  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