Files
harness-tools/extensions/subagents/docs/design-plan.md
T

34 KiB
Raw Blame History

subagents — Design Plan

A pi extension that fires off background subagents from a parent pi session, where each subagent can be powered by one of three backends — pi (in-process SDK session), Claude Code (@anthropic-ai/claude-agent-sdk), or Codex (codex app-server) — unified behind a single Effect v4 service interface.

Status: this document describes the original v1 plan (stubbed backends). All three backends are now REAL implementations — see src/backends/{pi,claude,codex}.ts. The stub machinery survives in src/backends/stub.ts for the manager test registry.

Scope of the first version: interface design + stubbed backend internals + the v1 UI carried over. No real Claude/Codex process integration yet; the pi backend may also stay stubbed initially so the manager/UI/tool loop can be exercised end to end with zero external dependencies.

Location: /Users/davis/.pi/agent/extensions/subagents/ — fully self-contained (no imports from ../shared or ../subagents; the handful of shared helpers v1 uses are copied in).


1. V1 inventory (what must be preserved)

Source: /Users/davis/.pi/agent/extensions/subagents/ (index.ts, manager.ts, prompt.ts, result-delivery.ts, takeover.ts) plus ../shared/ helpers.

1.1 Tools exposed to the parent LLM

Tool Parameters Behavior
subagent_spawn prompt, title, working_dir?, model?, provider?, reasoning_effort? Fire-and-forget spawn. Returns immediately with an id (sa-N). Enforces MAX_RUNNING = 4 with a synchronous reservation so parallel tool calls can't race past the cap. Validates working_dir, resolves model against the registry (inherit parent model/thinking level by default), truncates title to 160 chars.
subagent_wait ids[] (max 64) Blocks until all listed subagents settle; respects the tool AbortSignal; streams Waiting for ... via onUpdate. Marks the awaited results "consumed" so they are not also auto-delivered. Output budgets: 48KB total, 16KB per agent, with per-section fallbacks ([omitted: ...]). Errors on unknown ids (lists known ids).
subagent_cancel ids[] Aborts running subagents (marks consumed first to avoid duplicate delivery), waits for settlement, reports per-id Cancelled ... / was already <status>. Partial transcripts remain on disk.
subagent_check id Non-blocking peek: status line, turn count, error text, up to 2KB/20 lines of latest output (includes the live streaming assistant message). Does not consume the result.
subagent_list — One describeSubagent() line per agent: id [status] "title" (provider/model, ctx%, elapsed, cwd).

Prompt metadata (all strings live in prompt.ts): subagent_spawn has a promptSnippet and two promptGuidelines (delegate self-contained tasks; don't block on subagent_wait unless necessary). Tool descriptions explain fire-and-forget semantics, the concurrency cap, and that children can't orchestrate/see the parent conversation.

1.2 State tracking (v1 SubagentManager)

  • Plain class with Map<string, Subagent>; each Subagent = { id, title, prompt, cwd, session: AgentSession, status: "running" | "done" | "error", createdAt, settledAt?, errorText?, unsubscribeLifecycle }.
  • Children are in-process pi AgentSessions created via the SDK (createAgentSession + SessionManager.create(cwd) → real session files visible in /resume), with child resources loaded per-cwd (DefaultResourceLoader, trust-gated project resources) and a tool denylist (excludeTools: the subagent_* tools, workflow, ask_user).
  • Settlement is driven by session lifecycle events (agent_start re-marks running; agent_settled settles). Failure detection: thrown prompt error, last assistant stopReason === "error" | "aborted", error text bounded to 4096 chars.
  • Change notification: addChangeListener() + nextChange(signal) promise — used by waitFor, the footer status, and the dashboard.
  • waitFor(ids, signal, onPending) keeps a waitInterest refcount per id so settles during an active wait are marked consumed.
  • send(sub, text): steer via session.steer() while streaming, else start a fresh prompt() run (used by takeover).
  • Caps and cleanup: MAX_RUNNING = 4, MAX_TRACKED = 64 with LRU pruning of settled agents, STOP_TIMEOUT_MS = 5s bounded aborts, force-dispose fallback, idempotent disposeAll() on session_shutdown.

1.3 Result delivery back to the parent

  • When a child settles unconsumed, onSettled defers it into a tiny createDeferredResultDelivery buffer (defer/consume/drain/clear keyed by id).
  • Flush happens when the parent goes idle: immediately if sessionContext.isIdle(), otherwise on the parent's agent_settled event. A later subagent_wait can still consume a deferred result before flush (that's why it is a buffer, not an immediate send).
  • Delivery = pi.sendMessage({ customType: "subagent-result", content, display: true, details: { id, title, status } }, { deliverAs: "followUp", triggerTurn: true }). Content is built by buildSubagentResultMessage (Subagent sa-N "title" finished/failed. + optional Error: line + output truncated to 24KB/600 lines with a pointer to the child session file for the full transcript).

