diff --git a/packages/gui-plugins/src/agents/i18n/en.ts b/packages/gui-plugins/src/agents/i18n/en.ts
new file mode 100644
index 000000000000..48df7e601150
--- /dev/null
+++ b/packages/gui-plugins/src/agents/i18n/en.ts
@@ -0,0 +1,33 @@
+export default {
+ "tab.title": "Subagents",
+ "mobile.title": "Subagents",
+ "view": "View subagents",
+ "count": "Agents",
+ "empty": "No subagents running",
+ "emptyRunning": "No running subagents",
+ "emptyHistory": "No subagent history",
+ "emptySearch": "No matching subagents",
+ "total": "Total cost",
+ "session": "Session",
+ "subagents": "Subagents",
+ "tokens": "tokens",
+ "cost": "Cost",
+ "search.placeholder": "Search subagents...",
+ "sort.recent": "Recent",
+ "sort.cost": "Cost",
+ "sort.tokens": "Tokens",
+ "sort.name": "Name",
+ "sort.direction": "Toggle sort direction",
+ "tab.all": "All",
+ "tab.running": "Running",
+ "tab.history": "History",
+ "latestActivity": "Latest activity",
+ "lastResponse": "Last response",
+ "elapsed": "Elapsed {{value}}",
+ "footerNote": "Tokens are cumulative · Costs as reported",
+ "status.idle": "Idle",
+ "status.busy": "Running",
+ "status.cancelled": "Cancelled",
+ "status.completed": "Completed",
+ "status.error": "Failed",
+}
diff --git a/packages/gui-plugins/src/agents/index.ts b/packages/gui-plugins/src/agents/index.ts
new file mode 100644
index 000000000000..c0e47b493c1f
--- /dev/null
+++ b/packages/gui-plugins/src/agents/index.ts
@@ -0,0 +1,6 @@
+import { Plugin } from "@opencode/plugin/gui"
+import en from "./i18n/en"
+
+export default Plugin.define({
+ i18n: { en },
+})
diff --git a/packages/gui-plugins/src/agents/indicator.tsx b/packages/gui-plugins/src/agents/indicator.tsx
new file mode 100644
index 000000000000..83862b2e4838
--- /dev/null
+++ b/packages/gui-plugins/src/agents/indicator.tsx
@@ -0,0 +1,104 @@
+import { Show, createMemo, type ComponentProps, type JSX } from "solid-js"
+import { Icon } from "@opencode/ui/icon"
+import { IconButton } from "@opencode/ui/icon-button"
+import { Tooltip } from "@opencode/ui/tooltip"
+import { useI18n } from "@opencode/ui/context/i18n"
+import { usePlugin, type MountedSession } from "@opencode/plugin/gui"
+import { childStatus, childrenCost, childrenOf, subagentTasks } from "./status"
+
+function AgentsTooltipRow(props: { name: JSX.Element; value: JSX.Element }) {
+ return (
+
+ {props.name}
+ {props.value}
+
+ )
+}
+
+export function AgentsTabIcon(props: { session: MountedSession }) {
+ const busy = createMemo(() => {
+ if (!props.session.id) return false
+
+ const data = props.session.server.data
+ const tasks = subagentTasks(data.session.message.list(props.session.id))
+
+ if (tasks.blocking.length > 0) return true
+
+ return childrenOf(data.session.list(), props.session.id).some(
+ (info) =>
+ childStatus({
+ part: tasks.parts.get(info.id),
+ running: data.session.status(info.id) === "running",
+ outcome: info.outcome,
+ }) === "busy",
+ )
+ })
+
+ return (
+
+
+
+
+
+
+ )
+}
+
+export function SessionAgentsIndicator(props: {
+ session: MountedSession
+ variant?: "button" | "indicator"
+ placement?: ComponentProps["placement"]
+}) {
+ const ctx = usePlugin()
+ const layout = ctx.layout
+ const i18n = useI18n()
+
+ const variant = createMemo(() => props.variant ?? "button")
+
+ const children = createMemo(() => {
+ if (!props.session.id) return []
+
+ return props.session.server.data.session.list().filter((info) => info.parentID === props.session.id)
+ })
+
+ const subCost = createMemo(() => childrenCost(children()))
+
+ const toggleAgents = () => {
+ if (!props.session.id) return
+
+ layout.toggle(`${ctx.id}:main`, props.session)
+ }
+
+ const tooltipValue = () => (
+
+ )
+
+ return (
+
+
+ }
+ onClick={toggleAgents}
+ aria-label={ctx.t("view")}
+ />
+ }
+ >
+
+
+
+
+ )
+}
diff --git a/packages/gui-plugins/src/agents/panel.tsx b/packages/gui-plugins/src/agents/panel.tsx
new file mode 100644
index 000000000000..6a125f857670
--- /dev/null
+++ b/packages/gui-plugins/src/agents/panel.tsx
@@ -0,0 +1,502 @@
+import { For, Show, createMemo, createSignal, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import { Icon } from "@opencode/ui/icon"
+import { ScrollView } from "@opencode/ui/scroll-view"
+import { Select } from "@opencode/ui/select"
+import { useI18n } from "@opencode/ui/context/i18n"
+import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
+import type { SessionInfo } from "@opencode/client/promise"
+import type { IconName } from "@opencode/ui/icons/catalog"
+import { createKeyed, usePlugin, type MountedSession } from "@opencode/plugin/gui"
+import type definition from "./index"
+import {
+ childHref,
+ childrenCost,
+ childrenOf,
+ childStatus,
+ filterAgents,
+ formatCost,
+ formatElapsed,
+ formatRelative,
+ formatTokens,
+ latestActivity,
+ lastResponse,
+ sortAgents,
+ stripSubagentSuffix,
+ subagentDescriptions,
+ subagentTasks,
+ tokenTotal,
+ treeCost,
+ type AgentSort,
+ type DerivedStatus,
+} from "./status"
+
+type SectionTab = "all" | "running" | "history"
+
+const sortOptions: AgentSort[] = ["recent", "cost", "tokens", "name"]
+
+/** MountedSession keys join server key and session id with a newline. */
+function sessionServerKey(session: MountedSession): string {
+ return session.key.split("\n")[0] ?? ""
+}
+
+function statusLabel(status: DerivedStatus, t: (key: string) => string): string {
+ if (status === "busy") return t("status.busy")
+
+ if (status === "cancelled") return t("status.cancelled")
+
+ if (status === "completed") return t("status.completed")
+
+ if (status === "error") return t("status.error")
+
+ return t("status.idle")
+}
+
+function sortLabel(sort: AgentSort, t: (key: string) => string): string {
+ if (sort === "cost") return t("sort.cost")
+
+ if (sort === "tokens") return t("sort.tokens")
+
+ if (sort === "name") return t("sort.name")
+
+ return t("sort.recent")
+}
+
+function AgentStatusIcon(props: { status: DerivedStatus }) {
+ return (
+ }>
+
+
+ )
+}
+
+function RunningPill(props: { label: string }) {
+ return (
+
+
+ {props.label}
+
+ )
+}
+
+function toolIcon(tool: string): IconName {
+ if (tool === "edit" || tool === "write") return "edit"
+
+ if (tool === "read") return "filetree"
+
+ if (tool === "grep" || tool === "websearch") return "magnifying-glass"
+
+ if (tool === "shell" || tool === "task" || tool === "subagent") return "console"
+
+ if (tool === "webfetch") return "globe"
+
+ return "filetree"
+}
+
+export default function SessionAgentsPanel(props: { session: MountedSession }) {
+ const ctx = usePlugin()
+ const i18n = useI18n()
+ const data = () => props.session.server.data
+ const serverKey = sessionServerKey(props.session)
+
+ // One-shot family plus transcript load per routed session; the live event stream owns freshness after that,
+ // so the tab needs no polling and no per-child requests.
+ createKeyed(
+ () => (props.session.server.connected ? props.session.id : undefined),
+ (id) => {
+ const store = props.session.server.data
+ void store.session.sync(id, { children: true }).catch(() => undefined)
+ void store.session.message.sync(id).catch(() => undefined)
+ },
+ )
+
+ const [query, setQuery] = createSignal("")
+ const [sort, setSort] = createSignal("recent")
+ const [ascending, setAscending] = createSignal(false)
+ const [tab, setTab] = createSignal("all")
+ const [expanded, setExpanded] = createStore>({})
+ const [loaded, setLoaded] = createStore>({})
+ const [now, setNow] = createSignal(Date.now())
+ const timer = setInterval(() => setNow(Date.now()), 1000)
+ onCleanup(() => clearInterval(timer))
+
+ const children = createMemo(() => childrenOf(data().session.list(), props.session.id))
+ const info = createMemo(() => data().session.get(props.session.id))
+ const tasks = createMemo(() => subagentTasks(data().session.message.list(props.session.id)))
+ const descriptions = createMemo(() => subagentDescriptions(data().session.message.list(props.session.id)))
+
+ const titleOf = (child: SessionInfo): string => {
+ const description = descriptions().get(child.id)
+
+ if (description) return description
+
+ if (child.title) return stripSubagentSuffix(child.title)
+
+ return child.id
+ }
+
+ const titles = createMemo(() => new Map(children().map((child) => [child.id, titleOf(child)] as const)))
+ const nameOf = (id: string): string => titles().get(id) ?? id
+
+ const statusOf = (id: string): DerivedStatus => {
+ const store = data()
+
+ return childStatus({
+ part: tasks().parts.get(id),
+ running: store.session.status(id) === "running",
+ outcome: store.session.get(id)?.outcome,
+ })
+ }
+
+ // Foreground calls with no child session yet: still running, nothing to drill into.
+ const unclaimed = createMemo(() => {
+ const known = new Set(children().map((child) => child.id))
+
+ return tasks().blocking.filter((item) => !item.childID || !known.has(item.childID))
+ })
+
+ const totalCost = createMemo(() => treeCost(info()?.cost, children()))
+ const subCost = createMemo(() => childrenCost(children()))
+ const ownCost = createMemo(() => info()?.cost ?? 0)
+
+ const filtered = createMemo(() => filterAgents(children(), query(), nameOf))
+ const sorted = createMemo(() => sortAgents(filtered(), { sort: sort(), ascending: ascending(), name: nameOf }))
+
+ const busyIDs = createMemo(
+ () =>
+ new Set(
+ sorted()
+ .filter((child) => statusOf(child.id) === "busy")
+ .map((child) => child.id),
+ ),
+ )
+
+ const running = createMemo(() => sorted().filter((child) => busyIDs().has(child.id)))
+ const history = createMemo(() => sorted().filter((child) => !busyIDs().has(child.id)))
+
+ const runningCount = createMemo(() => running().length + unclaimed().length)
+
+ // Message detail for the few running children; history rows sync on expand instead.
+ // Already-synced children stay out: membership churn must not refetch the survivors.
+ const [synced, setSynced] = createSignal>(new Set())
+
+ const runningKey = createMemo(() => {
+ const ids = children()
+ .filter((child) => statusOf(child.id) === "busy")
+ .map((child) => child.id)
+ .sort()
+
+ return ids.length > 0 ? ids.join(" ") : undefined
+ })
+
+ createKeyed(runningKey, (key) => {
+ const store = props.session.server.data
+ const fresh = key.split(" ").filter((id) => !synced().has(id))
+
+ if (fresh.length === 0) return
+
+ setSynced((prev) => new Set([...prev, ...fresh]))
+ void Promise.all(fresh.map((id) => store.session.message.sync(id))).catch(() => undefined)
+ })
+
+ const toggle = (child: SessionInfo, defaultOpen: boolean) => {
+ const open = expanded[child.id] ?? defaultOpen
+ setExpanded(child.id, !open)
+
+ if (!open && !loaded[child.id]) {
+ void data()
+ .session.message.sync(child.id)
+ .then(() => setLoaded(child.id, true))
+ .catch(() => undefined)
+ }
+ }
+
+ const searching = createMemo(() => query().trim().length > 0)
+ const visibleRunning = createMemo(() => (tab() === "history" ? [] : running()))
+ const visibleUnclaimed = createMemo(() => (tab() === "history" ? [] : unclaimed()))
+ const visibleHistory = createMemo(() => (tab() === "running" ? [] : history()))
+
+ const emptyText = () => {
+ if (searching() && visibleRunning().length + visibleUnclaimed().length + visibleHistory().length === 0)
+ return ctx.t("emptySearch")
+
+ if (tab() === "running") return ctx.t("emptyRunning")
+
+ if (tab() === "history") return ctx.t("emptyHistory")
+
+ return ctx.t("empty")
+ }
+
+ const showSections = () =>
+ visibleRunning().length + visibleUnclaimed().length + visibleHistory().length > 0 || tab() !== "all" || searching()
+
+ return (
+
+
+
+
+ {ctx.t("tab.title")}
+
+ {children().length.toLocaleString(i18n.locale())}
+
+
+
+
+ {runningCount().toLocaleString(i18n.locale())} {ctx.t("status.busy")}
+
+
+ {ctx.t("total")} {formatCost(totalCost(), i18n.locale())}
+
+
+ {ctx.t("subagents")} {formatCost(subCost(), i18n.locale())} · {ctx.t("session")}{" "}
+ {formatCost(ownCost(), i18n.locale())}
+
+
+
+
+
+
+
+
+
+ setQuery(event.currentTarget.value)}
+ placeholder={ctx.t("search.placeholder")}
+ aria-label={ctx.t("search.placeholder")}
+ class="w-full rounded-md border border-[var(--v2-border-border-base)] bg-transparent py-1.5 pl-8 pr-2 text-12-regular text-text-strong placeholder:text-text-faint focus:outline-none"
+ />
+
+
+
+
+ setTab("all")}
+ label={ctx.t("tab.all")}
+ count={children().length + unclaimed().length}
+ />
+ setTab("running")}
+ label={ctx.t("tab.running")}
+ count={runningCount()}
+ />
+ setTab("history")}
+ label={ctx.t("tab.history")}
+ count={history().length}
+ />
+
+
+
{emptyText()} }>
+ 0}>
+
+
+
+
+
+ {(item) => (
+
+
+
+
{item.label}
+
+ {statusLabel("busy", ctx.t)}
+
+
+
+ )}
+
+
+ {(child) => }
+
+
+
+
+ 0}>
+
+
+
+
+
+ {ctx.t("tokens")}
+ {ctx.t("cost")}
+
+
+ {(child) => }
+
+
+
+
+
+ {emptyText()}
+
+
+
+ {ctx.t("footerNote")}
+
+
+ )
+
+ function TabButton(props: { active: boolean; onClick: () => void; label: string; count: number }) {
+ return (
+
+ )
+ }
+
+ function SectionTitle(props: { label: string; count: number }) {
+ return (
+
+ {props.label}
+ ({props.count.toLocaleString(i18n.locale())})
+
+ )
+ }
+
+ function ChildRow(props: { child: SessionInfo; defaultOpen: boolean; serverKey: string }) {
+ const status = createMemo(() => statusOf(props.child.id))
+ const title = createMemo(() => titleOf(props.child))
+ const open = createMemo(() => expanded[props.child.id] ?? props.defaultOpen)
+
+ const activity = createMemo(() =>
+ status() === "busy" ? latestActivity(data().session.message.list(props.child.id)) : [],
+ )
+
+ const response = createMemo(() =>
+ open() && status() !== "busy" ? lastResponse(data().session.message.list(props.child.id)) : undefined,
+ )
+
+ const ready = createMemo(() => loaded[props.child.id] ?? false)
+
+ return (
+
+
+
+
+
+ {title()}
+
+
+
+
+
+
+ {formatTokens(tokenTotal(props.child.tokens))}
+
+
+ {formatCost(props.child.cost, i18n.locale())}
+
+
+
+
+
+
+ {statusLabel(status(), ctx.t)}
+ {(model) => <> · {model().id}>}
+
+
+
+
+
+
+ {(model) => (
+
+ {model().id} · {model().providerID}
+
+ )}
+
+
+ {ctx.t("elapsed", { value: formatElapsed(props.child.time.created, now()) })}
+ ·
+
+ {formatTokens(tokenTotal(props.child.tokens))} {ctx.t("tokens")}
+
+ ·
+ {formatCost(props.child.cost, i18n.locale())}
+
+
0}>
+ {ctx.t("latestActivity")}
+
+ {(entry) => (
+
+
+ {entry.tool}
+
+ {entry.target}
+
+ {formatRelative(entry.at, now(), i18n.locale())}
+
+ )}
+
+
+
+
+
+
+
+
{ctx.t("lastResponse")}
+
+
+
+ }
+ >
+ {(text) => (
+
+ {text()}
+
+ )}
+
+
+
+
+ )
+ }
+}
diff --git a/packages/gui-plugins/src/agents/renderer.tsx b/packages/gui-plugins/src/agents/renderer.tsx
new file mode 100644
index 000000000000..103fa1a06001
--- /dev/null
+++ b/packages/gui-plugins/src/agents/renderer.tsx
@@ -0,0 +1,83 @@
+import { lazy, onCleanup, Show, Suspense } from "solid-js"
+import { MenuItem, Panel, Slot, usePanel, type PanelTab, type Plugin } from "@opencode/plugin/gui"
+import { onIdle } from "@opencode/plugin/gui/builtin"
+import type definition from "./index"
+import { AgentsTabIcon, SessionAgentsIndicator } from "./indicator"
+
+const setup: Plugin.Setup = (ctx) => {
+ const sessions = ctx.sessions
+ const layout = ctx.layout
+ const SessionAgentsPanel = lazy(() => import("./panel"))
+ // Compile the panel while the app idles, so the first open renders at once.
+ onCleanup(onIdle(() => void SessionAgentsPanel.preload()))
+
+ // One tab object for every session, so neither strip updates nor a session switch remount its trigger.
+ const tab: PanelTab = {
+ id: "main",
+ get title() {
+ return ctx.t("tab.title")
+ },
+ label: (state) => (
+
+
+
{ctx.t("tab.title")}
+
+ ),
+ draggable: false,
+ closable: "compact",
+ }
+
+ ctx.add(MenuItem, {
+ menu: "session.panel",
+ id: "open",
+ get title() {
+ return ctx.t("tab.title")
+ },
+ icon: "bullet-list",
+ order: 15,
+ run() {
+ const session = sessions.current()
+
+ if (!session) return
+ layout.open(`${ctx.id}:main`, session)
+ },
+ })
+
+ ctx.add(Slot, {
+ at: "session.header",
+ order: 15,
+ render: (input) => ,
+ })
+
+ ctx.add(
+ Panel,
+ (): Panel => ({
+ id: "main",
+ region: "side",
+ mobile: { title: ctx.t("mobile.title"), order: 30, kind: "menu", icon: "bullet-list" },
+ list: (input) => (input.open.includes("main") ? [tab] : []),
+ render: (props) => {
+ const panel = usePanel()
+
+ return (
+
+
+
+ }
+ >
+
+
+
+
+
+
+ )
+ },
+ }),
+ )
+}
+
+export default setup
diff --git a/packages/gui-plugins/src/agents/status.test.ts b/packages/gui-plugins/src/agents/status.test.ts
new file mode 100644
index 000000000000..799ef8a3b899
--- /dev/null
+++ b/packages/gui-plugins/src/agents/status.test.ts
@@ -0,0 +1,285 @@
+import { describe, expect, test } from "bun:test"
+import type {
+ JsonValue,
+ SessionMessageAssistant,
+ SessionMessageAssistantTool,
+ SessionMessageInfo,
+} from "@opencode/client/promise"
+import {
+ childHref,
+ childrenOf,
+ childStatus,
+ deriveStatus,
+ filterAgents,
+ formatElapsed,
+ formatRelative,
+ formatTokens,
+ latestActivity,
+ lastResponse,
+ sortAgents,
+ stripSubagentSuffix,
+ subagentDescriptions,
+ tokenTotal,
+ treeCost,
+ childrenCost,
+} from "./status"
+
+function tool(
+ state: SessionMessageAssistantTool["state"],
+ overrides?: Partial>,
+): SessionMessageAssistantTool {
+ return {
+ type: "tool",
+ id: "part-1",
+ name: "subagent",
+ time: { created: 1 },
+ state,
+ ...overrides,
+ }
+}
+
+function assistant(parts: SessionMessageAssistantTool[]) {
+ return {
+ id: "message-1",
+ type: "assistant" as const,
+ agent: "build",
+ model: { id: "model", providerID: "provider" },
+ content: parts,
+ time: { created: 1 },
+ }
+}
+
+describe("subagentDescriptions", () => {
+ test("maps child ids to task descriptions", () => {
+ const part = tool({ status: "running", input: { description: "Explore auth" }, metadata: { sessionID: "child" } })
+ expect(subagentDescriptions([assistant([part])]).get("child")).toBe("Explore auth")
+ })
+})
+
+describe("childrenOf", () => {
+ test("filters by parent and sorts by id", () => {
+ const sessions = [{ id: "b", parentID: "parent" }, { id: "root" }, { id: "a", parentID: "parent" }]
+ expect(childrenOf(sessions, "parent").map((session) => session.id)).toEqual(["a", "b"])
+ })
+})
+
+describe("deriveStatus", () => {
+ test("a running part means work in flight", () => {
+ expect(deriveStatus({ part: "running", backgrounded: false, running: false, outcome: undefined })).toBe("busy")
+ })
+ test("a backgrounded completion without an outcome is still active", () => {
+ expect(deriveStatus({ part: "completed", backgrounded: true, running: false, outcome: undefined })).toBe("busy")
+ })
+ test("the outcome settles everything else", () => {
+ expect(deriveStatus({ part: "completed", backgrounded: true, running: false, outcome: "succeeded" })).toBe(
+ "completed",
+ )
+ expect(deriveStatus({ part: "completed", backgrounded: false, running: false, outcome: "failed" })).toBe("error")
+ expect(deriveStatus({ part: undefined, running: true, outcome: undefined, backgrounded: false })).toBe("busy")
+ expect(deriveStatus({ part: "cancelled", backgrounded: false, running: false, outcome: "succeeded" })).toBe(
+ "completed",
+ )
+ })
+ test("terminal parts and outcomes fall through in order", () => {
+ expect(deriveStatus({ part: "completed", backgrounded: false, running: false, outcome: undefined })).toBe(
+ "completed",
+ )
+ expect(deriveStatus({ part: undefined, running: false, outcome: "interrupted", backgrounded: false })).toBe(
+ "cancelled",
+ )
+ expect(deriveStatus({ part: undefined, running: false, outcome: "failed", backgrounded: false })).toBe("error")
+ expect(deriveStatus({ part: undefined, running: false, outcome: "succeeded", backgrounded: false })).toBe(
+ "completed",
+ )
+ expect(deriveStatus({ part: undefined, running: false, outcome: undefined, backgrounded: false })).toBe("idle")
+ })
+})
+
+describe("childStatus", () => {
+ test("joins part, liveness and outcome", () => {
+ const running = tool({ status: "running", input: {}, metadata: { sessionID: "c" } })
+ expect(childStatus({ part: running, running: false, outcome: undefined })).toBe("busy")
+
+ const backgrounded = tool({
+ status: "completed",
+ input: {},
+ content: [{ type: "text", text: "x" }],
+ metadata: { sessionID: "c", status: "running" },
+ })
+
+ expect(childStatus({ part: backgrounded, running: false, outcome: undefined })).toBe("busy")
+ expect(childStatus({ part: backgrounded, running: false, outcome: "succeeded" })).toBe("completed")
+ expect(childStatus({ part: undefined, running: false, outcome: undefined })).toBe("idle")
+ })
+})
+
+describe("formatting", () => {
+ test("tree cost adds the owner once plus every child", () => {
+ expect(treeCost(159.13, [{ cost: 0.5 }, { cost: 0.25 }, { cost: 0 }])).toBeCloseTo(159.88, 2)
+ expect(treeCost(undefined, [])).toBe(0)
+ expect(treeCost(10, [])).toBe(10)
+ })
+ test("children cost never includes the owner", () => {
+ expect(childrenCost([{ cost: 0.5 }, { cost: 0.25 }, { cost: 0 }])).toBeCloseTo(0.75, 2)
+ expect(childrenCost([])).toBe(0)
+ })
+ test("token totals include every bucket", () => {
+ expect(tokenTotal({ input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } })).toBe(15)
+ })
+ test("token labels compact thousands", () => {
+ expect(formatTokens(999)).toBe("999")
+ expect(formatTokens(1500)).toBe("1.5k")
+ expect(formatTokens(2_500_000)).toBe("2.5M")
+ })
+ test("child links address the same server route", () => {
+ expect(childHref("test", "child-1")).toBe("/server/dGVzdA/session/child-1")
+ })
+ test("titles lose the subagent suffix", () => {
+ expect(stripSubagentSuffix("Explore auth (@build subagent)")).toBe("Explore auth")
+ expect(stripSubagentSuffix("Plain title")).toBe("Plain title")
+ })
+})
+
+describe("sortAgents", () => {
+ const sessions = [
+ {
+ id: "a",
+ cost: 3,
+ tokens: { input: 1, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+ title: "Zulu",
+ time: { created: 1000 },
+ },
+ {
+ id: "b",
+ cost: 1,
+ tokens: { input: 9, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+ title: "Alpha",
+ time: { created: 3000 },
+ },
+ {
+ id: "c",
+ cost: 2,
+ tokens: { input: 5, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+ title: "Mike",
+ time: { created: 2000 },
+ },
+ ]
+
+ const name = (id: string) => sessions.find((session) => session.id === id)?.title ?? id
+
+ test("recent defaults to newest first", () => {
+ expect(sortAgents(sessions, { sort: "recent", ascending: false, name }).map((session) => session.id)).toEqual([
+ "b",
+ "c",
+ "a",
+ ])
+ expect(sortAgents(sessions, { sort: "recent", ascending: true, name }).map((session) => session.id)).toEqual([
+ "a",
+ "c",
+ "b",
+ ])
+ })
+ test("cost, tokens and name order correctly", () => {
+ expect(sortAgents(sessions, { sort: "cost", ascending: false, name }).map((session) => session.id)).toEqual([
+ "a",
+ "c",
+ "b",
+ ])
+ expect(sortAgents(sessions, { sort: "tokens", ascending: false, name }).map((session) => session.id)).toEqual([
+ "b",
+ "c",
+ "a",
+ ])
+ expect(sortAgents(sessions, { sort: "name", ascending: true, name }).map((session) => session.id)).toEqual([
+ "b",
+ "c",
+ "a",
+ ])
+ })
+})
+
+describe("filterAgents", () => {
+ const sessions = [
+ {
+ id: "abc",
+ cost: 0,
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
+ title: "Explore auth",
+ time: { created: 1 },
+ model: { id: "m1", providerID: "test" },
+ },
+ ]
+
+ test("matches titles, models and ids case-insensitively", () => {
+ const name = () => "Explore auth"
+ expect(filterAgents(sessions, "", name)).toHaveLength(1)
+ expect(filterAgents(sessions, "explore", name)).toHaveLength(1)
+ expect(filterAgents(sessions, "TEST/M1", name)).toHaveLength(1)
+ expect(filterAgents(sessions, "ABC", name)).toHaveLength(1)
+ expect(filterAgents(sessions, "missing", name)).toHaveLength(0)
+ })
+})
+
+describe("clocks", () => {
+ test("elapsed compacts hours, minutes and seconds", () => {
+ expect(formatElapsed(0, 45_000)).toBe("45s")
+ expect(formatElapsed(0, 756_000)).toBe("12m 36s")
+ expect(formatElapsed(0, 11_139_000)).toBe("3h 05m 39s")
+ })
+ test("relative stamps degrade gracefully", () => {
+ expect(formatRelative(0, 2_000, "en")).toBe("2s ago")
+ expect(formatRelative(0, 1_080_000, "en")).toBe("18m ago")
+ expect(formatRelative(0, 10_800_000, "en")).toBe("3h ago")
+ })
+})
+
+describe("transcript detail", () => {
+ const activityPart = (id: string, name: string, input: { [x: string]: JsonValue }, at: number) =>
+ ({
+ type: "tool",
+ id,
+ name,
+ state: { status: "completed", input, content: [{ type: "text", text: "x" }] },
+ time: { created: at, completed: at },
+ }) satisfies SessionMessageAssistantTool
+
+ const assistantMessage = (id: string, content: SessionMessageAssistant["content"], at: number, done: boolean) =>
+ ({
+ id,
+ type: "assistant",
+ agent: "build",
+ model: { id: "m", providerID: "p" },
+ content,
+ time: done ? { created: at, completed: at + 500 } : { created: at },
+ }) satisfies SessionMessageInfo
+
+ test("latest activity walks newest tool calls first", () => {
+ const messages = [
+ assistantMessage(
+ "m1",
+ [
+ activityPart("a", "edit", { path: "src/a.ts" }, 1000),
+ activityPart("b", "grep", { pattern: "session" }, 2000),
+ ],
+ 1000,
+ true,
+ ),
+ ]
+
+ expect(latestActivity(messages)).toEqual([
+ { tool: "grep", target: "session", at: 2000 },
+ { tool: "edit", target: "src/a.ts", at: 1000 },
+ ])
+ expect(latestActivity(messages, 1)).toHaveLength(1)
+ })
+
+ test("last response returns the newest assistant text", () => {
+ const messages = [
+ assistantMessage("m1", [{ type: "text", text: "First." }], 1000, true),
+ assistantMessage("m2", [{ type: "text", text: "Second." }], 2000, true),
+ ]
+
+ expect(lastResponse(messages)).toBe("Second.")
+ expect(lastResponse([])).toBeUndefined()
+ })
+})
diff --git a/packages/gui-plugins/src/agents/status.ts b/packages/gui-plugins/src/agents/status.ts
new file mode 100644
index 000000000000..1ab75b62accc
--- /dev/null
+++ b/packages/gui-plugins/src/agents/status.ts
@@ -0,0 +1,321 @@
+import { Match, Predicate } from "effect"
+import { base64Encode } from "@opencode/util/encode"
+import {
+ partBackgrounded,
+ scanSubagentTranscript,
+ taskStatus,
+ text,
+ type SubagentTaskStatus,
+} from "@opencode/session-ui/subagent-transcript"
+import type {
+ SessionInfo,
+ SessionMessageAssistantTool,
+ SessionMessageInfo,
+ TokenUsageInfo,
+} from "@opencode/client/promise"
+
+/** Row states the agents panel renders. */
+export type DerivedStatus = "idle" | "busy" | "cancelled" | "completed" | "error"
+
+/** A running foreground subagent call, with a child session once one exists. */
+export interface BlockingSubagent {
+ readonly partID: string
+ readonly childID: string | undefined
+ readonly label: string
+}
+
+/** The joinable tool parts plus the live foreground calls of a parent transcript. */
+export interface SubagentTasks {
+ readonly parts: Map
+ readonly blocking: BlockingSubagent[]
+}
+
+/**
+ * The joinable tool parts plus the live foreground calls of a parent transcript,
+ * over the shared reader the header background tasks use: a running part only
+ * counts inside the latest uncompleted assistant message (older ones are stale),
+ * and a backgrounded completion only counts until its synthetic notice lands.
+ */
+export function subagentTasks(messages: SessionMessageInfo[]): SubagentTasks {
+ const scan = scanSubagentTranscript(messages, ["subagent", "task"])
+ const parts = new Map()
+ const blocking: BlockingSubagent[] = []
+
+ for (const call of scan.calls) {
+ if (call.status === "running") {
+ if (!call.live) continue
+ blocking.push({
+ partID: call.partID,
+ childID: call.childID,
+ label: call.description ?? call.childID ?? call.partID,
+ })
+
+ if (call.childID) parts.set(call.childID, call.part)
+
+ continue
+ }
+
+ if (!call.childID) continue
+
+ if (call.status === "completed" && call.backgrounded && scan.completed.has(call.childID)) continue
+
+ parts.set(call.childID, call.part)
+ }
+
+ return { parts, blocking }
+}
+
+/** Every subagent task description of a parent transcript keyed by child session id, last write wins. */
+export function subagentDescriptions(messages: SessionMessageInfo[]): Map {
+ const descriptions = new Map()
+
+ for (const call of scanSubagentTranscript(messages, ["subagent", "task"]).calls) {
+ if (call.childID && call.description) descriptions.set(call.childID, call.description)
+ }
+
+ return descriptions
+}
+
+/** Direct children of one session, oldest first by id. */
+export function childrenOf(sessions: readonly T[], parentID: string): T[] {
+ return sessions.filter((session) => session.parentID === parentID).sort((a, b) => (a.id < b.id ? -1 : 1))
+}
+
+/**
+ * One row's rendered state. A running part means work in flight no matter what the
+ * status map says; a backgrounded completion without an outcome is still active;
+ * otherwise the outcome is ground truth, then the part's terminal state.
+ */
+export function deriveStatus(input: {
+ part: SubagentTaskStatus | undefined
+ backgrounded: boolean
+ running: boolean
+ outcome: SessionInfo["outcome"]
+}): DerivedStatus {
+ if (input.part === "running") return "busy"
+
+ if (input.running) return "busy"
+
+ if (input.outcome === "interrupted") return "cancelled"
+
+ if (input.outcome === "failed") return "error"
+
+ if (input.outcome === "succeeded") return "completed"
+
+ if (input.part === "completed") return input.backgrounded ? "busy" : "completed"
+
+ if (input.part) return input.part
+
+ return "idle"
+}
+
+/** One child's rendered state from its tool part plus the live store. */
+export function childStatus(input: {
+ part: SessionMessageAssistantTool | undefined
+ running: boolean
+ outcome: SessionInfo["outcome"]
+}): DerivedStatus {
+ const task = input.part ? taskStatus(input.part) : undefined
+
+ return deriveStatus({
+ part: task,
+ backgrounded: input.part ? partBackgrounded(input.part) : false,
+ running: input.running,
+ outcome: input.outcome,
+ })
+}
+
+/** All tokens one child session burned, matching the context tab's total. */
+export function tokenTotal(tokens: TokenUsageInfo): number {
+ return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
+}
+
+/**
+ * Whole-tree spend: the owner's own cost plus every known child. Stored costs are
+ * own-only (the projector attributes each step to its running session), so adding
+ * the family never double counts. Matches the client's family aggregate.
+ */
+export function treeCost(parentCost: number | undefined, children: readonly { cost: number }[]): number {
+ return (parentCost ?? 0) + childrenCost(children)
+}
+
+/** Subagents-only spend: every known child, never the owner. Pairs with treeCost. */
+export function childrenCost(children: readonly { cost: number }[]): number {
+ return children.reduce((sum, child) => sum + child.cost, 0)
+}
+
+export function formatTokens(total: number): string {
+ if (total < 1000) return String(total)
+
+ if (total < 1_000_000) return `${(total / 1000).toFixed(total < 10_000 ? 1 : 0)}k`
+
+ return `${(total / 1_000_000).toFixed(1)}M`
+}
+
+export function formatCost(value: number, locale: string): string {
+ return new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(value)
+}
+
+export function formatTime(value: number, locale: string): string {
+ return new Date(value).toLocaleString(locale, {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ })
+}
+
+/** SPA route of a child session on the same server; the solid router intercepts the click. */
+export function childHref(serverKey: string, sessionID: string): string {
+ return `/server/${base64Encode(serverKey)}/session/${sessionID}`
+}
+
+/** Child titles carry a ` (@agent subagent)` suffix when no task description exists; drop it. */
+export function stripSubagentSuffix(title: string): string {
+ return title.replace(/\s+\(@[^)]+ subagent\)$/, "")
+}
+
+/** List orders for the agents panel. */
+export type AgentSort = "recent" | "cost" | "tokens" | "name"
+
+function compareKeys(a: number | string, b: number | string): number {
+ if (Predicate.isNumber(a) && Predicate.isNumber(b)) return a - b
+
+ return String(a).localeCompare(String(b))
+}
+
+/** Sorted children: recent means newest first unless ascending flips it. */
+export function sortAgents(
+ sessions: readonly T[],
+ options: { sort: AgentSort; ascending: boolean; name: (sessionID: string) => string },
+): T[] {
+ const direction = options.ascending ? 1 : -1
+
+ const keyed = sessions.map((session) => ({
+ session,
+ key: Match.value(options.sort).pipe(
+ Match.when("cost", () => session.cost),
+ Match.when("tokens", () => tokenTotal(session.tokens)),
+ Match.when("name", () => options.name(session.id).toLocaleLowerCase()),
+ Match.orElse(() => session.time.created),
+ ),
+ }))
+
+ keyed.sort((a, b) => compareKeys(a.key, b.key) * direction || (a.session.id < b.session.id ? -1 : 1))
+
+ return keyed.map((entry) => entry.session)
+}
+
+/** Case-insensitive title, description, model and id match. */
+export function filterAgents(
+ sessions: readonly T[],
+ query: string,
+ name: (sessionID: string) => string,
+): T[] {
+ const needle = query.trim().toLocaleLowerCase()
+
+ if (!needle) return [...sessions]
+
+ return sessions.filter((session) => {
+ const model = session.model ? `${session.model.providerID}/${session.model.id}`.toLocaleLowerCase() : ""
+
+ return (
+ name(session.id).toLocaleLowerCase().includes(needle) ||
+ session.id.toLocaleLowerCase().includes(needle) ||
+ model.includes(needle)
+ )
+ })
+}
+
+/** Compact elapsed clock: 45s, 12m 36s, 3h 04m 09s. */
+export function formatElapsed(started: number, now: number): string {
+ const seconds = Math.max(0, Math.floor((now - started) / 1000))
+ const hours = Math.floor(seconds / 3600)
+ const minutes = Math.floor((seconds % 3600) / 60)
+ const rest = seconds % 60
+
+ if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(rest).padStart(2, "0")}s`
+
+ if (minutes > 0) return `${minutes}m ${String(rest).padStart(2, "0")}s`
+
+ return `${rest}s`
+}
+
+/** Short relative stamp in the active locale: 2s ago, 18m ago, 3h ago, older falls back to the clock. */
+export function formatRelative(when: number, now: number, locale: string): string {
+ const format = new Intl.RelativeTimeFormat(locale, { numeric: "auto", style: "narrow" })
+ const seconds = Math.max(0, Math.floor((now - when) / 1000))
+
+ if (seconds < 60) return format.format(-seconds, "second")
+
+ const minutes = Math.floor(seconds / 60)
+
+ if (minutes < 60) return format.format(-minutes, "minute")
+
+ const hours = Math.floor(minutes / 60)
+
+ if (hours < 24) return format.format(-hours, "hour")
+
+ return formatTime(when, locale)
+}
+
+/** One recent tool call of a child transcript for the activity feed. */
+export interface AgentActivity {
+ readonly tool: string
+ readonly target: string
+ readonly at: number
+}
+
+/** The newest tool calls first, capped at the limit. */
+export function latestActivity(messages: SessionMessageInfo[], limit = 3): AgentActivity[] {
+ const found: AgentActivity[] = []
+
+ for (let index = messages.length - 1; index >= 0 && found.length < limit; index--) {
+ const message = messages[index]
+
+ if (!message || message.type !== "assistant") continue
+
+ for (let part = message.content.length - 1; part >= 0 && found.length < limit; part--) {
+ const entry = message.content[part]
+
+ if (!entry || entry.type !== "tool") continue
+
+ found.push({
+ tool: entry.name,
+ target: activityTarget(entry),
+ at: entry.time.completed ?? entry.time.ran ?? entry.time.created,
+ })
+ }
+ }
+
+ return found
+}
+
+function activityTarget(part: SessionMessageAssistantTool): string {
+ if (!("input" in part.state) || Predicate.isString(part.state.input)) return ""
+
+ const input = part.state.input
+
+ return (
+ text(input.path) ?? text(input.pattern) ?? text(input.command) ?? text(input.query) ?? text(input.description) ?? ""
+ )
+}
+
+/** The newest assistant text of a transcript, for the expandable last response. */
+export function lastResponse(messages: SessionMessageInfo[]): string | undefined {
+ for (let index = messages.length - 1; index >= 0; index--) {
+ const message = messages[index]
+
+ if (!message || message.type !== "assistant") continue
+
+ const texts: string[] = []
+
+ for (const part of message.content) {
+ if (part.type === "text" && part.text.trim()) texts.push(part.text)
+ }
+
+ if (texts.length > 0) return texts.join("\n\n")
+ }
+
+ return undefined
+}
diff --git a/packages/gui-plugins/src/main.ts b/packages/gui-plugins/src/main.ts
index 76cfe0cb2488..b499fcdb417c 100644
--- a/packages/gui-plugins/src/main.ts
+++ b/packages/gui-plugins/src/main.ts
@@ -14,6 +14,7 @@ import wsl from "./wsl"
import microsoftOffice from "./microsoft-office"
import sources from "./sources"
import goal from "./goal"
+import agents from "./agents"
/** Built-in plugins with their main entries, by id. Lists every built-in so their ids stay reserved. */
export const builtins = compose({
@@ -32,4 +33,5 @@ export const builtins = compose({
"microsoft-office": microsoftOffice,
sources,
goal,
+ agents,
})
diff --git a/packages/gui-plugins/src/renderer.ts b/packages/gui-plugins/src/renderer.ts
index 5c131e235d0c..ae4c17140e7f 100644
--- a/packages/gui-plugins/src/renderer.ts
+++ b/packages/gui-plugins/src/renderer.ts
@@ -29,6 +29,8 @@ import sources from "./sources"
import sourcesRenderer from "./sources/renderer"
import goal from "./goal"
import goalRenderer from "./goal/renderer"
+import agents from "./agents"
+import agentsRenderer from "./agents/renderer"
// The window renders once every built-in is active, so the small renderer entries load with the app, like the
// features they replaced. Heavy UI stays behind `lazy()` inside them.
@@ -52,4 +54,5 @@ export const builtins = compose({
"microsoft-office": { ...microsoftOffice, windowEntry: eager(microsoftOfficeRenderer) },
sources: { ...sources, windowEntry: eager(sourcesRenderer) },
goal: { ...goal, windowEntry: eager(goalRenderer) },
+ agents: { ...agents, windowEntry: eager(agentsRenderer) },
})
diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json
index b576be349105..0e2ce37dc4c3 100644
--- a/packages/session-ui/package.json
+++ b/packages/session-ui/package.json
@@ -28,6 +28,7 @@
"./tool-count-summary": "./src/components/tool-count-summary.tsx",
"./tool-error-card": "./src/components/tool-error-card.tsx",
"./tool-status-title": "./src/components/tool-status-title.tsx",
+ "./subagent-transcript": "./src/subagent-transcript.ts",
"./session-diff": "./src/components/session-diff.ts",
"./message-file": "./src/components/message-file.ts",
"./attachment-card": "./src/message/attachment-card.tsx",
diff --git a/packages/session-ui/src/subagent-transcript.test.ts b/packages/session-ui/src/subagent-transcript.test.ts
new file mode 100644
index 000000000000..fea005594c56
--- /dev/null
+++ b/packages/session-ui/src/subagent-transcript.test.ts
@@ -0,0 +1,186 @@
+import { describe, expect, test } from "bun:test"
+import type { SessionMessageAssistantTool } from "@opencode/client/promise"
+import {
+ partBackgrounded,
+ scanSubagentTranscript,
+ taskStatus,
+ text,
+ toolAgent,
+ toolDescription,
+ toolSessionID,
+} from "./subagent-transcript"
+
+function tool(
+ state: SessionMessageAssistantTool["state"],
+ overrides?: Partial>,
+): SessionMessageAssistantTool {
+ return {
+ type: "tool",
+ id: "part-1",
+ name: "subagent",
+ time: { created: 1 },
+ state,
+ ...overrides,
+ }
+}
+
+function assistant(parts: SessionMessageAssistantTool[], id = "message-1") {
+ return {
+ id,
+ type: "assistant" as const,
+ agent: "build",
+ model: { id: "model", providerID: "provider" },
+ content: parts,
+ time: { created: 1 },
+ }
+}
+
+function completedMessage(parts: SessionMessageAssistantTool[]) {
+ return { ...assistant(parts), time: { created: 1, completed: 2 } }
+}
+
+describe("text", () => {
+ test("keeps trimmed strings and drops everything else", () => {
+ expect(text(" hello ")).toBe("hello")
+ expect(text(" ")).toBeUndefined()
+ expect(text(3)).toBeUndefined()
+ expect(text(undefined)).toBeUndefined()
+ })
+})
+
+describe("taskStatus", () => {
+ test("running and streaming parts are running", () => {
+ expect(taskStatus(tool({ status: "running", input: {}, metadata: {} }))).toBe("running")
+ expect(taskStatus(tool({ status: "streaming", input: "..." }))).toBe("running")
+ })
+ test("completed parts are completed", () => {
+ expect(taskStatus(tool({ status: "completed", input: {}, content: [{ type: "text", text: "done" }] }))).toBe(
+ "completed",
+ )
+ })
+ test("aborted errors are cancelled", () => {
+ expect(taskStatus(tool({ status: "error", input: {}, error: { type: "aborted", message: "stopped" } }))).toBe(
+ "cancelled",
+ )
+ })
+ test("interrupted metadata is cancelled", () => {
+ expect(
+ taskStatus(
+ tool({
+ status: "error",
+ input: {},
+ error: { type: "failed", message: "x" },
+ metadata: { interrupted: true },
+ }),
+ ),
+ ).toBe("cancelled")
+ })
+ test("other errors are errors", () => {
+ expect(taskStatus(tool({ status: "error", input: {}, error: { type: "failed", message: "boom" } }))).toBe("error")
+ })
+})
+
+describe("partBackgrounded", () => {
+ test("completed parts with a running metadata status are backgrounded", () => {
+ expect(
+ partBackgrounded(
+ tool({
+ status: "completed",
+ input: {},
+ content: [{ type: "text", text: "x" }],
+ metadata: { status: "running" },
+ }),
+ ),
+ ).toBe(true)
+ expect(partBackgrounded(tool({ status: "completed", input: {}, content: [{ type: "text", text: "x" }] }))).toBe(
+ false,
+ )
+ expect(partBackgrounded(tool({ status: "running", input: {}, metadata: {} }))).toBe(false)
+ })
+})
+
+describe("toolSessionID", () => {
+ test("reads sessionID then the legacy sessionId spelling", () => {
+ expect(toolSessionID(tool({ status: "running", input: {}, metadata: { sessionID: "child-1" } }))).toBe("child-1")
+ expect(toolSessionID(tool({ status: "running", input: {}, metadata: { sessionId: "child-2" } }))).toBe("child-2")
+ })
+ test("missing and blank ids are undefined", () => {
+ expect(toolSessionID(tool({ status: "streaming", input: "..." }))).toBeUndefined()
+ expect(toolSessionID(tool({ status: "running", input: {}, metadata: { sessionID: " " } }))).toBeUndefined()
+ })
+})
+
+describe("toolDescription", () => {
+ test("reads descriptions and agent names", () => {
+ const part = tool({
+ status: "running",
+ input: { description: "Explore", agent: "general" },
+ metadata: { sessionID: "child" },
+ })
+
+ expect(toolDescription(part)).toBe("Explore")
+ expect(toolAgent(part)).toBe("general")
+ expect(toolDescription(tool({ status: "streaming", input: "..." }))).toBeUndefined()
+ })
+})
+
+describe("scanSubagentTranscript", () => {
+ test("collects calls with child, liveness and backgrounded flags", () => {
+ const live = tool({ status: "running", input: { description: "Live" }, metadata: { sessionID: "a" } }, { id: "a" })
+
+ const done = tool(
+ {
+ status: "completed",
+ input: {},
+ content: [{ type: "text", text: "done" }],
+ metadata: { sessionID: "b", status: "running" },
+ },
+ { id: "b" },
+ )
+
+ const scan = scanSubagentTranscript([completedMessage([]), assistant([live, done], "message-2")])
+
+ expect(scan.calls.map((call) => [call.partID, call.childID, call.live, call.backgrounded])).toEqual([
+ ["a", "a", true, false],
+ ["b", "b", true, true],
+ ])
+ expect(scan.completed.size).toBe(0)
+ expect(scan.live?.id).toBe("message-2")
+ })
+ test("stale running parts outside the live message stay out", () => {
+ const stale = tool({ status: "running", input: {}, metadata: { sessionID: "old" } })
+ const scan = scanSubagentTranscript([completedMessage([stale]), assistant([])])
+
+ expect(scan.calls.map((call) => [call.childID, call.live])).toEqual([["old", false]])
+ })
+ test("synthetic notices expire backgrounded calls", () => {
+ const backgrounded = tool(
+ {
+ status: "completed",
+ input: {},
+ content: [{ type: "text", text: "done" }],
+ metadata: { sessionID: "child", status: "running" },
+ },
+ { id: "a" },
+ )
+
+ const notice = {
+ id: "n1",
+ type: "synthetic",
+ metadata: { source: "subagent", childID: "child" },
+ text: "",
+ time: { created: 2 },
+ } as const
+
+ const scan = scanSubagentTranscript([completedMessage([backgrounded]), notice])
+
+ expect(scan.completed.has("child")).toBe(true)
+ expect(scan.calls.map((call) => call.childID)).toEqual(["child"])
+ })
+ test("legacy task parts join when asked", () => {
+ const legacy = tool({ status: "running", input: {}, metadata: { sessionID: "old" } }, { name: "task" })
+
+ expect(scanSubagentTranscript([assistant([legacy])]).calls).toHaveLength(1)
+ expect(scanSubagentTranscript([assistant([legacy])], ["subagent"]).calls).toHaveLength(0)
+ })
+})
diff --git a/packages/session-ui/src/subagent-transcript.ts b/packages/session-ui/src/subagent-transcript.ts
new file mode 100644
index 000000000000..d2da69a30b21
--- /dev/null
+++ b/packages/session-ui/src/subagent-transcript.ts
@@ -0,0 +1,194 @@
+import { Predicate } from "effect"
+import type {
+ JsonValue,
+ SessionMessageAssistant,
+ SessionMessageAssistantTool,
+ SessionMessageInfo,
+} from "@opencode/client/promise"
+
+/**
+ * Tool names that spawn a child session. `subagent` is the current name;
+ * `task` only survives inside transcripts migrated from V1, whose migration
+ * keeps the original tool name.
+ */
+export const subagentToolNames = ["subagent", "task"] as const
+
+/** What a parent session's subagent tool part reports for one child session. */
+export type SubagentTaskStatus = "running" | "completed" | "cancelled" | "error"
+
+/**
+ * One subagent tool call found in a parent transcript.
+ */
+export interface SubagentCall {
+ /** The tool call id. */
+ readonly partID: string
+ /** The spawned child session, once the runner assigned one. */
+ readonly childID: string | undefined
+ /** The tool input description, if any. */
+ readonly description: string | undefined
+ /** The requested agent, if any. */
+ readonly agent: string | undefined
+ /** The call's own wire state. */
+ readonly status: "streaming" | "running" | "completed" | "error"
+ /** Completed while the child keeps running. */
+ readonly backgrounded: boolean
+ /** Inside the latest uncompleted assistant message. */
+ readonly live: boolean
+ /** The underlying tool part. */
+ readonly part: SessionMessageAssistantTool
+}
+
+/**
+ * A parent transcript scanned for subagent work. Both the header's background
+ * reader and the agents panel compose this instead of reimplementing the
+ * notice, liveness and backgrounded checks.
+ */
+export interface SubagentTranscript {
+ /** Every matching tool call in transcript order. Streaming calls carry no metadata yet, so they never join. */
+ readonly calls: readonly SubagentCall[]
+ /** Child ids with a synthetic completion notice. */
+ readonly completed: ReadonlySet
+ /** The latest assistant message still running, if any. */
+ readonly live: SessionMessageAssistant | undefined
+}
+
+/**
+ * Trimmed text or undefined for blank and non-string wire values.
+ *
+ * @param value - A metadata or input value from the wire.
+ */
+export function text(value: JsonValue | undefined): string | undefined {
+ if (!Predicate.isString(value)) return undefined
+
+ const trimmed = value.trim()
+
+ if (!trimmed) return undefined
+
+ return trimmed
+}
+
+/**
+ * The child session id a tool part spawned, accepting both metadata key spellings.
+ *
+ * @param part - A subagent tool part.
+ */
+export function toolSessionID(part: SessionMessageAssistantTool): string | undefined {
+ return text(toolMetadata(part, "sessionID")) ?? text(toolMetadata(part, "sessionId"))
+}
+
+/**
+ * The task description of a tool part, if it carries one.
+ *
+ * @param part - A subagent tool part.
+ */
+export function toolDescription(part: SessionMessageAssistantTool): string | undefined {
+ if (!("input" in part.state) || Predicate.isString(part.state.input)) return undefined
+
+ return text(part.state.input.description)
+}
+
+/**
+ * The requested agent of a tool part, if it carries one.
+ *
+ * @param part - A subagent tool part.
+ */
+export function toolAgent(part: SessionMessageAssistantTool): string | undefined {
+ if (!("input" in part.state) || Predicate.isString(part.state.input)) return undefined
+
+ return text(part.state.input.agent)
+}
+
+/**
+ * Whether a completed part left its child running in the background.
+ *
+ * @param part - A subagent tool part.
+ */
+export function partBackgrounded(part: SessionMessageAssistantTool): boolean {
+ if (part.state.status !== "completed" || !("metadata" in part.state)) return false
+
+ return part.state.metadata?.status === "running"
+}
+
+/**
+ * The state one child session is in from its parent tool part. A completed part
+ * is done; an error part is only a cancellation when the runner marked it
+ * interrupted or aborted, mirroring core's structured abort errors.
+ *
+ * @param part - A subagent tool part.
+ */
+export function taskStatus(part: SessionMessageAssistantTool): SubagentTaskStatus {
+ if (part.state.status === "completed") return "completed"
+
+ if (part.state.status === "error") {
+ if (toolMetadata(part, "interrupted") === true) return "cancelled"
+
+ if (part.state.error.type === "aborted") return "cancelled"
+
+ return "error"
+ }
+
+ return "running"
+}
+
+function toolMetadata(part: SessionMessageAssistantTool, key: string): JsonValue | undefined {
+ if (!("metadata" in part.state)) return undefined
+
+ return part.state.metadata?.[key]
+}
+
+/**
+ * Scans a parent transcript for subagent work: completion notices, the latest
+ * uncompleted assistant message, and every matching tool call with its child,
+ * description, liveness and backgrounded flags resolved.
+ *
+ * @param messages - The parent session's messages in transcript order.
+ * @param names - Tool names that spawn a child session.
+ */
+export function scanSubagentTranscript(
+ messages: SessionMessageInfo[],
+ names: readonly string[] = subagentToolNames,
+): SubagentTranscript {
+ const completed = new Set()
+ let live: SessionMessageAssistant | undefined
+
+ for (const message of messages) {
+ if (message.type === "synthetic") {
+ if (message.metadata?.source === "subagent") {
+ const childID = text(message.metadata?.childID)
+
+ if (childID) completed.add(childID)
+ }
+
+ continue
+ }
+
+ if (message.type !== "assistant" || message.time.completed !== undefined) continue
+
+ live = message
+ }
+
+ const calls: SubagentCall[] = []
+
+ for (const message of messages) {
+ if (message.type !== "assistant") continue
+
+ for (const part of message.content) {
+ if (part.type !== "tool" || !names.includes(part.name)) continue
+
+ if (part.state.status === "streaming") continue
+
+ calls.push({
+ partID: part.id,
+ childID: toolSessionID(part),
+ description: toolDescription(part),
+ agent: toolAgent(part),
+ status: part.state.status,
+ backgrounded: partBackgrounded(part),
+ live: message === live,
+ part,
+ })
+ }
+ }
+
+ return { calls, completed, live }
+}