Subagent Orchestration Architecture

Super Multica — Parent-child agent spawning, lifecycle management, and result announcement

System Architecture

Agent Parent Agent (Interactive Session)
User-facing agent with full tool access. Can spawn child agents via sessions_spawn tool. Receives announcement messages when child agents complete.
src/agent/runner.ts → tools: sessions_spawn, exec, glob, web_fetch, ...
spawn announce
Singleton Hub
Central coordinator. Creates & manages all agents. Provides createSubagent(), getAgent(), closeAgent(). Calls registry init on startup, shutdown on exit.
src/hub/hub.ts + hub-singleton.ts
Module Subagent Registry
In-memory Map + JSON persistence. Tracks run lifecycle (created → started → ended). Archive sweeper cleans old runs every 60s. Handles crash recovery on restart.
src/agent/subagent/registry.ts
createSubagent() watchChildAgent()
Agent Child AsyncAgent
Isolated agent with isSubagent: true. Restricted tools (no sessions_spawn). Custom system prompt: stay focused, no user messaging, no nested spawning.
src/agent/async-agent.ts
Flow Announce Module
Reads child's last assistant reply from session JSONL. Formats announcement message with findings, duration, status. Delivers to parent via parentAgent.write().
src/agent/subagent/announce.ts
Store Registry Store
JSON file persistence at ~/.super-multica/subagents/runs.json. Schema: { version: 1, runs: {...} }. Survives process restarts.
src/agent/subagent/registry-store.ts

Call Chain — Spawn & Lifecycle

Parent Agent invokes sessions_spawn tool
Agent calls tool with { task, label?, model?, cleanup?, timeoutSeconds? }. Guard rejects if isSubagent === true.
sessions-spawn.ts → execute()
Generate IDs & build system prompt
runId = UUIDv7, childSessionId = UUIDv7. buildSubagentSystemPrompt() creates prompt with task context, rules (no nested spawn, stay focused).
announce.ts → buildSubagentSystemPrompt()
Hub creates child AsyncAgent
hub.createSubagent(childSessionId, { systemPrompt, model }) creates an AsyncAgent with isSubagent: true. Not persisted to agent store (ephemeral).
hub.ts → createSubagent()
Write task to child (non-blocking)
childAgent.write(task) enqueues the task to the serial queue. This happens before registration so waitForIdle() observes queued work.
async-agent.ts → write() (enqueues to serial queue)
Register run in registry
registerSubagentRun() saves record to in-memory Map + JSON file. Sets createdAt, starts archive sweeper.
registry.ts → registerSubagentRun()
Start lifecycle watcher & return to parent
watchChildAgent() sets startedAt. Attaches childAgent.waitForIdle() (promise resolves when task queue drained) and childAgent.onClose() callback. Optionally sets timeout timer. Tool returns { status: "accepted", childSessionId, runId } immediately.
registry.ts → watchChildAgent() → AsyncAgent.waitForIdle() + onClose()
Child agent processes task autonomously
Child runs LLM inference with restricted tools. Uses its own session. May call exec, glob, web_fetch etc. but NOT sessions_spawn.
runner.ts → Agent.run() (within AsyncAgent queue)
Child completes → waitForIdle() resolves
Task queue drains. Watcher's cleanup callback fires: sets endedAt, outcome: { status: "ok" }. Persists updated record to JSON.
registry.ts → cleanup() → handleRunCompletion()
Announce flow: read child reply & deliver to parent
readLatestAssistantReply(childSessionId) reads session JSONL, extracts last assistant text. formatAnnouncementMessage() builds summary with task, status, findings, runtime. parentAgent.write(message) delivers to parent.
announce.ts → runSubagentAnnounceFlow() → hub.getAgent(parentId).write()
Session cleanup & archive
If cleanup === "delete": removes child session directory + closes agent in Hub. Schedules archive at now + 60min. Sweeper removes from registry after TTL.
registry.ts → deleteChildSession() + sweep()

Sequence Diagram

Parent Agent sessions_spawn Hub Registry Child Agent execute({ task, label }) createSubagent(id, opts) new AsyncAgent({ isSubagent: true }) childAgent.write(task) registerSubagentRun(params) waitForIdle() + onClose() { status: "accepted", runId } async (non-blocking) LLM inference idle (resolved) announce parentAgent.write(announcement) deleteChildSession() archive 60m

Run State Machine

created
startedAt
started
endedAt
ended
announce
cleanup done
60 min
archived
Outcome Status Values
ok — Task completed normally (waitForIdle resolved) error — Child agent threw an error timeout — Exceeded timeoutSeconds limit unknown — Process crash or Hub shutdown

Module Map

src/agent/subagent/types.ts
Core type definitions
export SubagentRunOutcome
export SubagentRunRecord
export RegisterSubagentRunParams
export SubagentAnnounceParams
export SubagentSystemPromptParams
src/agent/subagent/registry.ts
In-memory registry + lifecycle watcher
export initSubagentRegistry()
export registerSubagentRun()
export listSubagentRuns()
export releaseSubagentRun()
export getSubagentRun()
export shutdownSubagentRegistry()
src/agent/subagent/registry-store.ts
JSON file persistence
export loadSubagentRuns()
export saveSubagentRuns()
export getSubagentStorePath()
src/agent/subagent/announce.ts
Result propagation child → parent
export buildSubagentSystemPrompt()
export readLatestAssistantReply()
export formatAnnouncementMessage()
export runSubagentAnnounceFlow()
src/agent/tools/sessions-spawn.ts
Tool definition for parent agents
export createSessionsSpawnTool()
schema: { task, label?, model?,
  cleanup?, timeoutSeconds? }
src/hub/hub-singleton.ts
Global Hub access for tools & registry
export setHub(hub)
export getHub()
export isHubInitialized()

Key Design Decisions

waitForIdle() vs stream consumption
Channel is single-reader. Hub.consumeAgent() already reads the stream for forwarding events. Registry uses waitForIdle() (promise on internal task queue) to detect completion without competing for the stream.
Singleton Hub access
Tools and registry modules cannot receive Hub via constructor injection (tools are created before Hub exists). A module-level singleton (setHub/getHub) bridges this gap, with isHubInitialized() guard for test safety.
Crash recovery
Runs are persisted to JSON after every state change. On restart, initSubagentRegistry() loads persisted runs: completed-but-unannounced runs trigger announce flow; unfinished runs are marked as status: "unknown".
Subagent isolation
Child agents have isSubagent: true which applies tool deny-list (blocks sessions_spawn). System prompt explicitly forbids nested spawning, direct user communication, and off-topic work. Sessions are ephemeral — deleted after announce unless cleanup: "keep".
Super Multica — Subagent Orchestration System — Branch: subagent-orchestration