1.4 UI (carried over into v2 essentially as-is)

  1. Footer status (ctx.ui.setStatus("subagents", ...)): subagents: ■ 2 running · ■ 1 done · ■ 1 failed · /subagents to view (warning/success/error colored squares; cleared when no subagents). Driven by manager change listener.
  2. subagent-result message renderer: status icon (■/x) + bold accent header subagent sa-N · title · finished/failed; collapsed = first 8 body lines + ... (ctrl+o to expand); expanded = header + Markdown component render of the body.
  3. /subagents command → openSubagentPicker loop (TUI mode only; notifies and bails in non-TUI or when there are no subagents):
    • SubagentDashboard — fullscreen overlay (anchor: "center", width: "100%", maxHeight: "100%"), bordered list panel titled agents · settled/total. Each row: selection marker ❯, status glyph, title, dim id on the left; model id · context utilization (%/capacity) · elapsed · status word on the right. Scroll window centered on the selection with ... N more markers. 1Hz ticker re-render for elapsed/token columns + manager change subscription. Keys: tui.select.up/down and j/k to move, tui.select.confirm to take over, x to abort the selected running agent, tui.select.cancel to close. Hint line shows the configured keys via keybindings.getKeys().
    • TakeoverView — fullscreen overlay for one subagent: header line (status glyph, id · title · status · elapsed · provider/model · ctx%), fixed-height transcript viewport (error line and scroll indicator consume viewport rows so height never jumps), an Input line, and a hint row. Keys: tui.input.submit send (steer if streaming, new run if idle), app.interrupt/tui.select.cancel back to dashboard, app.clear abort run, tui.editor.cursorUp/Down scroll ±6 lines, tui.editor.pageUp/Down page. Renders are throttled to 50ms because streaming can emit per-token events.
    • Transcript rendering (buildTranscriptLines): sanitizes ANSI/tabs/control chars; user messages as > accent-prefixed wrapped lines; assistant text wrapped plain; thinking as dim italic ~ lines; tool calls as → toolname {args}; tool results as one dim output:/red error: first line. Includes the live streaming assistant message, live tool executions (running/done/error marker + first output line preview, tracked from tool_execution_* events until the final tool result message lands), and queued steering/follow-up messages (> [queued steer] ...) so Enter visibly acknowledges input.

V2 requirement: all of the above renders from a normalized per-subagent view instead of poking at sub.session directly — that is the main UI refactor, everything else ports over mostly verbatim.


2. Backend integration facts

These shape the interface even though v1-of-v2 stubs the internals. (Traced from the "T3 Code" codebase which integrates Codex and Claude Code.)

