699 lines
24 KiB
TypeScript
699 lines
24 KiB
TypeScript
// 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 {
|
|
OpencodeClient,
|
|
SessionMessage,
|
|
SessionMessageAssistantTool,
|
|
ToolPart,
|
|
V2Event,
|
|
} from "@opencode-ai/sdk/v2"
|
|
import { Locale } from "@/util/locale"
|
|
import type { FooterSubagentDetail, FooterSubagentState, FooterSubagentTab, StreamCommit } from "./types"
|
|
|
|
const CHILD_MESSAGE_LIMIT = 80
|
|
const CHILD_FRAME_LIMIT = 80
|
|
const DISCOVERY_BUFFER_LIMIT = 64
|
|
const FAMILY_LIST_LIMIT = 100
|
|
const FALLBACK_LABEL = "Subagent"
|
|
|
|
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
|
|
callID: string
|
|
name: string
|
|
state: SessionMessageAssistantTool["state"]
|
|
time: SessionMessageAssistantTool["time"]
|
|
provider?: SessionMessageAssistantTool["provider"]
|
|
}): ToolPart {
|
|
const base = {
|
|
id: `prt_${input.callID}`,
|
|
sessionID: input.sessionID,
|
|
messageID: input.messageID,
|
|
type: "tool" as const,
|
|
callID: input.callID,
|
|
tool: input.name,
|
|
}
|
|
if (input.state.status === "pending") {
|
|
return {
|
|
...base,
|
|
state: { status: "pending", input: {}, raw: input.state.input },
|
|
}
|
|
}
|
|
if (input.state.status === "running") {
|
|
return {
|
|
...base,
|
|
state: {
|
|
status: "running",
|
|
input: input.state.input,
|
|
title: input.name,
|
|
metadata: { structured: input.state.structured, content: input.state.content, providerCall: input.provider },
|
|
time: { start: input.time.ran ?? input.time.created },
|
|
},
|
|
}
|
|
}
|
|
if (input.state.status === "completed") {
|
|
return {
|
|
...base,
|
|
state: {
|
|
status: "completed",
|
|
input: input.state.input,
|
|
output: outputText(input.state.content),
|
|
title: input.name,
|
|
metadata: {
|
|
structured: input.state.structured,
|
|
content: input.state.content,
|
|
outputPaths: input.state.outputPaths,
|
|
result: input.state.result,
|
|
providerCall: input.provider,
|
|
},
|
|
time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.time.created },
|
|
},
|
|
}
|
|
}
|
|
return {
|
|
...base,
|
|
state: {
|
|
status: "error",
|
|
input: input.state.input,
|
|
error: input.state.error.message,
|
|
metadata: {
|
|
structured: input.state.structured,
|
|
content: input.state.content,
|
|
result: input.state.result,
|
|
providerCall: input.provider,
|
|
},
|
|
time: { start: input.time.ran ?? input.time.created, end: input.time.completed ?? input.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<string, unknown>
|
|
started: number
|
|
}
|
|
|
|
type ChildState = {
|
|
sessionID: string
|
|
label: string
|
|
description: string
|
|
status: FooterSubagentTab["status"]
|
|
background: boolean
|
|
title?: string
|
|
callIDs: Set<string>
|
|
lastUpdatedAt: number
|
|
frames: Frame[]
|
|
text: Map<string, string>
|
|
projectedText: Map<string, string>
|
|
reasoning: Map<string, string>
|
|
projectedReasoning: Map<string, string>
|
|
tools: Map<string, ToolTrack>
|
|
finishedTools: Set<string>
|
|
messageIDs: Set<string>
|
|
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: SessionMessage[]; active: Record<string, unknown> }): Promise<void>
|
|
select(sessionID: string | undefined): void
|
|
snapshot(): FooterSubagentState
|
|
}
|
|
|
|
function record(value: unknown): Record<string, unknown> | undefined {
|
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record<string, unknown>
|
|
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<string, unknown> | 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<string, ChildState>()
|
|
// 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<string, Record<string, unknown>>()
|
|
// Foreign sessions already resolved through session.get. Non-children stay
|
|
// cached so unrelated concurrent sessions are checked at most once.
|
|
const checked = new Set<string>()
|
|
// 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<string, V2Event[]>()
|
|
const hydrations = new Map<string, Promise<void>>()
|
|
let selected: string | undefined
|
|
|
|
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(),
|
|
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<string, unknown> | 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,
|
|
callID: item.id,
|
|
name: item.name,
|
|
state: item.state,
|
|
time: item.time,
|
|
provider: item.provider,
|
|
})
|
|
if (item.state.status === "pending") 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: SessionMessage[]) => {
|
|
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") {
|
|
userFrame(child, message.id, message.text)
|
|
continue
|
|
}
|
|
if (message.type !== "assistant") continue
|
|
child.messageIDs.add(message.id)
|
|
for (const item of message.content) {
|
|
if (item.type === "text") {
|
|
child.text.set(item.id, item.text)
|
|
child.projectedText.set(item.id, item.text)
|
|
setFrame(child, `text:${item.id}`, {
|
|
kind: "assistant",
|
|
source: "assistant",
|
|
text: item.text,
|
|
phase: "progress",
|
|
messageID: message.id,
|
|
partID: item.id,
|
|
})
|
|
continue
|
|
}
|
|
if (item.type === "reasoning") {
|
|
child.reasoning.set(item.id, item.text)
|
|
child.projectedReasoning.set(item.id, item.text)
|
|
if (input.thinking)
|
|
setFrame(child, `reasoning:${item.id}`, {
|
|
kind: "reasoning",
|
|
source: "reasoning",
|
|
text: `Thinking: ${item.text}`,
|
|
phase: "progress",
|
|
messageID: message.id,
|
|
partID: item.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<void> => {
|
|
const existing = hydrations.get(child.sessionID)
|
|
if (existing) return existing
|
|
const task = input.sdk.v2.session
|
|
.messages({ sessionID: child.sessionID, limit: CHILD_MESSAGE_LIMIT, order: "desc" }, { throwOnError: true })
|
|
.then((response) => {
|
|
rebuild(child, response.data.data.toReversed())
|
|
child.hydrated = true
|
|
notifyDetail(child)
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
hydrations.delete(child.sessionID)
|
|
})
|
|
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.v2.session
|
|
.get({ sessionID }, { throwOnError: true })
|
|
.then((response) => {
|
|
const session = response.data.data
|
|
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.next.prompted") {
|
|
if (userFrame(child, event.data.messageID, event.data.prompt.text)) {
|
|
touch(child, event.data.timestamp)
|
|
notifyDetail(child)
|
|
}
|
|
return
|
|
}
|
|
if (event.type === "session.next.step.started") {
|
|
touch(child, event.data.timestamp)
|
|
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.next.text.delta") {
|
|
const projected = child.projectedText.get(event.data.textID)
|
|
const covered = projected?.indexOf(event.data.delta) ?? -1
|
|
if (projected && covered >= 0) {
|
|
child.projectedText.set(event.data.textID, projected.slice(covered + event.data.delta.length))
|
|
return
|
|
}
|
|
const next = (child.text.get(event.data.textID) ?? "") + event.data.delta
|
|
child.text.set(event.data.textID, next)
|
|
setFrame(child, `text:${event.data.textID}`, {
|
|
kind: "assistant",
|
|
source: "assistant",
|
|
text: next,
|
|
phase: "progress",
|
|
messageID: event.data.assistantMessageID,
|
|
partID: event.data.textID,
|
|
})
|
|
touch(child, event.data.timestamp)
|
|
notifyDetail(child)
|
|
return
|
|
}
|
|
if (event.type === "session.next.text.ended") {
|
|
child.text.set(event.data.textID, event.data.text)
|
|
child.projectedText.delete(event.data.textID)
|
|
setFrame(child, `text:${event.data.textID}`, {
|
|
kind: "assistant",
|
|
source: "assistant",
|
|
text: event.data.text,
|
|
phase: "progress",
|
|
messageID: event.data.assistantMessageID,
|
|
partID: event.data.textID,
|
|
})
|
|
touch(child, event.data.timestamp)
|
|
notifyDetail(child)
|
|
return
|
|
}
|
|
if (event.type === "session.next.reasoning.delta") {
|
|
const projected = child.projectedReasoning.get(event.data.reasoningID)
|
|
const covered = projected?.indexOf(event.data.delta) ?? -1
|
|
if (projected && covered >= 0) {
|
|
child.projectedReasoning.set(event.data.reasoningID, projected.slice(covered + event.data.delta.length))
|
|
return
|
|
}
|
|
const next = (child.reasoning.get(event.data.reasoningID) ?? "") + event.data.delta
|
|
child.reasoning.set(event.data.reasoningID, next)
|
|
if (!input.thinking) return
|
|
setFrame(child, `reasoning:${event.data.reasoningID}`, {
|
|
kind: "reasoning",
|
|
source: "reasoning",
|
|
text: `Thinking: ${next}`,
|
|
phase: "progress",
|
|
messageID: event.data.assistantMessageID,
|
|
partID: event.data.reasoningID,
|
|
})
|
|
notifyDetail(child)
|
|
return
|
|
}
|
|
if (event.type === "session.next.reasoning.ended") {
|
|
child.reasoning.set(event.data.reasoningID, event.data.text)
|
|
child.projectedReasoning.delete(event.data.reasoningID)
|
|
if (!input.thinking) return
|
|
setFrame(child, `reasoning:${event.data.reasoningID}`, {
|
|
kind: "reasoning",
|
|
source: "reasoning",
|
|
text: `Thinking: ${event.data.text}`,
|
|
phase: "progress",
|
|
messageID: event.data.assistantMessageID,
|
|
partID: event.data.reasoningID,
|
|
})
|
|
notifyDetail(child)
|
|
return
|
|
}
|
|
if (event.type === "session.next.tool.input.started") {
|
|
child.tools.set(event.data.callID, { name: event.data.name, input: {}, started: event.data.timestamp })
|
|
return
|
|
}
|
|
if (event.type === "session.next.tool.called") {
|
|
const current = child.tools.get(event.data.callID)
|
|
child.tools.set(event.data.callID, {
|
|
name: event.data.tool,
|
|
input: event.data.input,
|
|
started: current?.started ?? event.data.timestamp,
|
|
})
|
|
childTool(
|
|
child,
|
|
{
|
|
type: "tool",
|
|
id: event.data.callID,
|
|
name: event.data.tool,
|
|
provider: event.data.provider,
|
|
state: { status: "running", input: event.data.input, structured: {}, content: [] },
|
|
time: { created: current?.started ?? event.data.timestamp, ran: event.data.timestamp },
|
|
},
|
|
event.data.assistantMessageID,
|
|
)
|
|
touch(child, event.data.timestamp)
|
|
notifyDetail(child)
|
|
return
|
|
}
|
|
if (event.type === "session.next.tool.success" || event.type === "session.next.tool.failed") {
|
|
if (child.finishedTools.has(event.data.callID)) return
|
|
const current = child.tools.get(event.data.callID)
|
|
const failed = event.type === "session.next.tool.failed"
|
|
childTool(
|
|
child,
|
|
{
|
|
type: "tool",
|
|
id: event.data.callID,
|
|
name: current?.name ?? "tool",
|
|
provider: event.data.provider,
|
|
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,
|
|
outputPaths: event.data.outputPaths,
|
|
result: event.data.result,
|
|
},
|
|
time: {
|
|
created: current?.started ?? event.data.timestamp,
|
|
ran: current?.started,
|
|
completed: event.data.timestamp,
|
|
},
|
|
},
|
|
event.data.assistantMessageID,
|
|
)
|
|
touch(child, event.data.timestamp)
|
|
notifyDetail(child)
|
|
return
|
|
}
|
|
if (event.type === "session.next.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.data.timestamp)
|
|
notifyDetail(child)
|
|
return
|
|
}
|
|
if (event.type === "session.next.execution.settled") {
|
|
child.status =
|
|
event.data.outcome === "success" ? "completed" : event.data.outcome === "interrupted" ? "cancelled" : "error"
|
|
touch(child, event.data.timestamp)
|
|
input.emit()
|
|
}
|
|
}
|
|
|
|
const mainTool = (item: SessionMessageAssistantTool, active?: Record<string, unknown>) => {
|
|
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.next.tool.called") {
|
|
if (event.data.tool === "subagent") pendingCalls.set(event.data.callID, event.data.input)
|
|
return
|
|
}
|
|
if (event.type === "session.next.tool.failed") {
|
|
pendingCalls.delete(event.data.callID)
|
|
return
|
|
}
|
|
if (event.type !== "session.next.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.data.timestamp)
|
|
input.emit()
|
|
if (!child.hydrated) void hydrateChild(child)
|
|
},
|
|
foreign(sessionID, event) {
|
|
const child = children.get(sessionID)
|
|
if (child) {
|
|
reduce(child, event)
|
|
return
|
|
}
|
|
discover(sessionID)
|
|
const buffered = pendingEvents.get(sessionID)
|
|
if (buffered && buffered.length < DISCOVERY_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.v2.session
|
|
.list({ parentID: input.sessionID, limit: FAMILY_LIST_LIMIT, order: "desc" }, { throwOnError: true })
|
|
.then((response) => response.data.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<string, FooterSubagentDetail> = child
|
|
? { [child.sessionID]: { sessionID: child.sessionID, commits: child.frames.map((item) => item.commit) } }
|
|
: {}
|
|
return { tabs, details, permissions: [], questions: [] }
|
|
},
|
|
}
|
|
}
|