@tylerho/pi-subagents
v0.1.0
Published
Background subagents on a pi or Claude Code backend, with fire-and-forget spawn and deferred result delivery.
Readme
@tylerho/pi-subagents
Background subagents on a pi or Claude Code backend, with fire-and-forget spawn and deferred result delivery.
Install
pi install npm:@tylerho/pi-subagents
Subagents
Background subagents on one of two backends — pi (in-process SDK session) or Claude Code (Claude Agent SDK) — unified behind a single Effect v4 service interface. The backbone for delegation: workflows, memory consolidation, and recaps all share its delegation defaults and activity counters. Children are fully autonomous, headless, and self-contained: they cannot re-orchestrate (subagent_*, workflow, ask_user, enter_worktree, exit_worktree are excluded from their toolset), cannot ask the user, and model-origin children cannot see the parent conversation — /btw asides are the exception, seeded with the parent's conversation up to the spawn point.
Key concepts
- Two backends, one API.
harness: "pi" | "claude"selects where the subagent runs. The pi backend is an in-processcreateAgentSession()(real session files visible in/resume, per-cwd resources with trust gating, the child tool denylist). The claude backend is one@anthropic-ai/claude-agent-sdkquery()in streaming-input mode — the CLI owns conversation continuity, tool execution, and~/.claude/projectstranscripts; it runsbypassPermissions, disallows Claude's nativeAgent/Tasksubagent tools so orchestration stays inside this extension's manager and cap, and (when noclaudebinary is on PATH) reports unavailable. - Effect v4 layering.
backends/produce a scopedSubagentSessionexposing a normalizedSubagentEventstream; the manager spawns one pump fiber per subagent that folds that stream into a mutableSubagentSnapshot;runtime.tscomposes the layer into oneManagedRuntime;index.tsis the async boundary where tool handlers run effects viarunTool()(typed failures → thrownError, AbortSignal interruption →interruptMessage). - Fire-and-forget by default.
subagent_spawnreturnsSpawnDetailsimmediately. Settlement runs through a deferred-result queue: a settled result is delivered as a follow-up message (customType: "subagent-result",deliverAs: "followUp",triggerTurn: true) when the parent is idle or on the nextagent_settled. A latersubagent_wait/subagent_cancelmarks the settle consumed (waitInterestrefcount) so it is not delivered twice. The follow-up's model-facing content is a preview only (first 16 lines / 2 KB of the output) plus a pointer tosubagent_waitfor the full text — the complete output lives indetails.fullOutput(24 KB cap) for the TUI's expanded renderer, so the parent model can act on it but never re-dumps it into the transcript. - Origins. Model-origin spawns are visible to model-facing tools and the
/subagentsdashboard; user asides from/btw(btw-*ids) are filtered out byisModelVisibleeverywhere in the tool layer and the dashboard — they are revisited through the/btwpanel. A btw result is appended as a synchronous session entry (pi.appendEntry("btw-result"), safe while the parent is streaming) plus aui.notify, never a model-context follow-up. btw children run with no tools at all (noTools: "all", so built-in and extension tools are excluded persistently) and get a prompt prefix telling them to answer from context — one-off questions, not delegated work. - btw context inheritance. A btw child is a fork, not a clean slate: at spawn time the command handler snapshots the parent conversation (
buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages— two adjacent synchronous reads, so it is race-free even while the parent is streaming; an in-flight assistant message is only persisted at itsmessage_end, so the snapshot ends at the parent's last completed message, the same cut Claude Code applies). The pi backend seeds those messages into the child's session file beforecreateAgentSession, which restores them as the child's history — compaction, resume, and the transcript treat them like the child's own. Summary roles are rewritten to the plain user textconvertToLlmwould produce. The inherited range is fenced withbtw-context-start/btw-context-endcustom entries (BTW_CONTEXT_START/BTW_CONTEXT_ENDinby-the-way.ts) so the persisted-transcript loader hides it in the /btw panel; the child's own result extraction also excludes the seeds by object identity, so a child that fails before answering reports failure instead of echoing a parent message. - Ids are slugs, not counters. Each id is
sa-<slug>(orbtw-<slug>), where the slug is derived from the spawn title (slugifyTitleinmanager.ts: lowercase alphanumerics + hyphens, ≤24 chars,"subagent"fallback). Repeats get a numeric suffix (sa-refactor-util-2). Ids stay unique per parent session and are the handle forsubagent_wait/cancel/check/send. - Caps.
MAX_RUNNING = 50running subagents across all backends (reserved synchronously before the first yield so parallel spawns cannot race past it; idle restarts viasendcount too).MAX_TRACKED = 4_096settled snapshots per parent session, pruned oldest-settled-first; session files survive on disk. - Transcripts are bounded in memory, full on disk. The manager keeps 512 transcript items / 64 KiB per text; the takeover view lazily streams the FULL persisted JSONL (pi
~/.pi/agent/sessions/<escaped-cwd>/<timestamp>_<id>.jsonl, Claude~/.claude/projects/<escaped-cwd>/<sessionId>.jsonl) throughsrc/persisted/parsers (format auto-detected per record, line-by-line, never slurped). The persisted snapshot is merged with the live in-memory transcript (mergeTranscripts): live items always win so streamed content never vanishes when it finalizes; the persisted list supplies pruned history and full multi-line tool outputs. The persisted load is refreshed once when the run settles. For btw sessions the loader skips the fenced inherited-context range, so the /btw panel shows the aside, not the whole parent conversation. - Model defaults & cost ceiling. No
modelhint → each harness's configured default fromshared/subagent-models.json(set by/subagent-model), not the parent's model — delegated work must not silently run on an expensive interactive model. Agent-chosen models above the ceiling (default $10/Mtok output, envPI_SUBAGENT_COST_CEILING) are rejected with actionable alternatives; the configured default and/btwasides are exempt (deliberate user intent). - Trust gating. A child in the same directory inherits the parent's trust decision. An alternate
working_diris trusted only when pi's persistedProjectTrustStoreexplicitly trusts it (or a containing directory); unreadable/invalid trust data fails closed. Claude children in untrusted cwds getsettingSources: ["user"]so an untrusted project's config cannot reconfigure the child. - Child safety rails. Headless children exclude
subagent_spawn,subagent_wait,subagent_cancel,subagent_check,subagent_list,workflow,ask_user,enter_worktree,exit_worktree; every child tool call is wrapped with a 30-minute execution timeout (shared/tool-call-timeout.ts, re-applied onagent_startso tools registered mid-session are covered). - Task rail (TUI). A
belowEditorwidget shows running/finished subagents. A down double-tap (≤500 ms) in the default editor view (no modal focused, editor text empty) expands it; ↓/↑ navigate (no wrap — at-bottom reveals finished, at-top closes), Enter opens the takeover view. Key-release and repeat events are filtered (isKeyRelease/isKeyRepeat); in any modal the gesture yields to the focused component. - Lifecycle.
session_startwires the rail + terminal handler;session_shutdownunregisters everything and disposes the runtime, whose manager finalizer (disposeAll) force-closes every subagent scope (5 s bound per close) — a safety net even if the extension forgot to dispose.
API
Tools (registered for the parent LLM)
subagent_spawn — fire-and-forget spawn. Params: prompt (string, must be self-contained: no parent context is visible to the child), name (string, title; trimmed, truncated to 160 chars, default "subagent"), harness ("pi" | "claude"), working_dir (optional string, must exist and be a directory; default parent cwd), model (optional string: pi "provider/model-id" or bare id, claude alias like "sonnet"), reasoning_effort (optional "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"). The id derives from the name as a slug (see Key concepts). Returns text like Spawned sa-refactor-util "refactor util" (pi, deepseek/deepseek-v4-pro). Runs in background; result arrives automatically. subagent_wait(["sa-refactor-util"]) to block. plus details: { id, title, cwd, harness, model }. Rejects at the 50-agent cap (ConcurrencyLimitError), unknown/unavailable harness, bad working_dir, unknown model, or cost-ceiling violation.
{ "prompt": "Refactor src/util.ts: split the two exported functions into separate files, run the test suite, report the diff stat.", "name": "refactor util", "harness": "pi" }subagent_wait — block until all listed subagents settle, then return their final outputs. Params: ids (array of string, max 64). Streams Waiting for ... via onUpdate. Unknown ids (or btw ids) fail the call listing known model ids. Result: one ## <id> "<title>" finished/failed section per agent with error text and output (48 KB total / 16 KB per agent budget; per-section [omitted: ...] fallbacks), plus details: { results: [{ id, title, status }] }. Interruption (tool abort) releases the wait and leaves subagents running. Consumes the deferred automatic delivery.
{ "ids": ["sa-refactor-util"] }subagent_cancel — abort running subagents. Params: ids (array of string). Marks consumed before interrupting (5 s graceful session.interrupt, force-close fallback that settles first so the "stream ended" fallback cannot misreport), waits for settlement, reports Cancelled <id> "<title>". or <id> "<title>" was already <status>. plus details: { results: [{ id, title, status }] }. Partial session transcripts stay on disk.
subagent_check — non-blocking status peek. Params: id (string). Returns id [status] "title" (backend: model, ctx%/capacity, elapsed, cwd), turn count, error text, and up to 2 KB / 20 lines of the latest output (includes the live streaming assistant text). Does NOT consume the result. details: { id, status, turns }.
subagent_list — list all tracked subagents. No params. One describeSubagent() line per model-origin subagent (id [status] "title" (backend: model, ctx%, elapsed, cwd)); "No subagents." when empty. details: { subagents: [{ id, title, harness, status }] }.
Commands (all TUI-only; notify + return in non-TUI modes)
| Command | Args | Behavior |
|---|---|---|
| /btw | optional prompt text | Spawns a "pi"-harness subagent with origin: "btw", title from deriveBtwTitle (first prompt line, ≤60 chars), then opens the btw panel (BtwPanel) — a CC-style bottom dock listing past asides as /btw <question> lines above the selected aside's answer: ←/→ switches between asides (each is its own isolated session), ↑/↓ or j/k scrolls the answer, n asks a new question via ctx.ui.input, c copies the answer, ctrl+t toggles reasoning (hidden by default with the main session's collapsed 💭 thinking · snippet label), Esc closes. With no args and history the panel opens on the newest aside; with no history it goes straight to the question input. The child runs without tools (see Key concepts) on a session file seeded with the parent conversation up to the spawn point (see btw context inheritance); the prompt is prefixed with BTW_PROMPT_PREFIX telling it to answer from that shared context in a single response. Result arrives as a btw-result entry + notify, not a model follow-up. |
| /subagents | — | Opens the fullscreen dashboard overlay (SubagentDashboard) of model-origin subagents only (btw asides are excluded): j/k or ↑/↓ select, Enter takes over, x aborts a running agent. Empty state notifies "No subagents yet." |
| /subagent-model | — | Picker flow per harness: pick harness (shows current defaults) → pi: curated model list cheapest-first (cost + "over ceiling" markers) + supported thinking-level selector; claude: alias list (sonnet, haiku, opus, fable) + effort list. Saves atomically to shared/subagent-models.json, notifies the choice. |
Events
session_start— capture theExtensionContext; in TUI mode register the task-rail widget (setWidget("subagent-task-rail", …, { placement: "belowEditor" })) and the rawonTerminalInputhandler (down double-tap gesture, see Key concepts).agent_settled—flushResults(): drain the deferred-result queue and deliver each as asubagent-resultfollow-up message.session_shutdown— teardown: clearsessionContext, remove the task-rail widget, unsubscribe the raw-input handler, reset rail + result queue,await runtime.dispose()(disposes every subagent scope; childsession_shutdownhooks are emitted bounded at 5 s).
Message / entry renderers
registerMessageRenderer("subagent-result")— renders the follow-up message for settled subagents (status glyph,id · title · finished/failed, markdown body when expanded, 8-line preview otherwise).registerEntryRenderer("btw-result")— renders/btwanswers as session entries. Collapsed: just theby the way · title · answered/failedheader plus a/btw to reopenhint, so answers don't flood the transcript; expanded: the full markdown body inline.
Config
extensions/shared/subagent-models.json— persisted per-harness defaults{ pi: { provider, model, effort }, claude: { model, effort } }; the on-disk file is mutable (user-set via/subagent-model, currentlypi: deepseek/deepseek-v4-flash @ max,claude: sonnet @ high), and the code fallback constantsDEFAULT_SUBAGENT_MODELSdefault topi: deepseek/deepseek-v4-pro @ high,claude: sonnet @ high. Read vialoadSubagentModels()(per-harness fallback to defaults), written atomically (temp + rename) by/subagent-model.- Env
PI_SUBAGENT_COST_CEILING— USD/Mtok output ceiling for agent-chosen models (default10;"off"disables). Set at sonnet-5's price so haiku-4.5/sonnet-5 are selectable while the $15+ tier is not.
Other exported functions (internal modules; import for tests/builders)
domain.ts:BACKEND_NAMES,REASONING_EFFORTS, typesBackendName/SubagentOrigin/ReasoningEffort/SubagentStatus/ParentContext/SpawnTask/SubagentMeta/TranscriptPart/TranscriptItem/LiveToolState/QueuedMessage/RunOutcome/SubagentEvent/SubagentSnapshot, tagged errorsSpawnError/BackendUnavailableError/ConcurrencyLimitError/SendError, helperslatestText/formatElapsed.backend.ts:SubagentBackend,SubagentSession,BackendCapabilities(interface),BackendRegistry(service).backends/pi.ts:piBackend;backends/claude.ts:claudeBackend,contextOccupancyTokens(per-request context occupancy; the result-messageusageis a whole-run aggregate and must never be used as occupancy);backends/stub.ts:makeStubBackend(scripted test session;FAIL:-prefixed prompts settle as errors),StubProfile(interface).manager.ts:SubagentManager(service),SubagentManagerLive(layer),MAX_RUNNING = 50,MAX_TRACKED = 4_096,slugifyTitle,SubagentManagerShape,SubagentReadModel,CancelResult.runtime.ts:createSubagentRuntime(),SubagentRuntime(type,ReturnType<typeof createSubagentRuntime>),runTool(runtime, effect, { signal?, interruptMessage? }).prompt.ts: allSUBAGENT_*_TOOL_DESCRIPTION/*_PARAMETER_DESCRIPTIONS/SUBAGENT_SPAWN_PROMPT_SNIPPET/SUBAGENT_SPAWN_PROMPT_GUIDELINES,buildSubagentSpawnResult,buildSubagentResultMessage(preview +subagent_waitpointer, never the full output) — the single source of model-facing strings.by-the-way.ts:deriveBtwTitle,isModelVisible,BTW_TITLE_MAX_LENGTH;result-delivery.ts:createDeferredResultDelivery;format.ts:formatContextUtilization,contextPercent,formatCompactTokens,ContextUtilization(interface).ui/:openSubagentPicker,openSubagentTakeover,reconcileDashboardSelection,configuredKeys,statusGlyph,statusWord,DashboardSelection(interface, takeover.ts);openBtwPanel,BtwPanelResult(btw-panel.ts);TaskRailController,visibleRailSubagents,createTaskRail(task-rail.ts);findFocusedComponent,isDefaultEditorFocused(focus.ts);buildTranscriptLines,buildBtwAnswerLines,sanitizeText,mergeTranscripts(transcript.ts);pickHarness,pickPiModel,pickPiEffort,pickClaudeModel,pickClaudeEffort(model-picker.ts).persisted/:readPersistedTranscript(lazy async generator),loadPersistedTranscript,SessionFormat(type) +detectSessionFormat(transcript.ts);parsePiEntry(pi.ts);ClaudeParser(interface) +createClaudeParser(claude.ts); sharedparseJsonLine/safeJsonPreview/previewOf/textOf/isRecord.
Examples
- Delegate and keep working — the parent spawns a self-contained task and continues; the result arrives automatically as a
subagent-resultfollow-up (a preview in context; the full output renders on expand):subagent_spawn { prompt: "Read docs/errors.md and list the three most common failure modes with their codes, as a bullet list.", name: "errors survey", harness: "pi" }→{ id: "sa-errors-survey", … }; later the agent usessubagent_check { id: "sa-errors-survey" }to peek without consuming, orsubagent_wait { ids: ["sa-errors-survey"] }to pull the complete output when it cannot proceed without it. - Deliberate harness + model choice — a Claude Code task:
subagent_spawn { prompt: "In this repo, find every TODO and categorize by owner file; write the result to TODOS.md.", name: "todo sweep", harness: "claude", model: "sonnet", reasoning_effort: "medium" }. Omitmodel/reasoning_effortunless the user named them — both harnesses default to/subagent-modeland the ceiling rejects self-chosen expensive models. - User aside — the user types
/btw what changed in the subagent API in this update?; abtw-what-changed-in-the-…subagent inherits the conversation up to that point and answers while the main agent keeps working; the answer appears as a one-lineby the way · …session entry, reopened in the/btwpanel or expanded inline. - Manage —
/subagentsopens the dashboard of model-origin subagents (select with j/k or ↑/↓,xaborts a running agent, Enter takes over: scroll the transcript with ↑/↓/pgup/pgdn, ctrl+t toggles thinking (hidden by default, like the main session), ctrl+o toggles tool calls, ctrl+c aborts the run, the interrupt binding closes the view, type at the input line to steer/continue);/btwwith no args opens the aside panel (←/→ switches between past questions,nasks a new one,ccopies the answer);/subagent-modelchanges the per-harness defaults.