Interactive sessions One-shot tasks Event shape Interrupt Steering
pi In-process createAgentSession() (pi SDK); real session files; session.subscribe() session.prompt() and read final assistant message (or pi -p subprocess, not needed) AgentSessionEvent (message_start/update/end, tool_execution_*, agent_start/settled, queue_update, ...) session.abort() session.steer() / followUp()
Claude Code @anthropic-ai/claude-agent-sdk query() — SDK launches the claude executable and streams JSON messages (assistant/user/result/system, streaming partials, tool_use blocks) claude -p --output-format json SDK message stream (async iterable) query.interrupt() / abort controller streaming-input mode: push more user messages into the input iterable
Codex spawn codex app-server child process, JSON-RPC over stdin/stdout (newConversation / sendUserTurn, notifications: agentMessageDelta, execCommandBegin/End, taskComplete, tokenCount, ...) codex exec (prints result to stdout, --json for events) JSON-RPC notifications interruptConversation request send another sendUserTurn on the same conversation

Common denominator all three can supply:

  • an async event stream with: lifecycle (started/turn/settled), assistant text (deltas and/or completed messages), reasoning text, tool execution begin/update/end, token usage, errors;
  • a way to send a follow-up/steering user message into a live session;
  • an interrupt operation;
  • a final result text per run;
  • metadata: backend name, model identifier, session/log file path (pi session file, Claude session id + projects dir JSONL, Codex rollout path), working dir.

That is exactly what the normalized event model below encodes.


3. Architecture

3.1 Effect v4 conventions used

  • Single effect package (v4 beta). Services defined with ServiceMap.Service<T>()("id", ...) / ServiceMap.Key; wiring via Layer; ManagedRuntime.make(layer) at the extension edge with runtime.runPromise(effect, { signal }) inside async execute() tool handlers and await runtime.dispose() on session_shutdown.
  • Effect.gen generators throughout the internals. async/Promise appears only in: tool execute() bodies, pi.on(...) handlers, the /subagents command handler, and the imperative TUI component classes (which are callback-driven, not effectful).
  • Streams: Stream<SubagentEvent> per subagent, produced by backends (Stream.callback for push-based sources like JSON-RPC notifications / SDK iterables), consumed by a manager fiber per subagent.
  • Errors: tagged error classes (Data.TaggedError) — SpawnError, BackendUnavailable, SubagentNotFound, ConcurrencyLimitError, SendError, InterruptTimeout. Tool handlers map these to thrown Errors with the same user-facing messages v1 uses.

3.2 Domain model (src/domain.ts)

type BackendName = "pi" | "claude" | "codex";
type SubagentStatus = "running" | "done" | "error";   // unchanged from v1

interface SpawnTask {
  prompt: string;
  title: string;
  cwd: string;
  // Generic model hint; each backend interprets/validates it its own way.
  model?: string;            // pi: "provider/model-id"; claude: model alias; codex: model slug
  reasoningEffort?: string;  // pi thinking level; codex reasoning effort; claude: ignored/mapped
  parentContext: {           // resolved by the tool layer, passed opaquely
    parentCwd: string;
    projectTrusted: boolean;
    inheritedModelRef?: { provider: string; id: string };  // pi only
    inheritedThinkingLevel?: string;
  };
}

interface SubagentMeta {
  backend: BackendName;
  modelLabel?: string;         // "anthropic/claude-opus-4-5", "gpt-5-codex", ...
  contextWindow?: number;      // for utilization %, when known
  sessionFilePath?: string;    // pi session file / claude JSONL / codex rollout path
  nativeSessionId?: string;    // claude session id, codex conversation id
}

3.3 Normalized event model (src/domain.ts)

One discriminated union covers everything the v1 UI and manager need. Backends translate their native streams into this; nothing downstream knows which backend produced it.

