plans/input-history.md: - Implementation plan for shell-style up/down arrow message history in SimpleInput, deriving history from session log conversation data - Covers prop threading, history derivation, navigation state, keybinding details, modal parity, and test cases plans/model-selection.md: - Three-phase plan for model visibility and control: display current model, model picker at spawn, mid-session model switching via Zellij plans/PLAN-tool-result-display.md: - Updates to tool result display plan (pre-existing changes) plans/subagent-visibility.md: - Updates to subagent visibility plan (pre-existing changes) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
21 KiB
21 KiB
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
- User views a session card in AMC
- Session status area shows:
[●] Working 2m 15s · 42k tokens 32% ctx [3 agents] - User clicks "3 agents" button
- Popover opens showing:
Explore-a250de ● running 12m 42,000 tokens code-reviewer ○ completed 3m 18,500 tokens action-wirer ○ completed 5m 23,500 tokens - Popover auto-updates every 2s while open
- 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_tokensfrom all assistant entries (excludes cache tokens)
API
- AC-10:
/api/stateincludessubagent_countandsubagent_running_countfor each Claude session - AC-11: New endpoint
/api/sessions/{id}/subagentsreturns 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}/subagentsevery 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
# 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
# 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
# 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
// 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`
<div class="relative">
<button
ref=${buttonRef}
onClick=${() => setIsOpen(!isOpen)}
class="rounded-lg border border-selection/80 bg-bg/45 px-2.5 py-1 font-mono text-label text-dim hover:border-starting/50 hover:text-bright transition-colors"
>
${label}
</button>
${isOpen && html`
<div
ref=${popoverRef}
class="absolute right-0 top-full mt-2 z-50 min-w-[280px] rounded-lg border border-selection/80 bg-surface shadow-lg"
>
<div class="p-2">
${subagents.length === 0 ? html`
<div class="text-center text-dim text-sm py-4">Loading...</div>
` : subagents.map(agent => html`
<div class="flex items-center gap-3 px-3 py-2 rounded hover:bg-bg/40">
<span class="w-2 h-2 rounded-full ${agent.status === 'running' ? 'bg-active' : 'border border-dim'}"></span>
<span class="flex-1 font-mono text-sm text-bright truncate">${agent.name}</span>
<span class="font-mono text-label text-dim">${formatDuration(agent.duration_ms)}</span>
<span class="font-mono text-label text-dim">${formatTokens(agent.tokens)}</span>
</div>
`)}
</div>
</div>
`}
</div>
`;
}
IMP-5: SessionCard Status Area Update (JavaScript)
Fulfills: AC-13, AC-14, AC-15
// In dashboard/components/SessionCard.js, update the Session Status Area:
// Replace the contextUsage badge with plain text + SubagentButton
<!-- Session Status Area -->
<div class="flex items-center justify-between gap-3 px-4 py-2 border-b border-selection/50 bg-bg/60">
<${AgentActivityIndicator} session=${session} />
<div class="flex items-center gap-3">
${contextUsage && html`
<span class="font-mono text-label text-dim" title=${contextUsage.title}>
${contextUsage.headline}
</span>
`}
${session.subagent_count > 0 && session.agent === 'claude' && html`
<${SubagentButton}
sessionId=${session.session_id}
count=${session.subagent_count}
runningCount=${session.subagent_running_count || 0}
/>
`}
</div>
</div>
IMP-6: API Function (JavaScript)
Fulfills: AC-20
// 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.pywith discovery and stats logic - Integrate into
state.pyto 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}/subagentsroute - 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