feat(desktop): add MCP settings and live subagent tree

Surface configured MCP servers in Settings with JSON edit/save and a gateway-backed reload action so users can manage tool servers without falling back to slash commands.

Track live subagent gateway events in a desktop store, show active subagent counts in the Agents statusbar item, and replace the Agents overlay stub with a live spawn tree for the active session.
This commit is contained in:
Brooklyn Nicholson 2026-05-13 12:12:12 -04:00
parent ca2c3d4ab4
commit 17e86dddc7
10 changed files with 620 additions and 12 deletions

View file

@ -5,7 +5,8 @@ import { Activity, AlertCircle, Layers3, Loader2, type LucideIcon, RefreshCw, Sp
import { cn } from '@/lib/utils'
import { $desktopActionTasks, buildRailTasks, type RailTask, type RailTaskStatus } from '@/store/activity'
import { $previewServerRestart } from '@/store/preview'
import { $sessions, $workingSessionIds } from '@/store/session'
import { $activeSessionId, $sessions, $workingSessionIds } from '@/store/session'
import { $subagentsBySession, buildSubagentTree, type SubagentNode, type SubagentStatus } from '@/store/subagents'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { OverlayCard } from '../overlays/overlay-chrome'
@ -49,10 +50,12 @@ interface AgentsViewProps {
export function AgentsView({ initialSection = 'tree', onClose }: AgentsViewProps) {
const [section, setSection] = useRouteEnumParam('section', SECTION_IDS, initialSection)
const activeSessionId = useStore($activeSessionId)
const sessions = useStore($sessions)
const workingSessionIds = useStore($workingSessionIds)
const previewRestart = useStore($previewServerRestart)
const desktopActionTasks = useStore($desktopActionTasks)
const subagentsBySession = useStore($subagentsBySession)
const activityTasks = useMemo(
() => buildRailTasks(workingSessionIds, sessions, previewRestart, desktopActionTasks),
@ -60,6 +63,8 @@ export function AgentsView({ initialSection = 'tree', onClose }: AgentsViewProps
)
const active = SECTIONS.find(s => s.id === section) ?? SECTIONS[0]!
const activeSubagents = activeSessionId ? (subagentsBySession[activeSessionId] ?? []) : []
const tree = useMemo(() => buildSubagentTree(activeSubagents), [activeSubagents])
return (
<OverlayView closeLabel="Close agents" onClose={onClose}>
@ -82,13 +87,91 @@ export function AgentsView({ initialSection = 'tree', onClose }: AgentsViewProps
<p className="text-xs text-muted-foreground">{active.description}</p>
</header>
{section === 'activity' ? <ActivityList tasks={activityTasks} /> : <SectionStub label={active.label} />}
{section === 'tree' ? (
<SubagentTree tree={tree} />
) : section === 'activity' ? (
<ActivityList tasks={activityTasks} />
) : (
<SectionStub label={active.label} />
)}
</OverlayMain>
</OverlaySplitLayout>
</OverlayView>
)
}
const STATUS_CLASS: Record<SubagentStatus, string> = {
completed: 'text-emerald-500',
failed: 'text-destructive',
interrupted: 'text-amber-500',
queued: 'text-muted-foreground',
running: 'text-primary'
}
function SubagentTree({ tree }: { tree: SubagentNode[] }) {
if (tree.length === 0) {
return (
<OverlayCard className="grid place-items-center gap-3 px-6 py-12 text-center">
<Sparkles className="size-6 text-muted-foreground/70" />
<div className="grid gap-1">
<p className="text-sm font-medium text-foreground">No live subagents</p>
<p className="max-w-md text-xs leading-relaxed text-muted-foreground">
When a turn delegates work, child agents appear here as a live spawn tree.
</p>
</div>
</OverlayCard>
)
}
return (
<div className="grid gap-2 overflow-y-auto pr-1">
{tree.map(node => (
<SubagentRow key={node.id} node={node} />
))}
</div>
)
}
function SubagentRow({ node, depth = 0 }: { node: SubagentNode; depth?: number }) {
const running = node.status === 'running' || node.status === 'queued'
return (
<OverlayCard className="px-3 py-2" style={{ marginLeft: depth ? `${Math.min(depth, 4) * 1.25}rem` : undefined }}>
<div className="flex items-start gap-2">
{running ? (
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin text-primary" />
) : (
<Sparkles className={cn('mt-0.5 size-3.5 shrink-0', STATUS_CLASS[node.status])} />
)}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-sm font-medium text-foreground">{node.goal}</div>
<span className={cn('shrink-0 text-[0.65rem]', STATUS_CLASS[node.status])}>{node.status}</span>
</div>
<div className="mt-0.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.68rem] text-muted-foreground">
{node.model && <span>{node.model}</span>}
{typeof node.durationSeconds === 'number' && <span>{node.durationSeconds.toFixed(1)}s</span>}
{typeof node.costUsd === 'number' && <span>${node.costUsd.toFixed(4)}</span>}
{typeof node.apiCalls === 'number' && <span>{node.apiCalls} calls</span>}
</div>
{(node.toolName || node.toolPreview || node.summary) && (
<div className="mt-1 line-clamp-2 text-xs leading-relaxed text-muted-foreground">
{node.summary || [node.toolName, node.toolPreview].filter(Boolean).join(' · ')}
</div>
)}
</div>
</div>
{node.children.length > 0 && (
<div className="mt-2 grid gap-2">
{node.children.map(child => (
<SubagentRow depth={depth + 1} key={child.id} node={child} />
))}
</div>
)}
</OverlayCard>
)
}
function ActivityList({ tasks }: { tasks: readonly RailTask[] }) {
if (tasks.length === 0) {
return (

View file

@ -424,6 +424,7 @@ export function DesktopController() {
{settingsOpen && (
<Suspense fallback={null}>
<SettingsView
gateway={gatewayRef.current}
onClose={closeOverlayToPreviousRoute}
onConfigSaved={() => {
void refreshHermesConfig()

View file

@ -31,6 +31,7 @@ import {
setCurrentUsage,
setTurnStartedAt
} from '@/store/session'
import { clearSessionSubagents, upsertSubagent } from '@/store/subagents'
import { recordToolDiff } from '@/store/tool-diffs'
import type { RpcEvent } from '@/types/hermes'
@ -59,6 +60,14 @@ interface QueuedStreamDeltas {
}
const STREAM_DELTA_FLUSH_MS = 16
const SUBAGENT_EVENT_TYPES = new Set([
'subagent.spawn_requested',
'subagent.start',
'subagent.thinking',
'subagent.tool',
'subagent.progress',
'subagent.complete'
])
// Anonymous progress events that carry todos but no name still belong to the
// todo stream; named todo events are obviously routed there too.
@ -506,6 +515,7 @@ export function useMessageStream({
}
flushQueuedDeltas(sessionId)
clearSessionSubagents(sessionId)
if (isActiveEvent) {
triggerHaptic('streamStart')
@ -575,6 +585,10 @@ export function useMessageStream({
if (typeof payload?.inline_diff === 'string' && payload.inline_diff.trim()) {
recordToolDiff(payload.tool_id || payload.name || '', payload.inline_diff)
}
} else if (SUBAGENT_EVENT_TYPES.has(event.type)) {
if (sessionId && payload) {
upsertSubagent(sessionId, payload as Record<string, unknown>, event.type === 'subagent.spawn_requested')
}
} else if (event.type === 'clarify.request') {
if (!isActiveEvent) {
return

View file

@ -311,10 +311,11 @@ export const MODE_OPTIONS: ModeOption[] = [
{ id: 'system', label: 'System', description: 'Follow macOS appearance', icon: Monitor }
]
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'tools', string> = {
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools', string> = {
about: 'About Hermes Desktop',
config: 'Search settings...',
gateway: 'Gateway connection...',
keys: 'Search API keys...',
mcp: 'Search MCP servers...',
tools: 'Search skills and tools...'
}

View file

@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { Globe, Info, KeyRound, Package } from '@/lib/icons'
import { Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@ -18,6 +18,7 @@ import { ConfigSettings } from './config-settings'
import { SEARCH_PLACEHOLDER, SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KeysSettings } from './keys-settings'
import { McpSettings } from './mcp-settings'
import { ToolsSettings } from './tools-settings'
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
@ -25,11 +26,12 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
'gateway',
'keys',
'mcp',
'tools',
'about'
]
export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPageProps) {
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
const [queries, setQueries] = useState<Record<SettingsQueryKey, string>>({
@ -37,6 +39,7 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
config: '',
gateway: '',
keys: '',
mcp: '',
tools: ''
})
@ -147,6 +150,12 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
label="Skills & Tools"
onClick={() => setActiveView('tools')}
/>
<OverlayNavItem
active={activeView === 'mcp'}
icon={Wrench}
label="MCP"
onClick={() => setActiveView('mcp')}
/>
<div className="my-2 h-px bg-border/30" />
<OverlayNavItem
active={activeView === 'about'}
@ -196,6 +205,8 @@ export function SettingsView({ onClose, onConfigSaved }: SettingsPageProps) {
/>
) : activeView === 'keys' ? (
<KeysSettings query={queries.keys} />
) : activeView === 'mcp' ? (
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />
) : (
<ToolsSettings query={queries.tools} />
)}

View file

@ -0,0 +1,259 @@
import { useEffect, useMemo, useState } from 'react'
import { OverlayActionButton, OverlayCard } from '@/app/overlays/overlay-chrome'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { getHermesConfigRecord, saveHermesConfig, type HermesGateway } from '@/hermes'
import { Package, Wrench } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import { $activeSessionId } from '@/store/session'
import { useStore } from '@nanostores/react'
import type { HermesConfigRecord } from '@/types/hermes'
import { includesQuery } from './helpers'
import { EmptyState, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
import type { SearchProps } from './types'
interface McpSettingsProps extends SearchProps {
gateway?: HermesGateway | null
onConfigSaved?: () => void
}
type McpServers = Record<string, Record<string, unknown>>
const EMPTY_SERVER = {
command: '',
args: [],
env: {}
}
function getServers(config: HermesConfigRecord | null): McpServers {
const raw = config?.mcp_servers
return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as McpServers) : {}
}
const transportLabel = (server: Record<string, unknown>) =>
typeof server.transport === 'string'
? server.transport
: typeof server.url === 'string'
? 'http'
: typeof server.command === 'string'
? 'stdio'
: 'custom'
function serverMatches(name: string, server: Record<string, unknown>, query: string) {
if (!query) {
return true
}
return includesQuery(name, query) || includesQuery(JSON.stringify(server), query)
}
export function McpSettings({ gateway, onConfigSaved, query }: McpSettingsProps) {
const activeSessionId = useStore($activeSessionId)
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
const [selected, setSelected] = useState<string | null>(null)
const [name, setName] = useState('')
const [body, setBody] = useState('')
const [saving, setSaving] = useState(false)
const [reloading, setReloading] = useState(false)
useEffect(() => {
let cancelled = false
getHermesConfigRecord()
.then(next => {
if (cancelled) return
setConfig(next)
const first = Object.keys(getServers(next)).sort()[0] ?? null
setSelected(first)
})
.catch(err => notifyError(err, 'MCP config failed to load'))
return () => void (cancelled = true)
}, [])
const servers = useMemo(() => getServers(config), [config])
const names = useMemo(() => Object.keys(servers).sort(), [servers])
const filtered = useMemo(
() => names.filter(serverName => serverMatches(serverName, servers[serverName], query.trim().toLowerCase())),
[names, query, servers]
)
useEffect(() => {
const server = selected ? servers[selected] : null
setName(selected ?? '')
setBody(JSON.stringify(server ?? EMPTY_SERVER, null, 2))
}, [selected, servers])
if (!config) {
return <LoadingState label="Loading MCP servers..." />
}
const saveServer = async () => {
const nextName = name.trim()
if (!nextName) {
notify({ kind: 'error', title: 'Name required', message: 'Give this MCP server a config key.' })
return
}
let parsed: Record<string, unknown>
try {
const raw = JSON.parse(body)
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error('Server config must be a JSON object')
}
parsed = raw as Record<string, unknown>
} catch (err) {
notifyError(err, 'Invalid MCP JSON')
return
}
setSaving(true)
try {
const nextServers = { ...servers }
if (selected && selected !== nextName) {
delete nextServers[selected]
}
nextServers[nextName] = parsed
const nextConfig = { ...config, mcp_servers: nextServers }
await saveHermesConfig(nextConfig)
setConfig(nextConfig)
setSelected(nextName)
onConfigSaved?.()
notify({ kind: 'success', title: 'MCP server saved', message: `${nextName} applies after MCP reload.` })
} catch (err) {
notifyError(err, 'Save failed')
} finally {
setSaving(false)
}
}
const removeServer = async (serverName: string) => {
setSaving(true)
try {
const nextServers = { ...servers }
delete nextServers[serverName]
const nextConfig = { ...config, mcp_servers: nextServers }
await saveHermesConfig(nextConfig)
setConfig(nextConfig)
setSelected(Object.keys(nextServers).sort()[0] ?? null)
onConfigSaved?.()
} catch (err) {
notifyError(err, 'Remove failed')
} finally {
setSaving(false)
}
}
const reloadMcp = async () => {
if (!gateway) {
notify({ kind: 'warning', title: 'Gateway unavailable', message: 'Reconnect the gateway before reloading MCP.' })
return
}
setReloading(true)
try {
await gateway.request('reload.mcp', {
confirm: true,
session_id: activeSessionId ?? undefined
})
notify({ kind: 'success', title: 'MCP tools reloaded', message: 'New tool schemas apply to fresh turns.' })
} catch (err) {
notifyError(err, 'MCP reload failed')
} finally {
setReloading(false)
}
}
return (
<SettingsContent>
<div className="mb-4 flex items-center justify-between gap-3">
<SectionHeading icon={Package} meta={`${names.length} configured`} title="MCP servers" />
<div className="flex items-center gap-2">
<OverlayActionButton onClick={() => setSelected(null)}>New server</OverlayActionButton>
<OverlayActionButton disabled={reloading} onClick={() => void reloadMcp()}>
{reloading ? 'Reloading...' : 'Reload MCP'}
</OverlayActionButton>
</div>
</div>
<div className="grid min-h-0 gap-4 lg:grid-cols-[17rem_minmax(0,1fr)]">
<OverlayCard className="min-h-64 overflow-hidden p-2">
{filtered.length === 0 ? (
<EmptyState description="Add a stdio or HTTP server to expose MCP tools." title="No MCP servers" />
) : (
<div className="grid gap-1">
{filtered.map(serverName => {
const server = servers[serverName]
const active = selected === serverName
return (
<button
className={`rounded-md px-2 py-2 text-left transition-colors hover:bg-(--chrome-action-hover) ${
active ? 'bg-accent/45 text-foreground' : 'text-muted-foreground'
}`}
key={serverName}
onClick={() => setSelected(serverName)}
type="button"
>
<div className="truncate text-sm font-medium">{serverName}</div>
<div className="mt-1 flex items-center gap-1.5">
<Pill>{transportLabel(server)}</Pill>
{server.disabled === true && <Pill>disabled</Pill>}
</div>
</button>
)
})}
</div>
)}
</OverlayCard>
<OverlayCard className="grid gap-3 p-4">
<div className="flex items-center gap-2 text-sm font-medium">
<Wrench className="size-4 text-muted-foreground" />
{selected ? 'Edit server' : 'New server'}
</div>
<label className="grid gap-1.5">
<span className="text-xs text-muted-foreground">Name</span>
<Input onChange={event => setName(event.currentTarget.value)} placeholder="filesystem" value={name} />
</label>
<label className="grid gap-1.5">
<span className="text-xs text-muted-foreground">Server JSON</span>
<Textarea
className="min-h-80 font-mono text-xs"
onChange={event => setBody(event.currentTarget.value)}
spellCheck={false}
value={body}
/>
</label>
<div className="flex items-center justify-between">
{selected ? (
<OverlayActionButton disabled={saving} onClick={() => void removeServer(selected)} tone="danger">
Remove
</OverlayActionButton>
) : (
<span />
)}
<OverlayActionButton disabled={saving} onClick={() => void saveServer()}>
{saving ? 'Saving...' : 'Save server'}
</OverlayActionButton>
</div>
</OverlayCard>
</div>
</SettingsContent>
)
}

View file

@ -1,13 +1,15 @@
import type { Dispatch, SetStateAction } from 'react'
import type { HermesGateway } from '@/hermes'
import type { LucideIcon } from '@/lib/icons'
import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'tools' | `config:${string}`
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'tools'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'tools' | `config:${string}`
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'tools'
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
export interface SettingsPageProps {
gateway?: HermesGateway | null
onClose: () => void
onConfigSaved?: () => void
}

View file

@ -22,6 +22,7 @@ import {
$workingSessionIds,
setModelPickerOpen
} from '@/store/session'
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } from '@/store/updates'
import type { StatusResponse } from '@/types/hermes'
@ -63,6 +64,7 @@ export function useStatusbarItems({
const sessionStartedAt = useStore($sessionStartedAt)
const turnStartedAt = useStore($turnStartedAt)
const workingSessionIds = useStore($workingSessionIds)
const subagentsBySession = useStore($subagentsBySession)
const updateStatus = useStore($updateStatus)
const updateApply = useStore($updateApply)
const desktopVersion = useStore($desktopVersion)
@ -106,15 +108,20 @@ export function useStatusbarItems({
[gatewayLogLines, handleRestartGateway, openCommandCenterSection, restartingGateway, statusSnapshot]
)
const { bgFailed, bgRunning } = useMemo(() => {
const { bgFailed, bgRunning, subagentsRunning } = useMemo(() => {
const actions = Object.values(desktopActionTasks)
const running = actions.filter(t => t.status.running).length
const failed = actions.filter(t => !t.status.running && (t.status.exit_code ?? 0) !== 0).length
const previewRunning = previewServerRestartStatus === 'running' ? 1 : 0
const previewFailed = previewServerRestartStatus === 'error' ? 1 : 0
const subagentsRunning = Object.values(subagentsBySession).reduce((sum, items) => sum + activeSubagentCount(items), 0)
return { bgFailed: failed + previewFailed, bgRunning: workingSessionIds.length + running + previewRunning }
}, [desktopActionTasks, previewServerRestartStatus, workingSessionIds])
return {
bgFailed: failed + previewFailed,
bgRunning: workingSessionIds.length + running + previewRunning,
subagentsRunning
}
}, [desktopActionTasks, previewServerRestartStatus, subagentsBySession, workingSessionIds])
const gatewayUp = Boolean(statusSnapshot?.gateway_running)
@ -189,11 +196,18 @@ export function useStatusbarItems({
agentsOpen && 'bg-accent/55 text-foreground',
bgFailed > 0 && 'text-destructive hover:text-destructive'
),
detail: bgFailed > 0 ? `${bgFailed} failed` : bgRunning > 0 ? `${bgRunning} running` : undefined,
detail:
subagentsRunning > 0
? `${subagentsRunning} subagent${subagentsRunning === 1 ? '' : 's'}`
: bgFailed > 0
? `${bgFailed} failed`
: bgRunning > 0
? `${bgRunning} running`
: undefined,
icon:
bgFailed > 0 ? (
<AlertCircle className="size-3" />
) : bgRunning > 0 ? (
) : bgRunning > 0 || subagentsRunning > 0 ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Sparkles className="size-3" />
@ -214,6 +228,7 @@ export function useStatusbarItems({
gatewayUp,
openAgents,
statusSnapshot?.gateway_state,
subagentsRunning,
toggleCommandCenter
]
)

View file

@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { $subagentsBySession, activeSubagentCount, buildSubagentTree, clearSessionSubagents, upsertSubagent } from './subagents'
describe('subagent store', () => {
beforeEach(() => {
$subagentsBySession.set({})
})
it('upserts subagent progress and keeps terminal status stable', () => {
upsertSubagent('s1', {
goal: 'scan files',
status: 'running',
subagent_id: 'a1',
task_index: 0
})
upsertSubagent('s1', {
goal: 'scan files',
status: 'completed',
subagent_id: 'a1',
summary: 'done',
task_index: 0
})
upsertSubagent('s1', {
goal: 'scan files',
status: 'running',
subagent_id: 'a1',
task_index: 0,
text: 'late'
})
const item = $subagentsBySession.get().s1?.[0]
expect(item?.status).toBe('completed')
expect(item?.summary).toBe('done')
})
it('builds parent/child trees', () => {
upsertSubagent('s1', { goal: 'parent', status: 'running', subagent_id: 'p', task_index: 0 })
upsertSubagent('s1', {
goal: 'child',
parent_id: 'p',
status: 'queued',
subagent_id: 'c',
task_index: 1
})
const tree = buildSubagentTree($subagentsBySession.get().s1 ?? [])
expect(tree).toHaveLength(1)
expect(tree[0]?.children[0]?.goal).toBe('child')
expect(activeSubagentCount($subagentsBySession.get().s1 ?? [])).toBe(2)
})
it('clears one session without touching another', () => {
upsertSubagent('s1', { goal: 'one', status: 'running', subagent_id: 'a1', task_index: 0 })
upsertSubagent('s2', { goal: 'two', status: 'running', subagent_id: 'a2', task_index: 0 })
clearSessionSubagents('s1')
expect($subagentsBySession.get().s1).toBeUndefined()
expect($subagentsBySession.get().s2).toHaveLength(1)
})
})

View file

@ -0,0 +1,159 @@
import { atom } from 'nanostores'
export type SubagentStatus = 'completed' | 'failed' | 'interrupted' | 'queued' | 'running'
export interface SubagentProgress {
id: string
apiCalls?: number
costUsd?: number
depth: number
durationSeconds?: number
filesRead: string[]
filesWritten: string[]
goal: string
inputTokens?: number
model?: string
outputTail: { isError?: boolean; preview?: string; tool?: string }[]
outputTokens?: number
parentId: null | string
reasoningTokens?: number
sessionId: string
status: SubagentStatus
summary?: string
taskCount: number
taskIndex: number
toolName?: string
toolPreview?: string
toolsets: string[]
updatedAt: number
}
export interface SubagentNode extends SubagentProgress {
children: SubagentNode[]
}
export type SubagentPayload = Record<string, unknown>
export const $subagentsBySession = atom<Record<string, SubagentProgress[]>>({})
const TERMINAL = new Set<SubagentStatus>(['completed', 'failed', 'interrupted'])
const asString = (value: unknown) => (typeof value === 'string' ? value : '')
const asNumber = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined)
const asStatus = (value: unknown): SubagentStatus =>
value === 'completed' || value === 'failed' || value === 'interrupted' || value === 'queued' ? value : 'running'
const asStringList = (value: unknown) => (Array.isArray(value) ? value.map(asString).filter(Boolean) : [])
const asOutputTail = (value: unknown): SubagentProgress['outputTail'] =>
Array.isArray(value)
? value
.map(item => (item && typeof item === 'object' ? (item as Record<string, unknown>) : null))
.filter((item): item is Record<string, unknown> => Boolean(item))
.map(item => ({
isError: item.is_error === true,
preview: asString(item.preview) || undefined,
tool: asString(item.tool) || undefined
}))
: []
function idFor(payload: SubagentPayload) {
return (
asString(payload.subagent_id) ||
`${asString(payload.parent_id) || 'root'}:${asNumber(payload.task_index) ?? 0}:${asString(payload.goal)}`
)
}
function toProgress(sessionId: string, payload: SubagentPayload, previous?: SubagentProgress): SubagentProgress {
return {
apiCalls: asNumber(payload.api_calls) ?? previous?.apiCalls,
costUsd: asNumber(payload.cost_usd) ?? previous?.costUsd,
depth: asNumber(payload.depth) ?? previous?.depth ?? 0,
durationSeconds: asNumber(payload.duration_seconds) ?? previous?.durationSeconds,
filesRead: asStringList(payload.files_read).length ? asStringList(payload.files_read) : (previous?.filesRead ?? []),
filesWritten: asStringList(payload.files_written).length
? asStringList(payload.files_written)
: (previous?.filesWritten ?? []),
goal: asString(payload.goal) || previous?.goal || 'Subagent',
id: previous?.id || idFor(payload),
inputTokens: asNumber(payload.input_tokens) ?? previous?.inputTokens,
model: asString(payload.model) || previous?.model,
outputTail: asOutputTail(payload.output_tail).length ? asOutputTail(payload.output_tail) : (previous?.outputTail ?? []),
outputTokens: asNumber(payload.output_tokens) ?? previous?.outputTokens,
parentId: asString(payload.parent_id) || previous?.parentId || null,
reasoningTokens: asNumber(payload.reasoning_tokens) ?? previous?.reasoningTokens,
sessionId,
status: asStatus(payload.status),
summary: asString(payload.summary) || previous?.summary,
taskCount: asNumber(payload.task_count) ?? previous?.taskCount ?? 1,
taskIndex: asNumber(payload.task_index) ?? previous?.taskIndex ?? 0,
toolName: asString(payload.tool_name) || previous?.toolName,
toolPreview: asString(payload.tool_preview) || asString(payload.text) || previous?.toolPreview,
toolsets: asStringList(payload.toolsets).length ? asStringList(payload.toolsets) : (previous?.toolsets ?? []),
updatedAt: Date.now()
}
}
export function clearSessionSubagents(sessionId: string) {
const current = $subagentsBySession.get()
if (!(sessionId in current)) {
return
}
const next = { ...current }
delete next[sessionId]
$subagentsBySession.set(next)
}
export function upsertSubagent(sessionId: string, payload: SubagentPayload, createIfMissing = true) {
const current = $subagentsBySession.get()
const list = current[sessionId] ?? []
const id = idFor(payload)
const index = list.findIndex(item => item.id === id)
if (index < 0 && !createIfMissing) {
return
}
const previous = index >= 0 ? list[index] : undefined
if (previous && TERMINAL.has(previous.status)) {
return
}
const nextItem = toProgress(sessionId, payload, previous)
const nextList = index >= 0 ? list.map(item => (item.id === id ? nextItem : item)) : [...list, nextItem]
$subagentsBySession.set({ ...current, [sessionId]: nextList })
}
export function buildSubagentTree(items: readonly SubagentProgress[]): SubagentNode[] {
const nodes = new Map<string, SubagentNode>()
for (const item of items) {
nodes.set(item.id, { ...item, children: [] })
}
const roots: SubagentNode[] = []
for (const node of nodes.values()) {
const parent = node.parentId ? nodes.get(node.parentId) : null
if (parent) {
parent.children.push(node)
} else {
roots.push(node)
}
}
const sort = (a: SubagentNode, b: SubagentNode) => a.taskIndex - b.taskIndex || a.goal.localeCompare(b.goal)
const walk = (node: SubagentNode) => node.children.sort(sort).forEach(walk)
roots.sort(sort).forEach(walk)
return roots
}
export const activeSubagentCount = (items: readonly SubagentProgress[]) =>
items.filter(item => item.status === 'queued' || item.status === 'running').length