type SubagentEvent =
  // lifecycle
  | { _tag: "RunStarted" }                       // pi agent_start / claude init / codex turn start
  | { _tag: "RunSettled"; outcome: RunOutcome }  // terminal per run (a session can run again via send())
  // transcript building blocks
  | { _tag: "UserMessage"; text: string }        // initial prompt + takeover sends, echoed by backend
  | { _tag: "AssistantDelta"; kind: "text" | "thinking"; delta: string }
  | { _tag: "AssistantMessage"; parts: TranscriptPart[] }  // finalized message (replaces live buffer)
  | { _tag: "ToolStart";  toolId: string; name: string; argsPreview?: string }
  | { _tag: "ToolUpdate"; toolId: string; outputPreview?: string }
  | { _tag: "ToolEnd";    toolId: string; isError: boolean; outputPreview?: string }
  // bookkeeping
  | { _tag: "QueueChanged"; queued: ReadonlyArray<{ text: string; kind: "steer" | "follow-up" }> }
  | { _tag: "UsageChanged"; tokens?: number; contextWindow?: number }
  | { _tag: "MetaChanged";  meta: Partial<SubagentMeta> }  // model switched, session file known, ...
  | { _tag: "BackendError"; message: string };   // non-fatal diagnostics (fatal → RunSettled outcome)

type RunOutcome =
  | { _tag: "Completed"; finalText: string }
  | { _tag: "Failed"; errorText: string; partialText?: string }
  | { _tag: "Interrupted"; partialText?: string };

type TranscriptPart =
  | { type: "text"; text: string }
  | { type: "thinking"; text: string; redacted?: boolean }
  | { type: "toolCall"; toolId: string; name: string; argsPreview?: string };

Mapping sanity check against what the v1 UI renders:

v1 render source v2 event source
sub.session.messages (user/assistant/toolResult) fold of UserMessage / AssistantMessage / ToolEnd into the snapshot transcript
streaming message (agent.state.streamingMessage) live buffer fed by AssistantDelta (cleared on AssistantMessage/RunSettled)
live tool map from tool_execution_* ToolStart/Update/End (entries drop when the finalized assistant/tool item lands, same as v1)
queued steer/follow-up messages QueueChanged
status / errorText / settledAt RunStarted / RunSettled
model + context utilization columns MetaChanged + UsageChanged
finalOutput / latestOutput for check/wait/result delivery RunOutcome.finalText (+ live buffer for latestOutput)

Previews (argsPreview, outputPreview) are pre-flattened single-line strings because the UI only ever shows one sanitized line — this avoids leaking three different native "tool result" shapes through the interface.

3.4 The SubagentBackend service (src/backend.ts)

One interface; three implementations; a registry keyed by BackendName.

interface SubagentBackend {
  readonly name: BackendName;
  readonly capabilities: {
    steering: boolean;         // can send() into a live run (all three eventually; stubs: true)
    modelSelection: boolean;
    reasoningEffort: boolean;
  };
  /** Probe availability (binary on PATH, SDK importable, API key). Cheap + cached. */
  readonly available: Effect.Effect<boolean>;
  /**
   * Spawn a session. Scoped: releasing the scope interrupts/kills the underlying
   * session/process. Returns a live handle immediately (fire-and-forget semantics
   * live in the manager, not here).
   */
  spawn(task: SpawnTask): Effect.Effect<SubagentSession, SpawnError, Scope.Scope>;
}

interface SubagentSession {
  readonly meta: Effect.Effect<SubagentMeta>;                 // snapshot; also updated via MetaChanged
  /** All activity. Single consumer (the manager). Ends after the final RunSettled on close. */
  readonly events: Stream.Stream<SubagentEvent, never>;
  /** Steer while running, or start a fresh run when idle (v1 `manager.send` semantics). */
  send(text: string): Effect.Effect<void, SendError>;
  /** Interrupt the active run; resolves when the backend acknowledges. Bounded by the caller. */
  readonly interrupt: Effect.Effect<void>;
  /** Resolves with the outcome of the most recent run (mirrors the last RunSettled). */
  readonly awaitSettled: Effect.Effect<RunOutcome>;
}

