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>
534 lines
21 KiB
Markdown
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
|