Files
amc/plans/subagent-visibility.md
teernisse c5b1fb3a80 chore(plans): add input-history and model-selection plans
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>
2026-03-06 14:51:36 -05:00

534 lines
21 KiB
Markdown

# 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`
<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
```javascript
// 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
```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