// Registry: a plain ServiceMap.Key holding a ReadonlyMap<BackendName, SubagentBackend>,
// built by a Layer that collects the three backend layers. Adding a 4th backend = one
// new file + one line in the registry layer.
class BackendRegistry extends ServiceMap.Key<BackendRegistry,
  ReadonlyMap<BackendName, SubagentBackend>>()("subagents/BackendRegistry") {}

Design choices worth calling out:

  • spawn is scoped, not paired with an explicit dispose. The manager opens one Scope per subagent and closes it on cancel/prune/disposeAll — this replaces v1's shutdownAndDisposeChildSession + WeakMap-idempotence machinery with Effect's own guaranteed-once finalization. Timeout-bounded teardown (5s, then force) is a finalizer concern inside each backend.
  • send unifies steer/new-run. v1's manager.send already had these semantics; the interface keeps the decision inside the backend because "is a run active" is backend-native state.
  • Events, not message arrays, are the contract. The manager folds events into snapshots; backends never expose native message types. The pi backend has the richest native data and simply down-converts.
  • No exec() one-shot operation in v1 of this extension — see Open Questions; the interface deliberately leaves room to add exec(task): Effect<RunOutcome> later without touching the manager.

3.5 SubagentManager service (src/manager.ts)

Owns the registry of running/finished subagents. Effect service; internally a Ref (or plain mutable map guarded by the single-threaded JS model) of entries plus a synchronous read model for the TUI.

interface SubagentEntry {
  id: string;                    // "sa-N", same scheme as v1
  backend: BackendName;
  title: string; prompt: string; cwd: string;
  scope: Scope.Closeable;        // owns the SubagentSession
  session: SubagentSession;
  eventPump: Fiber.Fiber<void>;  // folds events → snapshot, fires settle hooks
}

interface SubagentSnapshot {      // what the UI and tools read; plain immutable data
  id: string; backend: BackendName; title: string; cwd: string;
  status: SubagentStatus;
  createdAt: number; settledAt?: number;
  errorText?: string;
  meta: SubagentMeta;
  usage: { tokens?: number; contextWindow?: number };
  transcript: ReadonlyArray<TranscriptItem>;    // finalized items
  liveAssistant?: { text: string; thinking: string };
  liveTools: ReadonlyArray<LiveToolState>;      // v1's LiveToolEvent, verbatim
  queued: ReadonlyArray<{ text: string; kind: "steer" | "follow-up" }>;
  finalText: string;             // last Completed finalText (v1 finalOutput)
  latestText: string;            // finalText or live buffer (v1 latestOutput)
  turns: number;                 // count of AssistantMessage events (for subagent_check)
}

class SubagentManager extends ServiceMap.Key<SubagentManager, {
  spawn(backend: BackendName, task: SpawnTask):
    Effect.Effect<SubagentSnapshot, SpawnError | ConcurrencyLimitError | BackendUnavailable>;
  waitFor(ids: string[], onPending?: (pending: string[]) => void):
    Effect.Effect<void, SubagentNotFound>;      // interruption = tool signal abort
  cancel(ids: string[]): Effect.Effect<CancelReport, SubagentNotFound>;
  send(id: string, text: string): Effect.Effect<void, SubagentNotFound | SendError>;
  get(id: string): Effect.Effect<SubagentSnapshot | undefined>;
  list: Effect.Effect<ReadonlyArray<SubagentSnapshot>>;
  disposeAll: Effect.Effect<void>;
  /** Synchronous read model for the TUI (see 3.6). */
  readonly view: SubagentReadModel;
}>()("subagents/SubagentManager") {}

