569 lines
34 KiB
Markdown
569 lines
34 KiB
Markdown
# 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 `AgentSession`s** 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 `Error`s with the same user-facing messages v1 uses.
|
||
|
||
### 3.2 Domain model (`src/domain.ts`)
|
||
|
||
```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.
|
||
|
||
```ts
|
||
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`.
|
||
|
||
```ts
|
||
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.
|
||
|
||
```ts
|
||
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:
|
||
|
||
```ts
|
||
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?
|