diff --git a/v2/src/lib/agent-dispatcher.ts b/v2/src/lib/agent-dispatcher.ts index 93e5a21..9b84dec 100644 --- a/v2/src/lib/agent-dispatcher.ts +++ b/v2/src/lib/agent-dispatcher.ts @@ -3,7 +3,7 @@ import { onSidecarMessage, onSidecarExited, restartAgent, type SidecarMessage } from './adapters/agent-bridge'; import { adaptSDKMessage } from './adapters/sdk-messages'; -import type { InitContent, CostContent } from './adapters/sdk-messages'; +import type { InitContent, CostContent, ToolCallContent } from './adapters/sdk-messages'; import { updateAgentStatus, setAgentSdkSessionId, @@ -11,7 +11,10 @@ import { appendAgentMessages, updateAgentCost, getAgentSessions, + createAgentSession, + findChildByToolUseId, } from './stores/agents.svelte'; +import { addPane, getPanes } from './stores/layout.svelte'; import { notify } from './stores/notifications.svelte'; let unlistenMsg: (() => void) | null = null; @@ -100,10 +103,31 @@ export async function startAgentDispatcher(): Promise { }); } +// Tool names that indicate a subagent spawn +const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task', 'dispatch_agent']); + +// Map toolUseId -> child session pane id for routing +const toolUseToChildPane = new Map(); + function handleAgentEvent(sessionId: string, event: Record): void { const messages = adaptSDKMessage(event); + // Route messages with parentId to the appropriate child pane + const mainMessages: typeof messages = []; + const childBuckets = new Map(); + for (const msg of messages) { + if (msg.parentId && toolUseToChildPane.has(msg.parentId)) { + const childPaneId = toolUseToChildPane.get(msg.parentId)!; + if (!childBuckets.has(childPaneId)) childBuckets.set(childPaneId, []); + childBuckets.get(childPaneId)!.push(msg); + } else { + mainMessages.push(msg); + } + } + + // Process main session messages + for (const msg of mainMessages) { switch (msg.type) { case 'init': { const init = msg.content as InitContent; @@ -112,6 +136,14 @@ function handleAgentEvent(sessionId: string, event: Record): vo break; } + case 'tool_call': { + const tc = msg.content as ToolCallContent; + if (SUBAGENT_TOOL_NAMES.has(tc.name)) { + spawnSubagentPane(sessionId, tc); + } + break; + } + case 'cost': { const cost = msg.content as CostContent; updateAgentCost(sessionId, { @@ -133,9 +165,70 @@ function handleAgentEvent(sessionId: string, event: Record): vo } } - if (messages.length > 0) { - appendAgentMessages(sessionId, messages); + if (mainMessages.length > 0) { + appendAgentMessages(sessionId, mainMessages); } + + // Append messages to child panes and update their status + for (const [childPaneId, childMsgs] of childBuckets) { + for (const msg of childMsgs) { + if (msg.type === 'init') { + const init = msg.content as InitContent; + setAgentSdkSessionId(childPaneId, init.sessionId); + setAgentModel(childPaneId, init.model); + updateAgentStatus(childPaneId, 'running'); + } else if (msg.type === 'cost') { + const cost = msg.content as CostContent; + updateAgentCost(childPaneId, { + costUsd: cost.totalCostUsd, + inputTokens: cost.inputTokens, + outputTokens: cost.outputTokens, + numTurns: cost.numTurns, + durationMs: cost.durationMs, + }); + updateAgentStatus(childPaneId, cost.isError ? 'error' : 'done'); + } + } + appendAgentMessages(childPaneId, childMsgs); + } +} + +function spawnSubagentPane(parentSessionId: string, tc: ToolCallContent): void { + // Don't create duplicate pane for same tool_use + if (toolUseToChildPane.has(tc.toolUseId)) return; + const existing = findChildByToolUseId(parentSessionId, tc.toolUseId); + if (existing) { + toolUseToChildPane.set(tc.toolUseId, existing.id); + return; + } + + const childId = crypto.randomUUID(); + const prompt = typeof tc.input === 'object' && tc.input !== null + ? (tc.input as Record).prompt as string ?? tc.name + : tc.name; + const label = typeof tc.input === 'object' && tc.input !== null + ? (tc.input as Record).name as string ?? tc.name + : tc.name; + + // Register routing + toolUseToChildPane.set(tc.toolUseId, childId); + + // Create agent session with parent link + createAgentSession(childId, prompt, { + sessionId: parentSessionId, + toolUseId: tc.toolUseId, + }); + updateAgentStatus(childId, 'running'); + + // Create layout pane, auto-grouped under parent's title + const parentPane = getPanes().find(p => p.id === parentSessionId); + const groupName = parentPane?.title ?? `Agent ${parentSessionId.slice(0, 8)}`; + addPane({ + id: childId, + type: 'agent', + title: `Sub: ${label}`, + group: groupName, + }); } export function stopAgentDispatcher(): void { diff --git a/v2/src/lib/components/Agent/AgentPane.svelte b/v2/src/lib/components/Agent/AgentPane.svelte index f92fb53..e4a4670 100644 --- a/v2/src/lib/components/Agent/AgentPane.svelte +++ b/v2/src/lib/components/Agent/AgentPane.svelte @@ -6,8 +6,10 @@ getAgentSession, createAgentSession, removeAgentSession, + getChildSessions, type AgentSession, } from '../../stores/agents.svelte'; + import { focusPane } from '../../stores/layout.svelte'; import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher'; import AgentTree from './AgentTree.svelte'; import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight'; @@ -37,6 +39,8 @@ let restarting = $state(false); let showTree = $state(false); let hasToolCalls = $derived(session?.messages.some(m => m.type === 'tool_call') ?? false); + let parentSession = $derived(session?.parentSessionId ? getAgentSession(session.parentSessionId) : undefined); + let childSessions = $derived(session ? getChildSessions(session.id) : []); const mdRenderer = new Renderer(); mdRenderer.code = function({ text, lang }: { text: string; lang?: string }) { @@ -188,6 +192,24 @@ {:else} + {#if parentSession} + + {/if} + {#if childSessions.length > 0} +
+ {childSessions.length} subagent{childSessions.length > 1 ? 's' : ''} + {#each childSessions as child (child.id)} + + {/each} +
+ {/if} {#if hasToolCalls}
diff --git a/v2/src/lib/stores/agents.svelte.ts b/v2/src/lib/stores/agents.svelte.ts index a9003ae..323c1ac 100644 --- a/v2/src/lib/stores/agents.svelte.ts +++ b/v2/src/lib/stores/agents.svelte.ts @@ -18,6 +18,10 @@ export interface AgentSession { numTurns: number; durationMs: number; error?: string; + // Agent Teams: parent/child hierarchy + parentSessionId?: string; + parentToolUseId?: string; + childSessionIds: string[]; } let sessions = $state([]); @@ -30,7 +34,7 @@ export function getAgentSession(id: string): AgentSession | undefined { return sessions.find(s => s.id === id); } -export function createAgentSession(id: string, prompt: string): void { +export function createAgentSession(id: string, prompt: string, parent?: { sessionId: string; toolUseId: string }): void { sessions.push({ id, status: 'starting', @@ -41,7 +45,18 @@ export function createAgentSession(id: string, prompt: string): void { outputTokens: 0, numTurns: 0, durationMs: 0, + parentSessionId: parent?.sessionId, + parentToolUseId: parent?.toolUseId, + childSessionIds: [], }); + + // Register as child of parent + if (parent) { + const parentSession = sessions.find(s => s.id === parent.sessionId); + if (parentSession) { + parentSession.childSessionIds.push(id); + } + } } export function updateAgentStatus(id: string, status: AgentStatus, error?: string): void { @@ -86,6 +101,24 @@ export function updateAgentCost( session.durationMs = cost.durationMs; } +/** Find a child session that was spawned by a specific tool_use */ +export function findChildByToolUseId(parentId: string, toolUseId: string): AgentSession | undefined { + return sessions.find(s => s.parentSessionId === parentId && s.parentToolUseId === toolUseId); +} + +/** Get all child sessions for a given parent */ +export function getChildSessions(parentId: string): AgentSession[] { + return sessions.filter(s => s.parentSessionId === parentId); +} + export function removeAgentSession(id: string): void { + // Also remove from parent's childSessionIds + const session = sessions.find(s => s.id === id); + if (session?.parentSessionId) { + const parent = sessions.find(s => s.id === session.parentSessionId); + if (parent) { + parent.childSessionIds = parent.childSessionIds.filter(cid => cid !== id); + } + } sessions = sessions.filter(s => s.id !== id); }