Context is Everything logo

Sub-Agent Activity Visibility Design

Problem

When Claude spawns a sub-agent (Task tool), the parent CLI process goes quiet while the sub-agent runs. The UI's 180-second stall watchdog fires, calls endStream(), and the streaming indicator disappears. The user sees no activity — it appears processing has stopped.

Root cause timeline (from issue #41 diagnostics):

  1. t=0s — Last streaming data received
  2. t=180s — Stall watchdog fires (reason: stream-start, ms: 180000)
  3. t=180s — endStream() called — streaming indicator stops
  4. t=184s — Sub-agent result arrives (32KB chunk) but stream already ended

The existing heartbeat (60s, claude-status type) should prevent this but diagnostic logs show zero heartbeat events during the 180s gap. Likely cause: the heartbeat sends identical "Processing..." text which may be filtered by the client's status deduplication, or the heartbeat was never started for this session.

PR #40 added real-time tool bubble display during streaming, but the stall timeout still kills the streaming state.

Solution: Three-Part Fix

Part 1: Server — Sub-Agent-Aware Heartbeat

File: claudecodeui/server/claude-cli.js

When extractToolEvents() detects a Task tool_use in the streaming content:

  1. Track active sub-agent tool IDs in the session's activeClaudeProcesses entry:

    entry.activeSubagentIds = entry.activeSubagentIds || new Set();
    entry.activeSubagentIds.add(toolUseId);
    
  2. Modify the heartbeat to send sub-agent-specific status messages with varying text (to avoid dedup):

    // In heartbeat interval handler:
    if (current.activeSubagentIds?.size > 0) {
      const elapsed = Math.round((Date.now() - current.subagentStartedAt) / 1000);
      const statusText = `Sub-agent processing... (${elapsed}s)`;
      wsHolder.send({
        type: 'claude-status',
        sessionId: sid,
        data: {
          type: 'subagent-heartbeat',
          message: statusText,
          subagentCount: current.activeSubagentIds.size,
          elapsedSeconds: elapsed
        },
        ts: Date.now()
      });
    }
    
  3. When a matching tool_result arrives for a sub-agent tool_use ID, remove it from the active set.

  4. Also emit a one-time claude-status with type: 'subagent-started' immediately when the Task tool_use is detected, so the client gets an explicit signal.

Rationale: Varying the status text (includes elapsed seconds) guarantees the client's dedup filter passes it through. The subagent-heartbeat type lets the client distinguish sub-agent activity from regular heartbeats.

Part 2: Client — Extend Stall Timeout for Sub-Agents

File: claudecodeui/src/hooks/useProjectWebSocketV2.js

  1. Add a ref to track sessions with active sub-agents:

    const activeSubagentSessionsRef = useRef(new Set()); // sessions with running sub-agents
    
  2. When a Task tool_use is emitted (line ~1020-1038), add the session to the active set:

    if (part.name === 'Task') {
      activeSubagentSessionsRef.current.add(sid);
    }
    
  3. Modify scheduleStallWatchdog() to use an extended timeout when a sub-agent is active:

    const SUBAGENT_STALL_TIMEOUT_MS = 600000; // 10 minutes for sub-agent runs
    const timeoutMs = activeSubagentSessionsRef.current.has(sid)
      ? SUBAGENT_STALL_TIMEOUT_MS
      : STREAM_STALL_TIMEOUT_MS;
    
  4. Clear the sub-agent tracking when:

    • A tool_result matching a Task tool_use arrives
    • claude-complete is received for the session
    • The stream ends normally
  5. When a claude-status with type: 'subagent-heartbeat' arrives, reset the stall watchdog AND ensure isStreaming stays true:

    // In claude-status handler:
    if (statusData.type === 'subagent-heartbeat') {
      activeSubagentSessionsRef.current.add(sid);
      scheduleStallWatchdog(sid, 'subagent-heartbeat');
    }
    

Part 3: Client — Persistent Sub-Agent Status Indicator

File: claudecodeui/src/components/ChatInterface.jsx

The tool bubble already shows "Tool - Sub-Agent" with a spinner (from PR #40). Enhancements:

  1. When a Task tool bubble is streaming (isToolUse && toolName === 'Task' && toolResultMissing), show an elapsed time counter:

    {message.toolName === 'Task' && message.toolResultMissing && (
      <SubAgentTimer startTime={message.timestamp} />
    )}
    
  2. The SubAgentTimer component updates every second to show "Running... 45s", "Running... 1m 23s", etc.

  3. Show a brief description extracted from the tool input (the description field from the Task tool call):

    <span className="text-xs text-gray-400">
      {tryParseDescription(message.toolInput)}
    </span>
    
  4. The tool bubble persists independently of the streaming state — even if endStream() fires, the tool bubble with its "Running..." timer continues until the tool_result arrives.

Key Files

File Changes
claudecodeui/server/claude-cli.js Sub-agent tracking in heartbeat, subagent-started status
claudecodeui/src/hooks/useProjectWebSocketV2.js Extended stall timeout, sub-agent session tracking
claudecodeui/src/components/ChatInterface.jsx Elapsed timer on Task tool bubbles

Testing

  1. Trigger a Claude session that spawns a sub-agent (Task tool)
  2. Verify the tool bubble appears immediately with "Tool - Sub-Agent" badge
  3. Verify the elapsed timer counts up on the tool bubble
  4. Verify no stall timeout fires during the sub-agent run (check debug logs)
  5. Verify the streaming indicator stays active throughout
  6. Verify normal completion after sub-agent returns
  7. Test with multiple concurrent sub-agents
  8. Test with sub-agents that take >3 minutes (previous failure threshold)