Behavior preserved from v1, expressed in Effect terms:

  • Concurrency cap: MAX_RUNNING = 4 enforced with a synchronous reserve-before-first-yield counter (same race-avoidance rationale as v1); the cap counts running agents across all backends (see Open Questions for per-backend caps).
  • Settlement: the per-subagent event pump fiber updates the snapshot on every event; on RunSettled it computes status/errorText (bounded to 4096 chars) and invokes the settle hook with consumed = waitInterest > 0. waitFor keeps the same wait-interest refcounts (a Ref<Map<string, number>>) and wakes on snapshot changes (a Latch/PubSub-based "next change" primitive replacing v1's resolver array).
  • Cancel: mark consumed → session.interrupt with 5s bound → close scope on timeout → wait for settle. Same "already <status>" reporting.
  • Pruning: MAX_TRACKED = 64, oldest settled non-wait-interested entries pruned by closing their scopes; cleanup tracked so disposeAll can await it.
  • Settle → result delivery hook: the manager exposes onSettled wiring identical in spirit to v1: the extension layer registers a callback that defers into the result-delivery buffer and flushes on parent idle / agent_settled. The createDeferredResultDelivery module is copied over unchanged (it is pure and already has a test).

3.6 Synchronous read model for the TUI (src/read-model.ts)

The TUI components (Component classes with render(width)/handleInput(data)) are imperative and render synchronously — they cannot yield* effects. Bridge:

interface SubagentReadModel {
  list(): ReadonlyArray<SubagentSnapshot>;         // sync snapshot reads
  get(id: string): SubagentSnapshot | undefined;
  subscribe(listener: () => void): () => void;     // any-change notification (dashboard, footer)
  subscribeTo(id: string, l: () => void): () => void; // per-agent (takeover view)
  // sync fire-and-forget commands, executed via the ManagedRuntime under the hood:
  requestSend(id: string, text: string): void;     // TakeoverView input submit
  requestAbort(id: string): void;                  // dashboard `x`, takeover app.clear
}

The manager's event pump writes each new snapshot into this store (plain mutable map + listener set) as its last step, so the UI is always at most one microtask behind the Effect world and v1's dashboard/takeover code ports with only these substitutions:

v1 v2
manager.list() / manager.get(id) view.list() / view.get(id)
manager.addChangeListener view.subscribe
sub.session.subscribe(handleSessionEvent) + local live-tool map view.subscribeTo(id, ...) + read snapshot.liveTools / liveAssistant (the fold moved into the manager)
manager.send(sub, text) / manager.abort(sub) view.requestSend(id, text) / view.requestAbort(id)
buildTranscriptLines(sub, ...) reading session.messages buildTranscriptLines(snapshot, ...) reading snapshot.transcript + live state + snapshot.queued

Keybindings, layout math, scroll behavior, 1Hz ticker, 50ms render throttle, sanitize logic: copied as-is.

3.7 Extension edge (index.ts + src/runtime.ts)

Layer graph:
  PiBackendStub.layer      ─┐
  ClaudeBackendStub.layer  ─┼→ BackendRegistry.layer ─→ SubagentManager.layer ─→ AppLayer
  CodexBackendStub.layer   ─┘
  • const runtime = ManagedRuntime.make(AppLayer) — created lazily on first use (per the extension-docs guidance to not start background resources in the factory; ManagedRuntime builds its layer on first run, which satisfies this, but we still gate creation behind session_start). session_shutdown: runtime.runPromise( manager.disposeAll) then await runtime.dispose(), then recreate on the next session_start (handles /new, /resume, /reload).
  • Tool handlers are the async boundary. Each execute(toolCallId, params, signal, onUpdate, ctx) builds one Effect.gen program and runs it with runtime.runPromise(program, { signal }); tool-visible errors are converted from tagged errors to Error messages matching v1 wording. onUpdate and ctx (model registry, cwd, trust) are captured into the program as plain values/callbacks.
  • Tool schema change: subagent_spawn gains agent: StringEnum(["pi", "claude", "codex"]) (optional, default "pi"), and model/provider/reasoning_effort keep their v1 shapes but are documented as backend-interpreted (pi validates against the registry; claude/codex validate against their own known-model rules — stubs accept anything). describeSubagent lines and the dashboard gain the backend name (e.g. sa-3 [running] "title" (codex, gpt-5-codex, 41%/272k, 1m32s, /repo)).
  • pi.registerMessageRenderer("subagent-result", ...), pi.registerCommand( "subagents", ...), footer status updates, and the result-delivery flush hooks (agent_settled, idle-check on settle) are wired exactly like v1 — these all live outside the runtime and call into it only via runPromise/the read model.

3.8 What the stubs do (v1 of this extension)

All three backends share a createStubSession(profile) helper (src/backends/stub.ts) that fakes a plausible session so the manager, tools, result delivery, and both TUI views are exercised end to end:

  • spawn: emits MetaChanged (backend-flavored model label + fake session file path under os.tmpdir(), e.g. .../subagents-stub/sa-1.jsonl, actually written with the transcript so "full transcript in session file" pointers resolve), then RunStarted, then a scripted turn: 2–3 AssistantDelta batches on a timer (~200ms cadence so streaming is visible), one fake ToolStart/Update/End cycle (bash with an args preview), UsageChanged ramping tokens, a final AssistantMessage, and RunSettled with Completed — final text echoes the task: "[stub:claude] completed: <first 200 chars of prompt>". Total runtime ~3–6s (configurable per profile) so subagent_wait, the footer counters, and the dashboard's running→done transition are observable.
  • send: emits UserMessage + QueueChanged (briefly, to exercise the queued-line rendering) and runs another scripted turn — so takeover steering works.
  • interrupt: stops the script timer and settles with Interrupted (→ status error, errorText "Run was aborted", matching v1) — so subagent_cancel and the x/app.clear keybindings work.
  • failure path: a magic prompt prefix (e.g. FAIL:) makes the run settle with Failed — so error rendering, errorText rows, and failed result delivery are testable without real backends.
  • Backend differentiation: per-backend profiles vary the model label (anthropic/claude-opus-4-5 vs claude-sonnet-4-5 vs gpt-5-codex), fake context window, tool names, and delta cadence — enough to verify the UI treats backends uniformly. available returns true for stubs (real impls will probe binaries/SDK).
  • The pi stub can later be swapped for the real in-process SDK implementation by porting v1's manager.ts session code behind the same SubagentSession shape; that port is the first post-stub milestone.

4. File/module layout

/Users/davis/.pi/agent/extensions/subagents/
├── package.json               # name, "effect": "^4.0.0-beta.x"; pi extension entry via pi.extensions
├── package-lock.json / node_modules/   (after npm install)
├── docs/
│   └── design-plan.md         # this document
├── index.ts                   # extension factory: runtime lifecycle, 5 tools, /subagents
│                              # command, message renderer, footer status, result flush hooks
└── src/
    ├── domain.ts              # BackendName, SubagentStatus, SpawnTask, SubagentEvent,
    │                          # RunOutcome, TranscriptItem/Part, SubagentSnapshot, tagged errors
    ├── backend.ts             # SubagentBackend + SubagentSession interfaces, BackendRegistry
    │                          # key + registry layer
    ├── backends/
    │   ├── stub.ts            # shared scripted fake-session machinery
    │   ├── pi.ts              # PiBackend layer (v1: stub profile; later: real pi SDK sessions)
    │   ├── claude.ts          # ClaudeBackend layer (v1: stub; later: @anthropic-ai/claude-agent-sdk)
    │   └── codex.ts           # CodexBackend layer (v1: stub; later: codex app-server JSON-RPC)
    ├── manager.ts             # SubagentManager service + layer: registry, cap, waitFor,
    │                          # cancel, prune, settle hook, event-fold into snapshots
    ├── read-model.ts          # sync SubagentReadModel bridge for the TUI
    ├── runtime.ts             # AppLayer composition + ManagedRuntime create/dispose helpers
    ├── result-delivery.ts     # deferred delivery buffer (copied from v1, unchanged)
    ├── result-delivery.test.ts
    ├── prompt.ts              # all model-facing strings (v1 copy + `agent` param description)
    ├── format.ts              # elapsed/context-utilization/activity-status formatting
    │                          # (merged copies of ../shared/{context-utilization,activity-status}.ts)
    └── ui/
        ├── transcript.ts      # sanitize + buildTranscriptLines over SubagentSnapshot
        └── takeover.ts        # SubagentDashboard + TakeoverView + openSubagentPicker (ported)

Notes:

  • package.json is needed because effect is an npm dependency (extension-with-deps style from the extension docs). Everything else avoids new dependencies.
  • v1's child-session.ts trust/tool-policy helpers are not copied in v1 of v2 (the stubs don't need them); the real pi backend will bring the needed subset into backends/pi.ts when implemented. The resolveStandaloneChildProjectTrust logic is still referenced by the design (SpawnTask.parentContext.projectTrusted) so the tool layer computes trust the same way v1 does.
  • Suggested project scripts (per house rules, to be added): check (tsc --noEmit), test (node --test or vitest for result-delivery + manager fold tests against stub backends).

