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

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

  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

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