Context is Everything logo

Sub-Agent Activity Visibility Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Keep the UI active and informative when Claude spawns sub-agents (Task tool), preventing the stall timeout from killing the streaming indicator.

Architecture: Three-part fix: (1) server emits sub-agent-aware heartbeat with varying text to avoid client dedup, (2) client extends stall timeout for sessions with active sub-agents, (3) UI shows elapsed timer and description on Task tool bubbles.

Tech Stack: Node.js server (claude-cli.js), React hooks (useProjectWebSocketV2.js), React components (ChatInterface.jsx)


Task 1: Server โ€” Track Active Sub-Agents in Process Entry

Files:

  • Modify: claudecodeui/server/claude-cli.js:2643-2709

Step 1: Add sub-agent detection after logToolEvents() call

After the existing logToolEvents() call at line 2644, add detection for Task tool_use and tool_result to track active sub-agents in the process entry. Insert this block after line 2651 (the closing }/) of logToolEvents), before the AskUserQuestion detection at line 2653:

            // Track active sub-agents (Task tool) for heartbeat awareness (fix for #41)
            if (Array.isArray(messageContent)) {
              for (const item of messageContent) {
                if (!item || typeof item !== 'object') continue;
                if (item.type === 'tool_use' && item.name === 'Task' && item.id) {
                  const entry = activeClaudeProcesses.get(effectiveSessionId());
                  if (entry) {
                    if (!entry.activeSubagentIds) entry.activeSubagentIds = new Set();
                    entry.activeSubagentIds.add(item.id);
                    if (!entry.subagentStartedAt) entry.subagentStartedAt = Date.now();
                    const description = item.input?.description || item.input?.prompt?.slice(0, 80) || 'processing';
                    console.log(`๐Ÿค– [SUBAGENT] Started: toolId=${item.id} description="${description}" sessionId=${effectiveSessionId() || 'none'}`);
                    // Emit immediate status so client knows a sub-agent is running
                    wsHolder.send({
                      type: 'claude-status',
                      sessionId: effectiveSessionId(),
                      projectName: projectNameDerived || null,
                      conversationId: conversationIdForEvent,
                      data: {
                        type: 'subagent-started',
                        message: `Sub-agent started: ${description}`,
                        toolId: item.id,
                        description
                      },
                      ts: Date.now(),
                      traceId: traceId || null
                    }, { context: 'subagent-status' });
                  }
                }
                if (item.type === 'tool_result' && item.tool_use_id) {
                  const entry = activeClaudeProcesses.get(effectiveSessionId());
                  if (entry?.activeSubagentIds?.has(item.tool_use_id)) {
                    entry.activeSubagentIds.delete(item.tool_use_id);
                    console.log(`๐Ÿค– [SUBAGENT] Completed: toolId=${item.tool_use_id} remaining=${entry.activeSubagentIds.size} sessionId=${effectiveSessionId() || 'none'}`);
                    if (entry.activeSubagentIds.size === 0) {
                      entry.subagentStartedAt = null;
                    }
                  }
                }
              }
            }

Step 2: Verify the change compiles