5. Migration/coexistence note

v1 and v2 register the same tool names (subagent_spawn, ...) and the same /subagents command. While both live in ~/.pi/agent/extensions/, pi will suffix duplicate commands (/subagents:1, /subagents:2) and both tool sets would be registered. During development, either (a) v2 uses temporary names (subagent2_spawn, /subagents2), or (b) v1 is moved out of the auto-discovery dir. Recommendation: (a) during development, rename to final names when v2 replaces v1.


6. Open questions (need user input)

  1. Per-backend spawn options. v1's model/provider/reasoning_effort are pi-shaped. Options: (a) keep one generic model string + reasoning_effort that each backend interprets (proposed above — simplest for the LLM); (b) add a backend_options free-form object; (c) per-backend defaults in a config file with no per-spawn override. Which surface do you want the parent LLM to have?
  2. One-shot exec mode. Should the interface expose a separate cheap exec(task): Effect<RunOutcome> (mapping to codex exec / claude -p --output-format json / a fresh in-memory pi session), or is one interactive-session path enough? Exec would forfeit takeover/steering for that subagent — is a mode: "session" | "exec" spawn parameter desirable, or backend-internal optimization only?
  3. Permissions/sandboxing for Claude/Codex children. Subagents are headless, so interactive permission prompts are impossible. Do we run Claude with bypassPermissions/--dangerously-skip-permissions and Codex with --full-auto-style sandbox + never-ask approval policy? Should this be a global extension setting, per-spawn, or hardcoded? (Pi children inherit v1's trust-store logic — keep that as-is?)
  4. Concurrency cap scope. Keep one global MAX_RUNNING = 4, or per-backend caps (e.g. 4 pi + 2 claude + 2 codex)? Global is proposed as default.
  5. Steering support parity in real backends. Codex steering means interrupt-then-new-turn or queued sendUserTurn; Claude requires streaming-input mode from the start. OK to declare capabilities.steering and have the TakeoverView input show "(steering not supported)" if a backend can't, or is steering a hard requirement for all three?
  6. Model/thinking inheritance across backends. When agent: "claude" and no model given, what's the default (e.g. always opus/sonnet)? Inheriting the parent pi model is meaningless cross-backend. Proposal: per-backend default model in a small config block; confirm.
  7. Binary/SDK discovery + failure UX. When codex/claude isn't installed or has no credentials, should subagent_spawn fail fast with a clear tool error (proposed), or should the backends be hidden from the agent enum dynamically?
  8. Result truncation budgets. Keep v1's numbers (24KB result message, 48KB wait total, 16KB per agent, 2KB check preview) unchanged?
  9. Effect version pinning. Effect v4 is beta — pin an exact 4.0.0-beta.x and accept manual bumps, or track the beta dist-tag?
  10. Persistence across reloads. v1 loses all subagents on session_shutdown (disposeAll). Codex/Claude children are external processes that could outlive a pi reload — should v2 keep v1's kill-everything behavior (proposed for v1 of v2) or plan for reattach later?