// Current-native subagent (child Session) tracking for the mini transport. // // Discovers child Sessions of the active parent from four current sources: // 1. projected subagent tool output (`structured.sessionID`) during hydration // 2. the current session list filtered by `parentID` during hydration // 3. the process-local active-session map during hydration // 4. live events from unknown sessions whose `parentID` matches the parent // // Tracks one footer tab per child and a detail transcript for the selected // child, reduced from the same current live event stream the parent uses. // Detail transcripts rebuild from projected messages on discovery, selection, // and reconnect, then continue from live deltas using the same // projected-prefix dedup the parent transport uses. // // Per-child interruption uses `v2.session.interrupt(childID)`. Per-child // backgrounding is intentionally absent: subagent jobs block the parent // session, so only whole-session `v2.session.background(parentID)` exists. import type { EventSubscribeOutput, OpenCodeClient } from "@opencode-ai/client/promise" import type { SessionMessageAssistantTool, SessionMessageInfo, ToolPart } from "@opencode-ai/sdk/v2" import { Locale } from "@opencode-ai/tui/util/locale" import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types" const CHILD_MESSAGE_LIMIT = 80 const CHILD_FRAME_LIMIT = 80 const CHILD_EVENT_BUFFER_LIMIT = 64 const FAMILY_LIST_LIMIT = 100 const FALLBACK_LABEL = "Subagent" type V2Event = EventSubscribeOutput export function outputText(content: ReadonlyArray<{ type: string; text?: string }>) { return content.flatMap((item) => (item.type === "text" && item.text ? [item.text] : [])).join("\n") } export function legacyTool(input: { sessionID: string messageID: string tool: SessionMessageAssistantTool }): ToolPart { const tool = input.tool const providerCall = tool.executed === undefined && tool.providerState === undefined ? undefined : { executed: tool.executed, state: tool.providerState } const providerResult = tool.executed === undefined && tool.providerResultState === undefined ? undefined : { executed: tool.executed, state: tool.providerResultState } const base = { id: `prt_${tool.id}`, sessionID: input.sessionID, messageID: input.messageID, type: "tool" as const, callID: tool.id, tool: tool.name, } if (tool.state.status === "streaming") { return { ...base, state: { status: "pending", input: {}, raw: tool.state.input }, } } if (tool.state.status === "running") { return { ...base, state: { status: "running", input: tool.state.input, title: tool.name, metadata: { structured: tool.state.structured, content: tool.state.content, providerCall }, time: { start: tool.time.ran ?? tool.time.created }, }, } } if (tool.state.status === "completed") { return { ...base, state: { status: "completed", input: tool.state.input, output: outputText(tool.state.content), title: tool.name, metadata: { structured: tool.state.structured, content: tool.state.content, result: tool.state.result, providerCall, providerResult, }, time: { start: tool.time.ran ?? tool.time.created, end: tool.time.completed ?? tool.time.created }, }, } } return { ...base, state: { status: "error", input: tool.state.input, error: tool.state.error.message, metadata: { structured: tool.state.structured, content: tool.state.content, result: tool.state.result, providerCall, providerResult, }, time: { start: tool.time.ran ?? tool.time.created, end: tool.time.completed ?? tool.time.created }, }, } } export function toolCommit(part: ToolPart, phase: "start" | "progress" | "final"): StreamCommit { const status = part.state.status const text = status === "running" ? part.tool === "task" ? "running task" : `running ${part.tool}` : status === "completed" ? part.state.output : status === "error" ? part.state.error : "" return { kind: "tool", source: "tool", text, phase, messageID: part.messageID, partID: part.id, tool: part.tool, part, toolState: status === "error" ? "error" : status === "completed" ? "completed" : "running", toolError: status === "error" ? part.state.error : undefined, } } type Frame = { key: string commit: StreamCommit } type ToolTrack = { name: string input: Record started: number providerState?: Record } type ChildState = { sessionID: string label: string description: string status: FooterSubagentTab["status"] background: boolean title?: string callIDs: Set lastUpdatedAt: number frames: Frame[] text: Map projectedText: Map reasoning: Map projectedReasoning: Map tools: Map finishedTools: Set messageIDs: Set prompts: Map hydrated: boolean } export type SubagentTrackerInput = { sdk: OpenCodeClient sessionID: string thinking: boolean emit: () => void } export type SubagentTracker = { main(event: V2Event): void foreign(sessionID: string, event: V2Event): void hydrate(next: { messages: SessionMessageInfo[]; active: Record }): Promise select(sessionID: string | undefined): void snapshot(): FooterSubagentState } function record(value: unknown): Record | undefined { if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record return undefined } function text(value: unknown): string | undefined { if (typeof value !== "string") return undefined const next = value.trim() return next || undefined } function childSessionID(structured: Record | undefined) { const sessionID = text(structured?.sessionID) if (!sessionID || !sessionID.startsWith("ses")) return undefined const status = structured?.status if (status !== "running" && status !== "completed") return undefined return { sessionID, running: status === "running" } } function tab(child: ChildState): FooterSubagentTab { return { sessionID: child.sessionID, partID: `subagent:${child.sessionID}`, callID: `subagent:${child.sessionID}`, label: child.label, description: child.description || child.title || "", status: child.status, background: child.background ? true : undefined, title: child.title, toolCalls: child.callIDs.size > 0 ? child.callIDs.size : undefined, lastUpdatedAt: child.lastUpdatedAt, } } export function createSubagentTracker(input: SubagentTrackerInput): SubagentTracker { const children = new Map() // Live subagent tool calls in the parent, so tool.success structured output // can be joined with the call's input metadata. const pendingCalls = new Map>() // Foreign sessions already resolved through session.get. Non-children stay // cached so unrelated concurrent sessions are checked at most once. const checked = new Set() // Foreign events buffered while a session.get discovery is in flight, so a // fast child (including its settled event) is not lost mid-discovery. const pendingEvents = new Map() const hydrationEvents = new Map() const hydrationOverflow = new Set() const hydrations = new Map>() let selected: string | undefined const fragmentKey = (messageID: string, partID: string) => `${messageID}\u0000${partID}` const ensureChild = (sessionID: string): ChildState => { const existing = children.get(sessionID) const child: ChildState = existing ?? { sessionID, label: FALLBACK_LABEL, description: "", status: "running", background: false, callIDs: new Set(), lastUpdatedAt: Date.now(), frames: [], text: new Map(), projectedText: new Map(), reasoning: new Map(), projectedReasoning: new Map(), tools: new Map(), finishedTools: new Set(), messageIDs: new Set(), prompts: new Map(), hydrated: false, } if (!existing) children.set(sessionID, child) // Adopting a child while its session.get discovery is still in flight: // drain the buffered events now. They arrived before whatever the caller // applies next, so replaying them first preserves bus order, and the // resolved discovery can no longer replay stale events (e.g. step.started) // after a terminal settled event was applied directly. const buffered = pendingEvents.get(sessionID) if (buffered) { pendingEvents.delete(sessionID) for (const event of buffered) reduce(child, event) } return child } const touch = (child: ChildState, timestamp?: number) => { child.lastUpdatedAt = Math.max(child.lastUpdatedAt, timestamp ?? Date.now()) } const notifyDetail = (child: ChildState) => { if (child.sessionID === selected) input.emit() } const setFrame = (child: ChildState, key: string, commit: StreamCommit) => { const index = child.frames.findIndex((item) => item.key === key) if (index === -1) { child.frames.push({ key, commit }) if (child.frames.length > CHILD_FRAME_LIMIT) child.frames.splice(0, child.frames.length - CHILD_FRAME_LIMIT) return } child.frames[index] = { key, commit } } const applyMeta = (child: ChildState, meta: Record | undefined) => { if (!meta) return const agent = text(meta.agent) if (agent) child.label = Locale.titlecase(agent) const description = text(meta.description) if (description) child.description = description if (meta.background === true) child.background = true } const userFrame = (child: ChildState, messageID: string, value: string) => { if (child.messageIDs.has(messageID)) return false child.messageIDs.add(messageID) setFrame(child, `user:${messageID}`, { kind: "user", source: "system", text: value, phase: "start", messageID, }) return true } const childTool = (child: ChildState, item: SessionMessageAssistantTool, messageID: string) => { const part = legacyTool({ sessionID: child.sessionID, messageID, tool: item, }) if (item.state.status === "streaming") return child.callIDs.add(item.id) if (item.state.status === "running") { setFrame(child, `tool:${item.id}`, toolCommit(part, "start")) return } child.finishedTools.add(item.id) child.tools.delete(item.id) setFrame(child, `tool:${item.id}`, toolCommit(part, "final")) } const rebuild = (child: ChildState, messages: SessionMessageInfo[]) => { child.frames = [] child.text.clear() child.projectedText.clear() child.reasoning.clear() child.projectedReasoning.clear() child.finishedTools.clear() child.messageIDs.clear() child.callIDs.clear() for (const message of messages) { if (message.type === "user") { child.prompts.delete(message.id) userFrame(child, message.id, message.text) continue } if (message.type !== "assistant") continue child.messageIDs.add(message.id) let textOrdinal = 0 let reasoningOrdinal = 0 for (const item of message.content) { if (item.type === "text") { const id = `text:${textOrdinal++}` const key = fragmentKey(message.id, id) child.text.set(key, item.text) child.projectedText.set(key, item.text) setFrame(child, key, { kind: "assistant", source: "assistant", text: item.text, phase: "progress", messageID: message.id, partID: id, }) continue } if (item.type === "reasoning") { const id = `reasoning:${reasoningOrdinal++}` const key = fragmentKey(message.id, id) child.reasoning.set(key, item.text) child.projectedReasoning.set(key, item.text) if (input.thinking) setFrame(child, key, { kind: "reasoning", source: "reasoning", text: `Thinking: ${item.text}`, phase: "progress", messageID: message.id, partID: id, }) continue } childTool(child, item, message.id) } if (message.error) { setFrame(child, `error:${message.id}`, { kind: "error", source: "system", text: message.error.message, phase: "start", messageID: message.id, }) } } } const hydrateChild = (child: ChildState): Promise => { const existing = hydrations.get(child.sessionID) if (existing) return existing const pendingPrompts = new Map(child.prompts) const pendingTools = new Map(child.tools) let retry = false const task = input.sdk.message .list({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }) .then((response) => { const buffered = hydrationEvents.get(child.sessionID) ?? [] hydrationEvents.delete(child.sessionID) if (hydrationOverflow.delete(child.sessionID)) { child.hydrated = false retry = true notifyDetail(child) return } for (const [id, prompt] of pendingPrompts) { if (!child.prompts.has(id)) child.prompts.set(id, prompt) } rebuild(child, structuredClone(response.data).toReversed() as SessionMessageInfo[]) for (const [id, tool] of pendingTools) { if (!child.finishedTools.has(id) && !child.tools.has(id)) child.tools.set(id, tool) } for (const event of buffered) reduce(child, event) child.hydrated = true notifyDetail(child) }) .catch(() => { hydrationEvents.delete(child.sessionID) hydrationOverflow.delete(child.sessionID) }) .finally(() => { hydrations.delete(child.sessionID) if (retry) queueMicrotask(() => void hydrateChild(child)) }) hydrations.set(child.sessionID, task) return task } const discover = (sessionID: string) => { if (checked.has(sessionID) || children.has(sessionID) || sessionID === input.sessionID) return checked.add(sessionID) if (!pendingEvents.has(sessionID)) pendingEvents.set(sessionID, []) void input.sdk.session .get({ sessionID }) .then((session) => { const buffered = pendingEvents.get(sessionID) ?? [] pendingEvents.delete(sessionID) if (session.parentID !== input.sessionID) return const child = ensureChild(sessionID) if (session.agent) child.label = Locale.titlecase(session.agent) child.title = session.title for (const event of buffered) reduce(child, event) touch(child) input.emit() void hydrateChild(child) }) .catch(() => { // Allow a later event to retry discovery after transient failures. pendingEvents.delete(sessionID) checked.delete(sessionID) }) } const reduce = (child: ChildState, event: V2Event) => { if (event.type === "session.input.admitted") { if (event.data.input.type === "user") child.prompts.set(event.data.inputID, event.data.input.data.text) return } if (event.type === "session.input.promoted") { const prompt = child.prompts.get(event.data.inputID) if (prompt === undefined) return child.prompts.delete(event.data.inputID) if (userFrame(child, event.data.inputID, prompt)) { touch(child, event.created) notifyDetail(child) } return } if (event.type === "session.step.started") { touch(child, event.created) if (child.label === FALLBACK_LABEL && event.data.agent) child.label = Locale.titlecase(event.data.agent) if (child.status !== "running") child.status = "running" input.emit() return } if (event.type === "session.text.started") { return } if (event.type === "session.text.delta") { const id = `text:${event.data.ordinal}` const key = fragmentKey(event.data.assistantMessageID, id) const projected = child.projectedText.get(key) const covered = projected?.indexOf(event.data.delta) ?? -1 if (projected && covered >= 0) { child.projectedText.set(key, projected.slice(covered + event.data.delta.length)) return } const next = (child.text.get(key) ?? "") + event.data.delta child.text.set(key, next) setFrame(child, key, { kind: "assistant", source: "assistant", text: next, phase: "progress", messageID: event.data.assistantMessageID, partID: id, }) touch(child, event.created) notifyDetail(child) return } if (event.type === "session.text.ended") { const id = `text:${event.data.ordinal}` const key = fragmentKey(event.data.assistantMessageID, id) child.text.set(key, event.data.text) child.projectedText.delete(key) setFrame(child, key, { kind: "assistant", source: "assistant", text: event.data.text, phase: "progress", messageID: event.data.assistantMessageID, partID: id, }) touch(child, event.created) notifyDetail(child) return } if (event.type === "session.reasoning.started") { return } if (event.type === "session.reasoning.delta") { const id = `reasoning:${event.data.ordinal}` const key = fragmentKey(event.data.assistantMessageID, id) const projected = child.projectedReasoning.get(key) const covered = projected?.indexOf(event.data.delta) ?? -1 if (projected && covered >= 0) { child.projectedReasoning.set(key, projected.slice(covered + event.data.delta.length)) return } const next = (child.reasoning.get(key) ?? "") + event.data.delta child.reasoning.set(key, next) if (!input.thinking) return setFrame(child, key, { kind: "reasoning", source: "reasoning", text: `Thinking: ${next}`, phase: "progress", messageID: event.data.assistantMessageID, partID: id, }) notifyDetail(child) return } if (event.type === "session.reasoning.ended") { const id = `reasoning:${event.data.ordinal}` const key = fragmentKey(event.data.assistantMessageID, id) child.reasoning.set(key, event.data.text) child.projectedReasoning.delete(key) if (!input.thinking) return setFrame(child, key, { kind: "reasoning", source: "reasoning", text: `Thinking: ${event.data.text}`, phase: "progress", messageID: event.data.assistantMessageID, partID: id, }) notifyDetail(child) return } if (event.type === "session.tool.input.started") { if (child.finishedTools.has(event.data.callID)) return child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.created }) return } if (event.type === "session.tool.called") { if (child.finishedTools.has(event.data.callID)) return const current = child.tools.get(event.data.callID) child.tools.set(event.data.callID, { name: current?.name ?? "tool", input: event.data.input, started: current?.started ?? event.created, providerState: event.data.state, }) childTool( child, structuredClone({ type: "tool", id: event.data.callID, name: current?.name ?? "tool", executed: event.data.executed, providerState: event.data.state, state: { status: "running", input: event.data.input, structured: {}, content: [] }, time: { created: current?.started ?? event.created, ran: event.created }, }) as SessionMessageAssistantTool, event.data.assistantMessageID, ) touch(child, event.created) notifyDetail(child) return } if (event.type === "session.tool.success" || event.type === "session.tool.failed") { if (child.finishedTools.has(event.data.callID)) return const current = child.tools.get(event.data.callID) const failed = event.type === "session.tool.failed" childTool( child, structuredClone({ type: "tool", id: event.data.callID, name: current?.name ?? "tool", executed: event.data.executed, providerState: current?.providerState, providerResultState: event.data.resultState, state: failed ? { status: "error", input: current?.input ?? {}, structured: {}, content: [], error: event.data.error, result: event.data.result, } : { status: "completed", input: current?.input ?? {}, structured: event.data.structured, content: event.data.content, result: event.data.result, }, time: { created: current?.started ?? event.created, ran: current?.started, completed: event.created, }, }) as SessionMessageAssistantTool, event.data.assistantMessageID, ) touch(child, event.created) notifyDetail(child) return } if (event.type === "session.step.ended") return if (event.type === "session.step.failed") { setFrame(child, `error:step:${event.data.assistantMessageID}`, { kind: "error", source: "system", text: event.data.error.message, phase: "start", messageID: event.data.assistantMessageID, }) touch(child, event.created) notifyDetail(child) return } if (event.type === "session.execution.started") { child.status = "running" touch(child, event.created) input.emit() return } if ( event.type === "session.execution.succeeded" || event.type === "session.execution.failed" || event.type === "session.execution.interrupted" ) { child.status = event.type === "session.execution.succeeded" ? "completed" : event.type === "session.execution.interrupted" ? "cancelled" : "error" touch(child, event.created) input.emit() } } const mainTool = (item: SessionMessageAssistantTool, active?: Record) => { if (item.name !== "subagent" || item.state.status !== "completed") return const found = childSessionID(record(item.state.structured)) if (!found) return const child = ensureChild(found.sessionID) applyMeta(child, record(item.state.input)) if (found.running) child.background = true if (child.status === "running") { const running = found.running && (!active || found.sessionID in active) child.status = running ? "running" : "completed" } touch(child, item.time.completed ?? item.time.created) } return { main(event) { if (event.type === "session.tool.input.started") { if (event.data.name === "subagent") pendingCalls.set(event.data.callID, {}) return } if (event.type === "session.tool.called") { if (pendingCalls.has(event.data.callID)) pendingCalls.set(event.data.callID, event.data.input) return } if (event.type === "session.tool.failed") { pendingCalls.delete(event.data.callID) return } if (event.type !== "session.tool.success") return const pending = pendingCalls.get(event.data.callID) pendingCalls.delete(event.data.callID) const found = childSessionID(record(event.data.structured)) if (!found) return const child = ensureChild(found.sessionID) applyMeta(child, pending) if (found.running) { child.background = true child.status = "running" } if (!found.running && child.status === "running") child.status = "completed" touch(child, event.created) input.emit() if (!child.hydrated) void hydrateChild(child) }, foreign(sessionID, event) { const child = children.get(sessionID) if (child) { if (hydrations.has(sessionID)) { const buffered = hydrationEvents.get(sessionID) ?? [] if (buffered.length < CHILD_EVENT_BUFFER_LIMIT) buffered.push(event) else hydrationOverflow.add(sessionID) hydrationEvents.set(sessionID, buffered) } reduce(child, event) return } discover(sessionID) const buffered = pendingEvents.get(sessionID) if (buffered && buffered.length < CHILD_EVENT_BUFFER_LIMIT) buffered.push(event) }, async hydrate(next) { for (const message of next.messages) { if (message.type !== "assistant") continue for (const item of message.content) { if (item.type === "tool") mainTool(item, next.active) } } // Family index: adopt children directly from the current session list so // historical subagents beyond the projected message window still get tabs. const family = await input.sdk.session .list({ parentID: input.sessionID, limit: FAMILY_LIST_LIMIT, order: "desc" }) .then((response) => response.data) .catch(() => []) for (const session of family) { const child = ensureChild(session.id) if (session.agent && child.label === FALLBACK_LABEL) child.label = Locale.titlecase(session.agent) if (!child.title) child.title = session.title touch(child, session.time.updated) } for (const sessionID of Object.keys(next.active)) discover(sessionID) for (const child of children.values()) { // Reconnect can miss a child's settled event; the active map is the // authoritative live signal for still-running children. if (child.status === "running" && !(child.sessionID in next.active)) child.status = "completed" } const current = selected ? children.get(selected) : undefined if (current) await hydrateChild(current) if (children.size > 0) input.emit() }, select(sessionID) { selected = sessionID const child = sessionID ? children.get(sessionID) : undefined if (child && !child.hydrated) void hydrateChild(child) input.emit() }, snapshot() { const tabs = [...children.values()].map(tab).toSorted((a, b) => { const active = Number(b.status === "running") - Number(a.status === "running") if (active !== 0) return active return b.lastUpdatedAt - a.lastUpdatedAt }) const child = selected ? children.get(selected) : undefined const details: Record = child ? { [child.sessionID]: { sessionID: child.sessionID, commits: child.frames.map((item) => item.commit) } } : {} return { tabs, details, permissions: [], questions: [] } }, } }