Run: cd claudecodeui && npx vite build 2>&1 | tail -5
Expected: Build succeeds (server code isn't bundled by Vite, but check for syntax errors via node -c server/claude-cli.js)

Run: cd claudecodeui && node -c server/claude-cli.js
Expected: No output (syntax OK)

Step 3: Commit

git add claudecodeui/server/claude-cli.js
git commit -m "feat(server): track active sub-agents in process entry (issue #41)"

Task 2: Server โ€” Sub-Agent-Aware Heartbeat

Files:

  • Modify: claudecodeui/server/claude-cli.js:2360-2381

Step 1: Modify the heartbeat to send sub-agent-specific messages

Replace the heartbeat interval handler (lines 2364-2381) with sub-agent awareness. The key change: when sub-agents are active, always send a heartbeat with varying text (includes elapsed seconds) to bypass client-side dedup:

      entry.heartbeatTimer = setInterval(() => {
        const current = activeClaudeProcesses.get(sid);
        if (!current) { stopHeartbeat(sid); return; }

        const hasActiveSubagents = current.activeSubagentIds?.size > 0;

        // For sub-agents: always send (they need keepalive). For normal: only if quiet.
        if (!hasActiveSubagents) {
          const quietThreshold = HEARTBEAT_INTERVAL_MS * 0.8;
          if (Date.now() - current.lastActivityAt < quietThreshold) return;
        }

        let statusText;
        let dataPayload;

        if (hasActiveSubagents) {
          const elapsed = Math.round((Date.now() - (current.subagentStartedAt || Date.now())) / 1000);
          const count = current.activeSubagentIds.size;
          statusText = count > 1
            ? `${count} sub-agents processing... (${elapsed}s)`
            : `Sub-agent processing... (${elapsed}s)`;
          dataPayload = {
            type: 'subagent-heartbeat',
            message: statusText,
            subagentCount: count,
            elapsedSeconds: elapsed
          };
        } else {
          statusText = current.lastStatus?.message || 'Processing...';
          dataPayload = { type: 'heartbeat', message: statusText };
        }

        console.log(`๐Ÿ’“ [HEARTBEAT] session=${sid} status="${statusText}"${hasActiveSubagents ? ' (subagent)' : ''}`);
        wsHolder.send({
          type: 'claude-status',
          sessionId: sid,
          projectName: projectNameDerived || null,
          conversationId: conversationIdForEvent,
          data: dataPayload,
          ts: Date.now(),
          traceId: traceId || null
        }, { context: 'heartbeat' });
      }, HEARTBEAT_INTERVAL_MS);

Step 2: Verify syntax

Run: cd claudecodeui && node -c server/claude-cli.js
Expected: No output (syntax OK)

Step 3: Commit

git add claudecodeui/server/claude-cli.js
git commit -m "feat(server): sub-agent-aware heartbeat with varying status text (issue #41)"

Task 3: Client โ€” Track Active Sub-Agent Sessions and Extend Stall Timeout

Files:

  • Modify: claudecodeui/src/hooks/useProjectWebSocketV2.js:60-65,375-427,1003-1042,1170-1200,1207-1230

Step 1: Add sub-agent timeout constant and tracking ref

After STREAM_STALL_TIMEOUT_MS (line 65), add:

  const SUBAGENT_STALL_TIMEOUT_MS = 600000; // 10 min for sub-agent runs (fix #41)

After streamedToolIdsRef (line 77), add:

  const activeSubagentSessionsRef = useRef(new Set()); // sessions with running sub-agents (fix #41)

Step 2: Modify scheduleStallWatchdog() to use extended timeout

In scheduleStallWatchdog() at line 375, replace the timeout value. Change line 427:

      }, STREAM_STALL_TIMEOUT_MS);

to:

      }, activeSubagentSessionsRef.current.has(sid) ? SUBAGENT_STALL_TIMEOUT_MS : STREAM_STALL_TIMEOUT_MS);

Also update the debug log at line 382 to include the actual timeout used:

        const timeoutMs = activeSubagentSessionsRef.current.has(sid) ? SUBAGENT_STALL_TIMEOUT_MS : STREAM_STALL_TIMEOUT_MS;
        debugLog('stream', 'stall-timeout', { sid, reason, ms: timeoutMs });

Step 3: Track Task tool_use as sub-agent session

In the tool_use emission block (around line 1020-1038), after streamedToolIdsRef.current.add(part.id); (line 1023), add:

                // Track sub-agent sessions for extended stall timeout (fix #41)
                if (part.name === 'Task') {
                  activeSubagentSessionsRef.current.add(sid);
                  // Reschedule stall watchdog with extended timeout immediately
                  scheduleStallWatchdog(sid, 'subagent-started');
                }

Step 4: Handle sub-agent heartbeat in claude-status handler

In the claude-status handler (line 1170), after the text extraction (line 1178) but before the if (text) check (line 1179), add handling for subagent-heartbeat type:

        // Sub-agent heartbeat: ensure session stays in sub-agent tracking (fix #41)
        if (statusData.type === 'subagent-heartbeat' || statusData.type === 'subagent-started') {
          activeSubagentSessionsRef.current.add(latestMessage.sessionId);
        }

Step 5: Clear sub-agent tracking on stream completion

In the claude-complete handler (around line 1207), after the existing cleanup, add:

        activeSubagentSessionsRef.current.delete(sid);

Also in endStream() function body, when the stall timeout fires and calls endStream(), if the session has active sub-agents, do NOT actually end the stream. Modify the stall timeout handler (around line 421) โ€” before the endStream(sid, isCurrentlyViewing); call at line 421:

        // Don't end stream if sub-agents are still active (fix #41)
        if (activeSubagentSessionsRef.current.has(sid)) {
          debugLog('stream', 'stall-timeout-suppressed', { sid, reason: 'active-subagent' });
          // Reschedule with extended timeout
          scheduleStallWatchdog(sid, 'subagent-active');
          return;
        }

Also in streamedToolIdsRef.current.clear() calls (lines 1326 and 1975), add adjacent cleanup:

        activeSubagentSessionsRef.current.delete(sid);

Step 6: Verify build

Run: cd claudecodeui && npx vite build 2>&1 | tail -5
Expected: Build succeeds

Step 7: Commit

git add claudecodeui/src/hooks/useProjectWebSocketV2.js
git commit -m "feat(ui): extend stall timeout for sessions with active sub-agents (issue #41)"

