# Subagent & Agent Team Visibility for AMC > **Status**: Draft > **Last Updated**: 2026-03-02 ## Summary Add visibility into Claude Code subagents (Task tool spawns and team members) within AMC session cards. A pill button shows active agent count; clicking opens a popover with names, status, and stats. Claude-only (Codex does not support subagents). --- ## User Workflow 1. User views a session card in AMC 2. Session status area shows: `[●] Working 2m 15s · 42k tokens 32% ctx [3 agents]` 3. User clicks "3 agents" button 4. Popover opens showing: ``` Explore-a250de ● running 12m 42,000 tokens code-reviewer ○ completed 3m 18,500 tokens action-wirer ○ completed 5m 23,500 tokens ``` 5. Popover auto-updates every 2s while open 6. Button hidden when session has no subagents --- ## Acceptance Criteria ### Discovery - **AC-1**: Subagent JSONL files discovered for Claude sessions at `{claude_projects}/{encoded_project_dir}/{session_id}/subagents/agent-*.jsonl` - **AC-2**: Team members discovered from same location (team spawning uses Task tool, stores in subagents dir) - **AC-3**: Codex sessions do not show subagent button (Codex does not support subagents) ### Status Detection - **AC-4**: Subagent is "running" if parent session is not dead AND last assistant entry has `stop_reason != "end_turn"` - **AC-5**: Subagent is "completed" if last assistant entry has `stop_reason == "end_turn"` OR parent session is dead ### Name Resolution - **AC-6**: Team member names extracted from agentId format `{name}@{team_name}` (O(1) string split) - **AC-7**: Non-team subagent names generated as `agent-{agentId_prefix}` (no parent session parsing required) ### Stats Extraction - **AC-8**: Duration = first entry timestamp to last entry timestamp (or server time if running) - **AC-9**: Tokens = sum of `input_tokens + output_tokens` from all assistant entries (excludes cache tokens) ### API - **AC-10**: `/api/state` includes `subagent_count` and `subagent_running_count` for each Claude session - **AC-11**: New endpoint `/api/sessions/{id}/subagents` returns full subagent list with name, status, duration_ms, tokens - **AC-12**: Subagent endpoint supports session_id path param; returns 404 if session not found ### UI - **AC-13**: Context usage displays as plain text (remove badge styling) - **AC-14**: Agent count button appears as bordered pill to the right of context text - **AC-15**: Button hidden when `subagent_count == 0` - **AC-16**: Button shows running indicator: "3 agents" when none running, "3 agents (1 running)" when some running - **AC-17**: Clicking button opens popover anchored to button - **AC-18**: Popover shows list: name, status indicator, duration, token count per row - **AC-19**: Running agents show filled indicator (●), completed show empty (○) - **AC-20**: Popover polls `/api/sessions/{id}/subagents` every 2s while open - **AC-21**: Popover closes on outside click or Escape key - **AC-22**: Subagent rows are display-only (no click action in v1) --- ## Architecture ### Why This Structure | Decision | Rationale | Fulfills | |----------|-----------|----------| | Aggregate counts in `/api/state` + detail endpoint | Minimizes payload size; hash stability (counts change less than durations) | AC-10, AC-11 | | Claude-only | Codex lacks subagent infrastructure | AC-3 | | Name from agentId pattern | Avoids expensive parent session parsing; team names encoded in agentId | AC-6, AC-7 | | Input+output tokens only | Matches "work done" mental model; simpler than cache tracking | AC-9 | | Auto-poll in popover | Real-time feel consistent with session card updates | AC-20 | | Hide button when empty | Reduces visual noise for sessions without agents | AC-15 | ### Data Flow ``` ┌─────────────────────────────────────────────────────────────────┐ │ Backend (Python) │ │ │ │ _collect_sessions() │ │ │ │ │ ├── For each Claude session: │ │ │ └── _count_subagents(session_id, project_dir) │ │ │ ├── glob subagents/agent-*.jsonl │ │ │ ├── count files, check running status │ │ │ └── return (count, running_count) │ │ │ │ │ └── Attach subagent_count, subagent_running_count │ │ │ │ _serve_subagents(session_id) │ │ ├── _get_claude_session_dir(session_id, project_dir) │ │ ├── glob subagents/agent-*.jsonl │ │ ├── For each file: │ │ │ ├── Parse name from agentId │ │ │ ├── Determine status from stop_reason │ │ │ ├── Calculate duration from timestamps │ │ │ └── Sum tokens from assistant usage │ │ └── Return JSON list │ │ │ └─────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────┐ │ Frontend (Preact) │ │ │ │ SessionCard │ │ │ │ │ ├── Session Status Area: │ │ │ ├── AgentActivityIndicator (left) │ │ │ ├── Context text (center-right, plain) │ │ │ └── SubagentButton (far right, if count > 0) │ │ │ │ │ └── SubagentButton │ │ ├── Shows "{count} agents" or "{count} ({running})" │ │ ├── onClick: opens SubagentPopover │ │ └── SubagentPopover │ │ ├── Polls /api/sessions/{id}/subagents │ │ ├── Renders list with status indicators │ │ └── Closes on outside click or Escape │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` ### File Changes | File | Change | ACs | |------|--------|-----| | `amc_server/mixins/subagent.py` | New mixin for subagent discovery and stats | AC-1,2,4-9 | | `amc_server/mixins/state.py` | Call subagent mixin, attach counts to session | AC-10 | | `amc_server/mixins/http.py` | Add route `/api/sessions/{id}/subagents` | AC-11,12 | | `amc_server/handler.py` | Add SubagentMixin to handler class | - | | `dashboard/components/SessionCard.js` | Update status area layout | AC-13,14 | | `dashboard/components/SubagentButton.js` | New component for button + popover | AC-15-22 | | `dashboard/utils/api.js` | Add `fetchSubagents(sessionId)` function | AC-20 | --- ## Implementation Specs ### IMP-1: SubagentMixin (Python) **Fulfills:** AC-1, AC-2, AC-4, AC-5, AC-6, AC-7, AC-8, AC-9 ```python # amc_server/mixins/subagent.py class SubagentMixin: def _get_subagent_counts(self, session_id: str, project_dir: str) -> tuple[int, int]: """Return (total_count, running_count) for a Claude session.""" subagents_dir = self._get_subagents_dir(session_id, project_dir) if not subagents_dir or not subagents_dir.exists(): return (0, 0) total = 0 running = 0 for jsonl_file in subagents_dir.glob("agent-*.jsonl"): total += 1 if self._is_subagent_running(jsonl_file): running += 1 return (total, running) def _get_subagents_dir(self, session_id: str, project_dir: str) -> Path | None: """Construct path to subagents directory.""" if not project_dir: return None encoded_dir = project_dir.replace("/", "-") if not encoded_dir.startswith("-"): encoded_dir = "-" + encoded_dir return CLAUDE_PROJECTS_DIR / encoded_dir / session_id / "subagents" def _is_subagent_running(self, jsonl_file: Path) -> bool: """Check if subagent is still running based on last assistant stop_reason.""" try: # Read last few lines to find last assistant entry entries = self._read_jsonl_tail_entries(jsonl_file, max_lines=20) for entry in reversed(entries): if entry.get("type") == "assistant": stop_reason = entry.get("message", {}).get("stop_reason") return stop_reason != "end_turn" return True # No assistant entries yet = still starting except Exception: return False def _get_subagent_list(self, session_id: str, project_dir: str, parent_is_dead: bool) -> list[dict]: """Return full subagent list with stats.""" subagents_dir = self._get_subagents_dir(session_id, project_dir) if not subagents_dir or not subagents_dir.exists(): return [] result = [] for jsonl_file in subagents_dir.glob("agent-*.jsonl"): subagent = self._parse_subagent(jsonl_file, parent_is_dead) if subagent: result.append(subagent) # Sort: running first, then by name result.sort(key=lambda s: (0 if s["status"] == "running" else 1, s["name"])) return result def _parse_subagent(self, jsonl_file: Path, parent_is_dead: bool) -> dict | None: """Parse a single subagent JSONL file.""" try: entries = self._read_jsonl_tail_entries(jsonl_file, max_lines=500, max_bytes=512*1024) if not entries: return None # Get agentId from first entry first_entry = entries[0] if entries else {} agent_id = first_entry.get("agentId", "") # Resolve name name = self._resolve_subagent_name(agent_id, jsonl_file) # Determine status is_running = False if not parent_is_dead: for entry in reversed(entries): if entry.get("type") == "assistant": stop_reason = entry.get("message", {}).get("stop_reason") is_running = stop_reason != "end_turn" break status = "running" if is_running else "completed" # Calculate duration first_ts = first_entry.get("timestamp") last_ts = entries[-1].get("timestamp") if entries else None duration_ms = self._calculate_duration_ms(first_ts, last_ts, is_running) # Sum tokens tokens = self._sum_assistant_tokens(entries) return { "name": name, "status": status, "duration_ms": duration_ms, "tokens": tokens, } except Exception: return None def _resolve_subagent_name(self, agent_id: str, jsonl_file: Path) -> str: """Extract display name from agentId or filename.""" # Team members: "reviewer-wcja@surgical-sync" -> "reviewer-wcja" if "@" in agent_id: return agent_id.split("@")[0] # Regular subagents: use prefix from agentId # agent_id like "a250dec6325c589be" -> "a250de" prefix = agent_id[:6] if agent_id else "agent" # Try to get subagent_type from filename if it contains it # Filename: agent-acompact-b857538cac0d5172.jsonl -> might indicate "compact" # For now, use generic fallback return f"agent-{prefix}" def _calculate_duration_ms(self, first_ts: str, last_ts: str, is_running: bool) -> int: """Calculate duration in milliseconds.""" if not first_ts: return 0 try: first = datetime.fromisoformat(first_ts.replace("Z", "+00:00")) if is_running: end = datetime.now(timezone.utc) elif last_ts: end = datetime.fromisoformat(last_ts.replace("Z", "+00:00")) else: return 0 return max(0, int((end - first).total_seconds() * 1000)) except Exception: return 0 def _sum_assistant_tokens(self, entries: list[dict]) -> int: """Sum input_tokens + output_tokens from all assistant entries.""" total = 0 for entry in entries: if entry.get("type") != "assistant": continue usage = entry.get("message", {}).get("usage", {}) input_tok = usage.get("input_tokens", 0) or 0 output_tok = usage.get("output_tokens", 0) or 0 total += input_tok + output_tok return total ``` ### IMP-2: State Integration (Python) **Fulfills:** AC-10 ```python # In amc_server/mixins/state.py, within _collect_sessions(): # After computing is_dead, add: if data.get("agent") == "claude": subagent_count, subagent_running = self._get_subagent_counts( data.get("session_id", ""), data.get("project_dir", "") ) if subagent_count > 0: data["subagent_count"] = subagent_count data["subagent_running_count"] = subagent_running ``` ### IMP-3: Subagents Endpoint (Python) **Fulfills:** AC-11, AC-12 ```python # In amc_server/mixins/http.py, add route handling: def _route_request(self): # ... existing routes ... # /api/sessions/{id}/subagents subagent_match = re.match(r"^/api/sessions/([^/]+)/subagents$", self.path) if subagent_match: session_id = subagent_match.group(1) self._serve_subagents(session_id) return def _serve_subagents(self, session_id): """Serve subagent list for a specific session.""" # Find session to get project_dir and is_dead session_file = SESSIONS_DIR / f"{session_id}.json" if not session_file.exists(): self._send_json(404, {"error": "Session not found"}) return try: session_data = json.loads(session_file.read_text()) except (json.JSONDecodeError, OSError): self._send_json(404, {"error": "Session not found"}) return if session_data.get("agent") != "claude": self._send_json(200, {"subagents": []}) return parent_is_dead = session_data.get("is_dead", False) subagents = self._get_subagent_list( session_id, session_data.get("project_dir", ""), parent_is_dead ) self._send_json(200, {"subagents": subagents}) ``` ### IMP-4: SubagentButton Component (JavaScript) **Fulfills:** AC-14, AC-15, AC-16, AC-17, AC-18, AC-19, AC-20, AC-21, AC-22 ```javascript // dashboard/components/SubagentButton.js import { html, useState, useEffect, useRef } from '../lib/preact.js'; import { fetchSubagents } from '../utils/api.js'; export function SubagentButton({ sessionId, count, runningCount }) { const [isOpen, setIsOpen] = useState(false); const [subagents, setSubagents] = useState([]); const buttonRef = useRef(null); const popoverRef = useRef(null); // Format button label const label = runningCount > 0 ? `${count} agents (${runningCount} running)` : `${count} agents`; // Poll while open useEffect(() => { if (!isOpen) return; const fetchData = async () => { const data = await fetchSubagents(sessionId); if (data?.subagents) { setSubagents(data.subagents); } }; fetchData(); const interval = setInterval(fetchData, 2000); return () => clearInterval(interval); }, [isOpen, sessionId]); // Close on outside click or Escape useEffect(() => { if (!isOpen) return; const handleClickOutside = (e) => { if (popoverRef.current && !popoverRef.current.contains(e.target) && buttonRef.current && !buttonRef.current.contains(e.target)) { setIsOpen(false); } }; const handleEscape = (e) => { if (e.key === 'Escape') setIsOpen(false); }; document.addEventListener('mousedown', handleClickOutside); document.addEventListener('keydown', handleEscape); return () => { document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleEscape); }; }, [isOpen]); const formatDuration = (ms) => { const sec = Math.floor(ms / 1000); if (sec < 60) return `${sec}s`; const min = Math.floor(sec / 60); return `${min}m`; }; const formatTokens = (count) => { if (count >= 1000) return `${(count / 1000).toFixed(1)}k`; return String(count); }; return html`
${isOpen && html`
${subagents.length === 0 ? html`
Loading...
` : subagents.map(agent => html`
${agent.name} ${formatDuration(agent.duration_ms)} ${formatTokens(agent.tokens)}
`)}
`}
`; } ``` ### IMP-5: SessionCard Status Area Update (JavaScript) **Fulfills:** AC-13, AC-14, AC-15 ```javascript // In dashboard/components/SessionCard.js, update the Session Status Area: // Replace the contextUsage badge with plain text + SubagentButton
<${AgentActivityIndicator} session=${session} />
${contextUsage && html` ${contextUsage.headline} `} ${session.subagent_count > 0 && session.agent === 'claude' && html` <${SubagentButton} sessionId=${session.session_id} count=${session.subagent_count} runningCount=${session.subagent_running_count || 0} /> `}
``` ### IMP-6: API Function (JavaScript) **Fulfills:** AC-20 ```javascript // In dashboard/utils/api.js, add: export async function fetchSubagents(sessionId) { try { const response = await fetch(`/api/sessions/${sessionId}/subagents`); if (!response.ok) return null; return await response.json(); } catch (e) { console.error('Failed to fetch subagents:', e); return null; } } ``` --- ## Rollout Slices ### Slice 1: Backend Discovery (AC-1, AC-2, AC-4, AC-5, AC-6, AC-7, AC-8, AC-9, AC-10) - Create `amc_server/mixins/subagent.py` with discovery and stats logic - Integrate into `state.py` to add counts to session payload - Unit tests for name resolution, status detection, token summing ### Slice 2: Backend Endpoint (AC-11, AC-12) - Add `/api/sessions/{id}/subagents` route - Return 404 for missing sessions, empty list for Codex - Integration test with real session data ### Slice 3: Frontend Button (AC-13, AC-14, AC-15, AC-16) - Update SessionCard status area layout - Create SubagentButton component with label logic - Test: button shows when count > 0, hidden when 0 ### Slice 4: Frontend Popover (AC-17, AC-18, AC-19, AC-20, AC-21, AC-22) - Add popover with polling - Style running/completed indicators - Test: popover opens, polls, closes on outside click/Escape