34 KiB
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 insrc/backends/stub.tsfor 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>; eachSubagent={ 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_startre-marks running;agent_settledsettles). Failure detection: thrown prompt error, last assistantstopReason === "error" | "aborted", error text bounded to 4096 chars. - Change notification:
addChangeListener()+nextChange(signal)promise — used bywaitFor, the footer status, and the dashboard. waitFor(ids, signal, onPending)keeps awaitInterestrefcount per id so settles during an active wait are marked consumed.send(sub, text): steer viasession.steer()while streaming, else start a freshprompt()run (used by takeover).- Caps and cleanup:
MAX_RUNNING = 4,MAX_TRACKED = 64with LRU pruning of settled agents,STOP_TIMEOUT_MS = 5sbounded aborts, force-dispose fallback, idempotentdisposeAll()onsession_shutdown.
1.3 Result delivery back to the parent
- When a child settles unconsumed,
onSettleddefers it into a tinycreateDeferredResultDeliverybuffer (defer/consume/drain/clear keyed by id). - Flush happens when the parent goes idle: immediately if
sessionContext.isIdle(), otherwise on the parent'sagent_settledevent. A latersubagent_waitcan 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 bybuildSubagentResultMessage(Subagent sa-N "title" finished/failed.+ optionalError: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)
- 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. subagent-resultmessage renderer: status icon (■/x) + bold accent headersubagent sa-N · title · finished/failed; collapsed = first 8 body lines +... (ctrl+o to expand); expanded = header +Markdowncomponent render of the body./subagentscommand →openSubagentPickerloop (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 titledagents · 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 moremarkers. 1Hz ticker re-render for elapsed/token columns + manager change subscription. Keys:tui.select.up/downandj/kto move,tui.select.confirmto take over,xto abort the selected running agent,tui.select.cancelto close. Hint line shows the configured keys viakeybindings.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), anInputline, and a hint row. Keys:tui.input.submitsend (steer if streaming, new run if idle),app.interrupt/tui.select.cancelback to dashboard,app.clearabort run,tui.editor.cursorUp/Downscroll ±6 lines,tui.editor.pageUp/Downpage. 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 dimoutput:/rederror:first line. Includes the live streaming assistant message, live tool executions (running/done/error marker + first output line preview, tracked fromtool_execution_*events until the final tool result message lands), and queued steering/follow-up messages (> [queued steer] ...) so Enter visibly acknowledges input.
- SubagentDashboard — fullscreen overlay (
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
effectpackage (v4 beta). Services defined withServiceMap.Service<T>()("id", ...)/ServiceMap.Key; wiring viaLayer;ManagedRuntime.make(layer)at the extension edge withruntime.runPromise(effect, { signal })insideasync execute()tool handlers andawait runtime.dispose()onsession_shutdown. Effect.gengenerators throughout the internals.async/Promiseappears only in: toolexecute()bodies,pi.on(...)handlers, the/subagentscommand handler, and the imperative TUI component classes (which are callback-driven, not effectful).- Streams:
Stream<SubagentEvent>per subagent, produced by backends (Stream.callbackfor 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 thrownErrors 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:
spawnis scoped, not paired with an explicitdispose. The manager opens oneScopeper subagent and closes it on cancel/prune/disposeAll — this replaces v1'sshutdownAndDisposeChildSession+ WeakMap-idempotence machinery with Effect's own guaranteed-once finalization. Timeout-bounded teardown (5s, then force) is a finalizer concern inside each backend.sendunifies steer/new-run. v1'smanager.sendalready 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 addexec(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 = 4enforced 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
RunSettledit computesstatus/errorText(bounded to 4096 chars) and invokes the settle hook withconsumed = waitInterest > 0.waitForkeeps the same wait-interest refcounts (aRef<Map<string, number>>) and wakes on snapshot changes (aLatch/PubSub-based "next change" primitive replacing v1's resolver array). - Cancel: mark consumed →
session.interruptwith 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 sodisposeAllcan await it. - Settle → result delivery hook: the manager exposes
onSettledwiring identical in spirit to v1: the extension layer registers a callback that defers into theresult-deliverybuffer and flushes on parent idle /agent_settled. ThecreateDeferredResultDeliverymodule 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;ManagedRuntimebuilds its layer on first run, which satisfies this, but we still gate creation behindsession_start).session_shutdown:runtime.runPromise( manager.disposeAll)thenawait runtime.dispose(), then recreate on the nextsession_start(handles/new,/resume,/reload).- Tool handlers are the async boundary. Each
execute(toolCallId, params, signal, onUpdate, ctx)builds oneEffect.genprogram and runs it withruntime.runPromise(program, { signal }); tool-visible errors are converted from tagged errors toErrormessages matching v1 wording.onUpdateandctx(model registry, cwd, trust) are captured into the program as plain values/callbacks. - Tool schema change:
subagent_spawngainsagent: StringEnum(["pi", "claude", "codex"])(optional, default"pi"), andmodel/provider/reasoning_effortkeep 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).describeSubagentlines 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 viarunPromise/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 underos.tmpdir(), e.g..../subagents-stub/sa-1.jsonl, actually written with the transcript so "full transcript in session file" pointers resolve), thenRunStarted, then a scripted turn: 2–3AssistantDeltabatches on a timer (~200ms cadence so streaming is visible), one fakeToolStart/Update/Endcycle (bashwith an args preview),UsageChangedramping tokens, a finalAssistantMessage, andRunSettledwithCompleted— final text echoes the task:"[stub:claude] completed: <first 200 chars of prompt>". Total runtime ~3–6s (configurable per profile) sosubagent_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(→ statuserror, errorText"Run was aborted", matching v1) — sosubagent_canceland thex/app.clearkeybindings work. - failure path: a magic prompt prefix (e.g.
FAIL:) makes the run settle withFailed— so error rendering,errorTextrows, and failed result delivery are testable without real backends. - Backend differentiation: per-backend profiles vary the model label
(
anthropic/claude-opus-4-5vsclaude-sonnet-4-5vsgpt-5-codex), fake context window, tool names, and delta cadence — enough to verify the UI treats backends uniformly.availablereturnstruefor 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.tssession code behind the sameSubagentSessionshape; 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.jsonis needed becauseeffectis an npm dependency (extension-with-deps style from the extension docs). Everything else avoids new dependencies.- v1's
child-session.tstrust/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 intobackends/pi.tswhen implemented. TheresolveStandaloneChildProjectTrustlogic 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 --testor vitest forresult-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)
- Per-backend spawn options. v1's
model/provider/reasoning_effortare pi-shaped. Options: (a) keep one genericmodelstring +reasoning_effortthat each backend interprets (proposed above — simplest for the LLM); (b) add abackend_optionsfree-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? - One-shot exec mode. Should the interface expose a separate cheap
exec(task): Effect<RunOutcome>(mapping tocodex 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 amode: "session" | "exec"spawn parameter desirable, or backend-internal optimization only? - Permissions/sandboxing for Claude/Codex children. Subagents are headless, so
interactive permission prompts are impossible. Do we run Claude with
bypassPermissions/--dangerously-skip-permissionsand 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?) - 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. - 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 declarecapabilities.steeringand have the TakeoverView input show "(steering not supported)" if a backend can't, or is steering a hard requirement for all three? - Model/thinking inheritance across backends. When
agent: "claude"and no model given, what's the default (e.g. alwaysopus/sonnet)? Inheriting the parent pi model is meaningless cross-backend. Proposal: per-backend default model in a small config block; confirm. - Binary/SDK discovery + failure UX. When
codex/claudeisn't installed or has no credentials, shouldsubagent_spawnfail fast with a clear tool error (proposed), or should the backends be hidden from theagentenum dynamically? - Result truncation budgets. Keep v1's numbers (24KB result message, 48KB wait total, 16KB per agent, 2KB check preview) unchanged?
- Effect version pinning. Effect v4 is beta — pin an exact
4.0.0-beta.xand accept manual bumps, or track the beta dist-tag? - 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?