Task 4: Client โ€” Add Task Tool Badge Label and Elapsed Timer

Files:

  • Modify: claudecodeui/src/components/ChatInterface.jsx:588-617,1142-1198

Step 1: Add Task tool badge label

In getToolBadgeLabel() (line 588), add a case for Task before the default at line 615:

    case 'Task':
      return 'Tool ยท Sub-Agent';
    case 'Skill':
      return 'Tool ยท Skill';

Step 2: Add Task tool description extraction

Check if getToolDescription() (line 545) handles Task. If not, ensure the descriptions object includes a Task entry. Look at getToolContext() to see how it extracts context from tool input. Add to the descriptions object in getToolDescription():

    'Task': `${context || 'Running sub-agent'}`,

And in getToolContext(), add handling for Task tool input:

    case 'Task': {
      try {
        const parsed = typeof toolInput === 'string' ? JSON.parse(toolInput) : toolInput;
        return parsed?.description || parsed?.prompt?.slice(0, 60) || null;
      } catch { return null; }
    }

Step 3: Add elapsed time display for Task tool bubbles

In the tool bubble rendering (around line 1153-1182), for Task tool specifically when toolResultMissing is true, the existing spinner already shows. The elapsed time is already computed via toolExecutions and getElapsedTime (lines 1242-1248). However, since toolExecutions may not have an entry for streamed tool_use events (they're added during streaming), we need to use the message timestamp as fallback.

After the spinner SVG block (line 1176-1179), within the same !showTools conditional, add elapsed time display for sub-agents:

                        {message.toolName === 'Task' && message.toolResultMissing && (
                          <SubAgentElapsedTimer startTime={message.timestamp} />
                        )}

Step 4: Create SubAgentElapsedTimer component

Add this as a simple function component near the top of ChatInterface.jsx (around line 540, near the other helper functions):

// Elapsed timer for sub-agent tool bubbles โ€” updates every second (fix #41)
const SubAgentElapsedTimer = ({ startTime }) => {
  const [elapsed, setElapsed] = React.useState(0);
  React.useEffect(() => {
    const start = typeof startTime === 'string' ? new Date(startTime).getTime() : (startTime || Date.now());
    const update = () => setElapsed(Math.round((Date.now() - start) / 1000));
    update();
    const interval = setInterval(update, 1000);
    return () => clearInterval(interval);
  }, [startTime]);
  if (elapsed < 2) return null; // Don't show for first 2 seconds
  const mins = Math.floor(elapsed / 60);
  const secs = elapsed % 60;
  const display = mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
  return <span className="text-[10px] text-blue-400 dark:text-blue-500 ml-1">{display}</span>;
};

Step 5: Verify build

Run: cd claudecodeui && npx vite build 2>&1 | tail -5
Expected: Build succeeds

Step 6: Commit

git add claudecodeui/src/components/ChatInterface.jsx
git commit -m "feat(ui): add sub-agent badge label and elapsed timer on tool bubbles (issue #41)"

Task 5: Integration Test in Docker

Step 1: Build and run Docker container

Run: cd /Users/lindsay/Documents/work/sasha && ./docker-local.sh
Expected: Container starts on http://localhost:3006

Step 2: Trigger a sub-agent session

In the Sasha chat, send a message that triggers Claude to spawn a sub-agent (Task tool). A good prompt:

"Search the codebase for all files that mention 'heartbeat' and summarize what you find."

This should cause Claude to use the Task tool with an Explore agent.

Step 3: Verify behavior

  1. Watch for the tool bubble to appear with "Tool - Sub-Agent" badge
  2. Watch for the elapsed timer counting up (e.g., "12s", "1m 3s")
  3. Check browser console for [HEARTBEAT] messages with (subagent) suffix
  4. Verify the streaming indicator (spinner) stays active throughout
  5. Verify no stall timeout in debug logs (stream-stall-timeout event should NOT appear)
  6. Verify the tool bubble shows completion checkmark when sub-agent returns

Step 4: Check server logs

Run: Check docker container logs for [SUBAGENT] Started: and [HEARTBEAT] lines.

Step 5: Commit any fixes discovered during testing


Task 6: Update Icon Registry and Documentation

Step 1: Check if any new icons were added

The SubAgentElapsedTimer uses no new icons โ€” it reuses existing text styling. No icon registry update needed.

Step 2: Update the chat processing indicators doc

Modify: claudecodeui/docs-developer/features/chat-processing-indicators.md

Add a section about sub-agent visibility:

  • Sub-agent heartbeat mechanism
  • Extended stall timeout behavior
  • Elapsed timer on Task tool bubbles

Step 3: Commit

git add claudecodeui/docs-developer/features/chat-processing-indicators.md
git commit -m "docs: document sub-agent visibility behavior (issue #41)"