Added nono profiles & orca integration - Added some extensions to start working on

This commit is contained in:
jb
2026-08-24 10:23:38 -04:00
parent 8bcb82813c
commit 10f64b593b
83 changed files with 21840 additions and 1353 deletions
+29
View File
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
BTW_TITLE_MAX_LENGTH,
deriveBtwTitle,
isModelVisible,
} from "./src/by-the-way.ts";
test("deriveBtwTitle uses the first non-empty line and bounds the title", () => {
assert.equal(
deriveBtwTitle("\n Why does this work? \nignore me"),
"Why does this work?",
);
assert.equal(deriveBtwTitle(" \n\t"), "by the way");
const title = deriveBtwTitle("x".repeat(BTW_TITLE_MAX_LENGTH + 10));
assert.equal(title.length, BTW_TITLE_MAX_LENGTH);
assert.equal(title, `${"x".repeat(BTW_TITLE_MAX_LENGTH - 1)}…`);
const emojiTitle = deriveBtwTitle(
`${"x".repeat(BTW_TITLE_MAX_LENGTH - 2)}😀 more`,
);
assert.equal(emojiTitle, `${"x".repeat(BTW_TITLE_MAX_LENGTH - 2)}😀…`);
});
test("only model-origin snapshots are visible to model-facing tools", () => {
assert.equal(isModelVisible({ origin: "model" }), true);
assert.equal(isModelVisible({ origin: "btw" }), false);
});
+119
View File
@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Effect } from "effect";
import { SubagentManager } from "./src/manager.ts";
import { claudeBackend } from "./src/backends/claude.ts";
import type { ParentContext, SpawnTask } from "./src/domain.ts";
import { createSubagentRuntime, runTool } from "./src/runtime.ts";
const parent: ParentContext = {
parentCwd: process.cwd(),
projectTrusted: false,
};
function task(prompt: string): SpawnTask {
return {
prompt,
title: "live Claude test",
cwd: process.cwd(),
model: "haiku",
reasoningEffort: "off",
parent,
};
}
async function claudeAvailable() {
return Effect.runPromise(claudeBackend.available);
}
/** Rejecting deadline so a hung wait still reaches finally() and disposes. */
function deadline<A>(operation: Promise<A>, timeoutMs: number) {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error(`Live Claude test exceeded ${timeoutMs}ms`)),
timeoutMs,
);
});
return Promise.race([operation, timeout]).finally(() => {
if (timer) clearTimeout(timer);
});
}
test(
"Claude backend completes a live manager run",
{ timeout: 60_000 },
async (t) => {
if (!(await claudeAvailable())) {
t.skip("Claude Code executable is unavailable");
return;
}
const runtime = createSubagentRuntime();
try {
const manager = await runtime.runPromise(SubagentManager);
const started = await runTool(
runtime,
manager.spawn("claude", task("Reply with exactly: hello claude")),
);
await deadline(runTool(runtime, manager.waitFor([started.id])), 45_000);
const done = manager.view.get(started.id);
assert.equal(done?.status, "done");
assert.match(done?.finalText ?? "", /hello claude/i);
assert.ok(done?.meta.nativeSessionId);
assert.ok(done?.meta.sessionFilePath?.endsWith(".jsonl"));
} finally {
await runtime.dispose();
}
},
);
test(
"Claude backend interrupt settles a live run as aborted",
{ timeout: 60_000 },
async (t) => {
if (!(await claudeAvailable())) {
t.skip("Claude Code executable is unavailable");
return;
}
const runtime = createSubagentRuntime();
try {
const manager = await runtime.runPromise(SubagentManager);
const started = await runTool(
runtime,
manager.spawn(
"claude",
task(
"Write a detailed 10,000-word essay about the history of computing.",
),
),
);
// Wait for streamed output so cancellation definitely lands mid-run and
// exercises the SDK's normal interrupt receipt/result path.
const streamDeadline = Date.now() + 15_000;
while (
manager.view.get(started.id)?.status === "running" &&
!manager.view.get(started.id)?.liveAssistant?.text &&
Date.now() < streamDeadline
) {
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.equal(manager.view.get(started.id)?.status, "running");
assert.ok(manager.view.get(started.id)?.liveAssistant?.text);
const report = await deadline(
runTool(runtime, manager.cancel([started.id])),
20_000,
);
assert.equal(report[0]?.cancelled, true);
assert.equal(manager.view.get(started.id)?.status, "error");
assert.equal(manager.view.get(started.id)?.errorText, "Run was aborted");
} finally {
await runtime.dispose();
}
},
);
+102
View File
@@ -0,0 +1,102 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Effect } from "effect";
import { codexBackend } from "./src/backends/codex.ts";
import type { ParentContext, SpawnTask } from "./src/domain.ts";
import { SubagentManager } from "./src/manager.ts";
import { createSubagentRuntime, runTool } from "./src/runtime.ts";
const parent: ParentContext = {
parentCwd: process.cwd(),
projectTrusted: false,
};
function task(prompt: string): SpawnTask {
return {
prompt,
title: "live Codex test",
cwd: process.cwd(),
parent,
};
}
function deadline<A>(operation: Promise<A>, timeoutMs: number) {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error(`Live Codex test exceeded ${timeoutMs}ms`)),
timeoutMs,
);
});
return Promise.race([operation, timeout]).finally(() => {
if (timer) clearTimeout(timer);
});
}
async function codexAvailable() {
return Effect.runPromise(codexBackend.available);
}
test(
"Codex backend completes a live manager run",
{ timeout: 75_000 },
async (t) => {
if (!(await codexAvailable())) {
t.skip("codex executable is unavailable");
return;
}
const runtime = createSubagentRuntime();
try {
const manager = await runtime.runPromise(SubagentManager);
const spawned = await runTool(
runtime,
manager.spawn("codex", task("Reply with exactly: hello codex")),
);
await deadline(runTool(runtime, manager.waitFor([spawned.id])), 60_000);
const done = manager.view.get(spawned.id);
assert.equal(done?.status, "done");
assert.match(done?.finalText ?? "", /hello codex/i);
assert.equal(done?.meta.backend, "codex");
assert.ok(done?.meta.nativeSessionId);
assert.ok(done?.meta.sessionFilePath);
} finally {
await runtime.dispose();
}
},
);
test(
"Codex backend interrupt settles a live manager run",
{ timeout: 30_000 },
async (t) => {
if (!(await codexAvailable())) {
t.skip("codex executable is unavailable");
return;
}
const runtime = createSubagentRuntime();
try {
const manager = await runtime.runPromise(SubagentManager);
const spawned = await runTool(
runtime,
manager.spawn(
"codex",
task("Run `sleep 30`, then reply with the word finished."),
),
);
await new Promise((resolve) => setTimeout(resolve, 250));
const result = await deadline(
runTool(runtime, manager.cancel([spawned.id])),
10_000,
);
assert.equal(result[0]?.cancelled, true);
assert.equal(manager.view.get(spawned.id)?.status, "error");
assert.equal(manager.view.get(spawned.id)?.errorText, "Run was aborted");
} finally {
await runtime.dispose();
}
},
);
+107
View File
@@ -0,0 +1,107 @@
import assert from "node:assert/strict";
import test from "node:test";
import { contextOccupancyTokens } from "./src/backends/claude.ts";
import { parseThreadTokenUsage } from "./src/backends/codex.ts";
// --- Claude: per-request occupancy, never the run aggregate ------------------
test("Claude occupancy sums one request's input, cache, and output tokens", () => {
assert.equal(
contextOccupancyTokens({
input_tokens: 12,
cache_read_input_tokens: 45_000,
cache_creation_input_tokens: 3_000,
output_tokens: 700,
}),
48_712,
);
});
test("Claude occupancy treats null cache/output counts as zero", () => {
assert.equal(
contextOccupancyTokens({
input_tokens: 9_000,
cache_read_input_tokens: null,
cache_creation_input_tokens: null,
output_tokens: 250,
}),
9_250,
);
});
test("Claude occupancy is unknown without a usable per-request usage", () => {
assert.equal(contextOccupancyTokens(undefined), undefined);
assert.equal(contextOccupancyTokens(null), undefined);
assert.equal(
contextOccupancyTokens({ input_tokens: null, output_tokens: 5 }),
undefined,
);
});
test("Claude occupancy from the last request stays below the window where the run aggregate would not", () => {
// A 10-request tool loop over a mostly-cached 150k prompt: the aggregate
// usage (what SDKResultMessage.usage reports) re-counts the cache per
// request and blows past the 200k window; the final request's usage is the
// true occupancy.
const perRequest = {
input_tokens: 500,
cache_read_input_tokens: 150_000,
cache_creation_input_tokens: 0,
output_tokens: 400,
};
const aggregate = {
input_tokens: 500 * 10,
cache_read_input_tokens: 150_000 * 10,
cache_creation_input_tokens: 0,
output_tokens: 400 * 10,
};
const occupancy = contextOccupancyTokens(perRequest);
assert.ok(occupancy !== undefined && occupancy < 200_000);
const misleading = contextOccupancyTokens(aggregate);
assert.ok(misleading !== undefined && misleading > 200_000);
});
// --- Codex: last request's total, never the thread-cumulative total ----------
const codexParams = (tokenUsage: unknown) => ({
threadId: "t",
turnId: "u",
tokenUsage,
});
test("Codex occupancy uses tokenUsage.last.totalTokens, not the cumulative total", () => {
const { tokens, contextWindow } = parseThreadTokenUsage(
codexParams({
total: {
totalTokens: 1_450_000,
inputTokens: 1_400_000,
cachedInputTokens: 1_300_000,
outputTokens: 50_000,
reasoningOutputTokens: 20_000,
},
last: {
totalTokens: 61_000,
inputTokens: 60_000,
cachedInputTokens: 55_000,
outputTokens: 1_000,
reasoningOutputTokens: 400,
},
modelContextWindow: 272_000,
}),
);
assert.equal(tokens, 61_000);
assert.equal(contextWindow, 272_000);
});
test("Codex occupancy is unknown when last usage or window is absent", () => {
assert.deepEqual(
parseThreadTokenUsage(
codexParams({ total: { totalTokens: 10 }, modelContextWindow: null }),
),
{ tokens: undefined, contextWindow: undefined },
);
assert.deepEqual(parseThreadTokenUsage({ threadId: "t" }), {
tokens: undefined,
contextWindow: undefined,
});
});
+568
View File
@@ -0,0 +1,568 @@
# 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?
@@ -0,0 +1,354 @@
# Building a pi extension on the Effect v4 setup
> Practical, migration-oriented companion to [`effect-v4-notes.md`](./effect-v4-notes.md).
> That file is the Effect API cheat-sheet (v3→v4 renames, child processes, concurrency);
> **this** file is how to stand up _one pi extension_ on the same toolchain and where to
> draw the line between "wrap in Effect" and "leave as plain TS".
>
> **Verified against** `effect@4.0.0-beta.98`, `@effect/platform-node@4.0.0-beta.98`,
> `@effect/tsgo@0.19.0`, `typescript@7.0.2` (checked 2026-07-13, in `extensions/subagents`).
> `npm run check` there passes clean — use it as the reference implementation.
>
> Audience: the agents migrating `firecrawl-search`, `ask-user`, `model-info`,
> `git-info`, `ui-customization`, and `copy-all`.
---
## 0. The one rule: Effect is for the async core, not the whole file
A pi extension's public surface is **plain callbacks** the host calls:
`export default function (pi: ExtensionAPI)`, `pi.registerTool({ …, async execute() })`,
`pi.registerCommand`, `pi.on(...)`, renderers. None of that changes. Effect lives _inside_
those callbacks — you run an effect at the top of `execute` and return its value.
Reach for Effect only where you actually get something from it:
- **Yes:** async work that needs typed errors, cancellation via the tool `AbortSignal`,
timeouts, retries/polling, or a resource whose lifetime must outlive one call
(child process, subscription) → child processes (`git-info`, `copy-all`), the
Firecrawl SDK calls (`firecrawl-search`), git/gh polling (`git-info`).
- **No / barely:** pure TUI popups and rendering (`ask-user`, `ui-customization`),
cross-extension channel plumbing, cost/token bookkeeping (`model-info`). These are
synchronous or already-Promise UI code; wrapping them in Effect adds ceremony and no
safety. Migrate them by keeping the logic and only touching whatever genuinely async
part benefits (usually nothing).
If an extension has no async core worth typing, the "migration" may be just adopting the
toolchain (§1) and leaving the body plain. Don't invent an Effect layer to have one.
---
## 1. Per-extension toolchain (copy this exactly)
Each extension is its own npm package with its own `node_modules`. Replicate the pinned
setup — do **not** float the versions.
`package.json`:
```jsonc
{
"name": "<ext>",
"private": true,
"type": "module",
"scripts": {
"check": "tsc --noEmit -p .",
"prepare": "effect-tsgo patch", // patches the Effect LS into the tsgo binary
},
"dependencies": {
"effect": "4.0.0-beta.98", // EXACT pin, no ^
"@effect/platform-node": "4.0.0-beta.98", // only if you touch fs / child processes
},
"devDependencies": {
"@effect/tsgo": "^0.19.0",
"typescript": "^7.0.2",
},
}
```
`tsconfig.json` (extends the repo root, adds the Effect language-service plugin):
```jsonc
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"plugins": [{ "name": "@effect/language-service" }],
},
"include": ["index.ts", "src/**/*.ts", "*.test.ts"],
}
```
The root `tsconfig.json` already sets `strict`, `module`/`moduleResolution: NodeNext`,
`verbatimModuleSyntax`, `allowImportingTsExtensions`, `target: ES2022`, `types: ["node"]`.
Keep local `.ts` imports written **with the `.ts` extension** (`./src/manager.ts`), matching
the house style.
### The LSP / language-service, precisely
- `typescript@7` is the **native (tsgo) TypeScript** — its `tsc` is a Go binary, and it's
what `npm run check` runs.
- `@effect/language-service` is **not an installed npm package** here (you won't find it in
`node_modules`). It's delivered by `effect-tsgo patch`, run automatically by the `prepare`
lifecycle script on `npm install`. The patch injects the Effect Language Service into the
tsgo binary; the tsconfig `plugins` entry then turns on Effect-aware editor diagnostics and
quickfixes (e.g. "yield missing services", floating effects).
- Practical sequence for a fresh/edited extension: `npm install` (runs the patch) →
`npm run check`. If editor diagnostics look stale, re-run `npx effect-tsgo patch`;
`npx effect-tsgo get-exe-path` prints the patched binary it resolved.
- The plugin drives the _editor_; it does not change `tsc` exit codes. `npm run check` is
still your ground-truth green/red signal.
---
## 2. The async boundary: one `ManagedRuntime` + a `runTool` helper
Every extension that uses Effect needs exactly this shape. Build **one** runtime lazily,
dispose it on shutdown, and funnel every tool handler through a single helper that turns
Effect exits into what pi expects (a value, or a thrown `Error`; interruption → a friendly
message). This is lifted verbatim from `src/runtime.ts` — reuse it.
```ts
// src/runtime.ts
import { Cause, Exit, Layer, ManagedRuntime, type Effect } from "effect";
import { NodeServices } from "@effect/platform-node"; // only if you need fs / processes
// Compose your services here (see §3). NodeServices.layer =
// ChildProcessSpawner | FileSystem | Path | Crypto | Stdio | Terminal.
const AppLayer = Layer.mergeAll(NodeServices.layer /*, MyServiceLive */);
export function createRuntime() {
return ManagedRuntime.make(AppLayer);
}
export type ExtRuntime = ReturnType<typeof createRuntime>;
/** Run an effect from an async handler: value on success, thrown Error otherwise. */
export async function runTool<A, E>(
runtime: ExtRuntime,
effect: Effect.Effect<A, E>,
options: { signal?: AbortSignal; interruptMessage?: string } = {},
) {
const exit = await runtime.runPromiseExit(
effect,
options.signal ? { signal: options.signal } : undefined,
);
if (Exit.isSuccess(exit)) return exit.value;
if (Cause.hasInterruptsOnly(exit.cause)) {
throw new Error(options.interruptMessage ?? "Operation was aborted.");
}
const [first] = Cause.prettyErrors(exit.cause);
throw new Error(first?.message ?? Cause.pretty(exit.cause));
}
```
Wire it into the extension lifecycle (lazy build, dispose on shutdown):
```ts
// index.ts
export default function (pi: ExtensionAPI) {
let runtime: ExtRuntime | undefined;
const getRuntime = () => (runtime ??= createRuntime());
pi.registerTool({
name: "my_tool",
parameters: Type.Object({/* typebox */}),
async execute(_id, params, signal) {
return await runTool(getRuntime(), myEffect(params), {
signal,
interruptMessage: "Cancelled.",
});
},
});
pi.on("session_shutdown", async () => {
const closing = runtime;
runtime = undefined;
await closing?.dispose(); // runs all finalizers: kills scoped child processes, etc.
});
}
```
Key facts:
- `runPromiseExit(effect, { signal })` — passing the tool's `AbortSignal` makes cancelling
the tool interrupt the fiber; scoped resources (child processes) are torn down.
- `runtime.runFork(effect)` for fire-and-forget background work (polling loops, §5).
- `dispose()` is the single teardown hook — put resource cleanup in Effect finalizers, not
in ad-hoc `session_shutdown` code.
- If an extension has **no** services (pure `Effect.tryPromise` calls, no fs/process), you
can skip `NodeServices.layer` and even skip `ManagedRuntime` — `Effect.runPromiseExit(effect, { signal })`
works standalone. Add the runtime only when you have a `Layer` to share.
---
## 3. Services, layers, errors (house style)
Mirror the subagents code. Class-style `Context.Service`, `Layer.effect`/`Layer.sync`,
`Data.TaggedError` for domain errors.
```ts
import { Context, Data, Effect, Layer } from "effect";
// Domain error — yieldable, tag-narrowable with Effect.catchTag:
export class GitError extends Data.TaggedError("GitError")<{
readonly message: string;
}> {}
// Service: the class value is the key AND the type.
export interface GitShape {
readonly status: Effect.Effect<string, GitError>;
}
export class Git extends Context.Service<Git, GitShape>()("gitinfo/Git") {}
// Layer building it (Layer.effect can use scoped resources; Layer.sync for pure):
export const GitLive: Layer.Layer<Git, never, ChildProcessSpawner> =
Layer.effect(
Git,
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner; // dependency, provided by NodeServices.layer
return Git.of({
status: spawner
.string(ChildProcess.make("git", ["status", "--porcelain"]))
.pipe(
Effect.mapError(
(cause) => new GitError({ message: String(cause) }),
),
),
});
}),
);
```
Provide dependencies with `Layer.provide`, compose with `Layer.mergeAll` (see §2 `AppLayer`).
Full rename table (`Effect.fork`→`forkChild`, `catchAll`→`catch`, `Either`→`Result`, …) and
the child-process / concurrency APIs live in `effect-v4-notes.md` — don't re-derive them.
---
## 4. Recipe: wrapping a Promise SDK (firecrawl-search)
The Firecrawl client is Promise-based. Wrap each call in `Effect.tryPromise` with a typed
error; the callback receives an `AbortSignal` tied to fiber interruption — forward it to any
SDK that accepts one so tool cancellation propagates.
```ts
import { Data, Effect } from "effect";
class FirecrawlError extends Data.TaggedError("FirecrawlError")<{
readonly cause: unknown;
}> {}
const search = (client: Firecrawl, query: string) =>
Effect.tryPromise({
try: (signal) => client.search(query, {/* …, signal? if supported */}),
catch: (cause) => new FirecrawlError({ cause }),
});
```
There's no `Layer` here unless you want the client as a service. For a single tool this is
fine to call directly through `runTool` (or even plain `Effect.runPromiseExit`, §2). Keep the
existing `.env`/API-key reading and result truncation as plain TS — no reason to Effect-ify
string trimming.
---
## 5. Recipe: child processes + timeout + polling (git-info, copy-all)
`git-info` shells out to `git`/`gh` with per-command timeouts and polls on an interval;
`copy-all` pipes text into `pbcopy`. Two viable levels — pick the lightest that fits.
**Simple, one-shot, small:** if all you do is "run a command, capture stdout, with a
timeout," the Effect win is `Effect.timeout` + interruption killing the child. Use the
spawner service:
```ts
import { Effect } from "effect";
import { ChildProcess } from "effect/unstable/process";
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
const gitStatus = Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner;
return yield* spawner
.string(ChildProcess.make("git", ["status", "--porcelain"], { cwd }))
.pipe(Effect.timeout("3 seconds")); // fails with Cause.TimeoutError (tag "TimeoutError")
});
```
Note the import gotcha: `ChildProcessSpawner` the **class** comes from the
`.../ChildProcessSpawner` submodule; the `effect/unstable/process` index gives you the
namespace. Provide `NodeServices.layer` (or `ChildProcessSpawner.layer`). Full command
builder / streaming / kill semantics are in `effect-v4-notes.md §6`.
**Polling on an interval:** replace `setInterval` with a forked, scheduled effect so it
cancels cleanly on dispose:
```ts
import { Effect, Schedule } from "effect";
const pollLoop = refresh.pipe(
Effect.catchCause(() => Effect.void), // don't let one failure kill the loop
Effect.repeat(Schedule.spaced("3 seconds")),
);
const fiber = runtime.runFork(pollLoop); // interrupted by runtime.dispose()
```
**When to stay plain:** `copy-all` spawning `pbcopy` is a trivial one-shot with no
cancellation need — the existing `node:child_process` + Promise wrapper is honestly fine.
Migrate it only for consistency; if you do, `Effect.callback` around `child.once("exit", …)`
(see notes §4) is the minimal wrapper. Don't add a service/layer for a clipboard write.
---
## 6. Recipe: UI popups & rendering (ask-user, ui-customization, model-info)
These are the "leave it mostly plain" cases.
- `ask-user` is a TUI popup that resolves a Promise when the user picks. That Promise already
models the one async thing. Effect adds nothing; if you want uniformity, wrap the final
await in `Effect.tryPromise` at the boundary and stop there. Do **not** build a service.
- `ui-customization` and `model-info` are renderers / event bookkeepers driven by
`pi.on(...)` and cross-extension channels (`shared/dashboard-state.ts`). Channels are a
pi-native mechanism — keep them. State counting and formatting stay synchronous TS.
- If `model-info` has a periodic "live update" tick, the §5 polling pattern applies; but a
plain timer here is also acceptable since there's no resource to tear down.
The migration bar for these: adopt the toolchain (§1) so they typecheck under TS7 + the
Effect LS, and only touch runtime code that has a real async/resource concern.
---
## 7. Verify — scoped to the one extension
Run everything from inside the extension directory so you never trigger a root/global build
or format:
```bash
cd extensions/<ext>
npm install # first time / after dep or script edits — runs `prepare` (effect-tsgo patch)
npm run check # tsc --noEmit -p . ← ground-truth green/red
npm run test # only if the extension defines tests; keep them minimal
```
`extensions/subagents` is the known-green reference: `npm run check` there exits 0 against
the pinned versions. If a migrated extension fails `check` with `Effect.fork`/`ServiceMap`/
`Either` errors, it's using stale v3/early-beta APIs — consult the rename table in
`effect-v4-notes.md`.
---
## 8. Don'ts (keep it lean)
1. **Don't float versions.** Pin `effect` and `@effect/platform-node` to the exact same
`4.0.0-beta.98`; `unstable/*` can break between betas.
2. **Don't Effect-ify pure/UI code.** No service or layer for a clipboard write, a string
truncation, or a popup. §0 is the test: is there a typed-error / cancellation / resource /
retry concern? If not, leave it.
3. **Don't guess APIs.** If it's not in this guide or `effect-v4-notes.md` and you haven't
grepped it in `node_modules/effect/dist/*.d.ts`, don't write it. Watch for hallucinated
`ServiceMap` (reverted to `Context`), `Effect.fork` (→ `forkChild`), `Effect.either`
(→ `Effect.result`).
4. **Don't hand-roll teardown.** Put cleanup in Effect finalizers and let `runtime.dispose()`
in `session_shutdown` run them.
5. **Don't over-test.** A `check` that passes plus one focused runtime test (where behavior
is non-obvious) beats a wall of defensive unit tests.
6. **Don't run root scripts.** No repo-root `tsc`, `prettier`, or `npm run format`; stay
inside `extensions/<ext>`.
</content>
@@ -0,0 +1,571 @@
# Effect v4 — Practical Notes for the Subagents Extension
> **Building a new pi extension on this stack?** Start with the migration-oriented
> [`effect-v4-extension-guide.md`](./effect-v4-extension-guide.md) (toolchain setup, the
> `ManagedRuntime` + `runTool` boundary, when *not* to use Effect, per-extension recipes).
> This file is the deeper Effect API reference it points back to.
> **Verified against:** `effect@4.0.0-beta.98` and `@effect/platform-node@4.0.0-beta.98`
> (npm dist-tag `beta`, checked 2026-07-13). Every snippet in this doc was type-checked
> with `tsc --strict` against these packages, and the process-spawning / runtime snippets
> were executed with Node 24. v4 source lives in the **`Effect-TS/effect-smol`** repo
> (not `Effect-TS/effect`); the official migration guide is
> [`effect-smol/MIGRATION.md`](https://github.com/Effect-TS/effect-smol/blob/main/MIGRATION.md)
> plus the `migration/*.md` files next to it (including a machine-readable
> `migration/v3-to-v4.md` rename map).
## Install
```bash
npm install effect@beta @effect/platform-node@beta
```
Big structural facts:
- **One version number for everything.** All ecosystem packages release together at
`4.0.0-beta.N`. `@effect/platform-node` must match `effect` exactly.
- **`@effect/platform` is gone — merged into core `effect`.** `FileSystem`, `Path`,
`PlatformError`, `Terminal`, `Stdio` are now top-level `effect` modules.
`@effect/platform-node` remains as the Node *implementation* package.
- **`effect/unstable/*` namespace.** Modules that may break in minor releases:
`effect/unstable/process` (child processes — the one we need), `http`, `rpc`, `cli`,
`ai`, `workers`, etc. Everything outside `unstable/` follows strict semver.
- **`"type": "module"`**, ESM-first. Works with `moduleResolution: NodeNext` or `Bundler`.
- Runtime keep-alive is built in now: a fiber suspended on `Deferred.await` etc. keeps
the Node process alive without `NodeRuntime.runMain` (v3 needed runMain for that).
## Cheat sheet: v3 → v4 renames you will actually hit
| v3 | v4 |
| --- | --- |
| `Context.Tag` / `Effect.Service` | `Context.Service` (`Effect.Service` is **gone**) |
| `Effect.fork` | `Effect.forkChild` |
| `Effect.forkDaemon` | `Effect.forkDetach` |
| `Effect.async` | `Effect.callback` |
| `Effect.catchAll` | `Effect.catch` |
| `Effect.catchAllCause` / `catchAllDefect` | `Effect.catchCause` / `catchDefect` |
| `Effect.zipRight` / `zipLeft` | `Effect.andThen` / `Effect.tap` |
| `Effect.either` | `Effect.result` |
| `Either` module | `Result` (`Result.succeed` / `Result.fail`) |
| `Layer.scoped` | `Layer.effect` (all layers are scoped-capable now) |
| `Mailbox` | `Queue` (Queue absorbed Mailbox: done/fail signalling built in) |
| `FiberRef` | `Context.Reference` (module `effect/References`) |
| `Scope.extend` | `Scope.provide` |
| `Stream.async*` (all 4 variants) | `Stream.callback` |
| `Stream.fromChunk(s)` | `Stream.fromArray` / `Stream.fromArrays` (Chunk de-emphasized; arrays used) |
| `Schema.TaggedError` | `Schema.TaggedErrorClass` |
| `@effect/platform/Command` | `effect/unstable/process` → `ChildProcess` |
| `@effect/platform/CommandExecutor` | `effect/unstable/process` → `ChildProcessSpawner` |
| `@effect/platform/FileSystem` | `effect/FileSystem` |
| `Runtime.runPromise(runtime)(...)` | gone — use `ManagedRuntime` methods directly |
| `UnknownException` (tryPromise default) | `Cause.UnknownError` |
Removed with no replacement: `Effect.forkAll`, `Effect.forkWithErrorHandler`.
Early v4 betas renamed `Context` → `ServiceMap`; **that was reverted** — beta.98 uses
`Context` again (ignore older blog posts/AI answers mentioning `ServiceMap`).
---
## 1. Basics: `Effect.gen`, running, the Promise boundary
Unchanged in spirit from v3. `Effect<A, E, R>` = success `A`, typed error `E`,
required services `R`.
```ts
import { Effect, Exit } from "effect"
const double = (n: number) => Effect.succeed(n * 2)
const program = Effect.gen(function* () {
const a = yield* Effect.succeed(1)
const b = yield* double(a)
yield* Effect.log(`result: ${b}`)
return b
})
// Entry-point conversion — only at the outermost layer (tool handlers):
const p: Promise<number> = Effect.runPromise(program) // rejects on failure/defect
const pe: Promise<Exit.Exit<number, never>> = Effect.runPromiseExit(program) // never rejects
const s: number = Effect.runSync(program) // throws if async
const fiber = Effect.runFork(program) // fire-and-forget fiber
```
`runPromise` only accepts `Effect<A, E, never>` — all services must be provided first
(or use a `ManagedRuntime`, §8). `runPromiseExit` is the right choice inside tool
handlers when you want to convert failures to structured results instead of throws.
`Effect.fn` gives you named, traced effect functions (nice stack traces):
```ts
const spawnJob = Effect.fn("spawnJob")(function* (name: string) {
yield* Effect.log(`spawning ${name}`)
return name
})
// spawnJob("x") : Effect<string>
```
## 2. Services & Layers
`Context.Tag` and the v3 `Effect.Service` helper class are **both gone**. The single
API is `Context.Service`, in two forms:
```ts
import { Context, Effect, Layer } from "effect"
// Class-style (recommended — class value doubles as the key):
class Clock extends Context.Service<Clock, {
readonly now: Effect.Effect<number>
}>()("app/Clock") {}
// Function-style key:
const Random = Context.Service<{ next: Effect.Effect<number> }>("app/Random")
// Yielding the key retrieves the service (keys are Effects):
const use = Effect.gen(function* () {
const clock = yield* Clock
return yield* clock.now
})
```
Layers work like v3 (`Layer.scoped` merged into `Layer.effect` — every `Layer.effect`
build can use scoped resources):
```ts
const ClockLive = Layer.succeed(Clock, { now: Effect.sync(() => Date.now()) })
const RandomLive = Layer.effect(
Random,
Effect.gen(function* () {
yield* Effect.log("building Random")
return { next: Effect.sync(() => Math.random()) }
})
)
// Dependencies between layers:
class Ids extends Context.Service<Ids, { readonly nextId: Effect.Effect<string> }>()("app/Ids") {}
const IdsLive = Layer.effect(
Ids,
Effect.gen(function* () {
const random = yield* Random
return Ids.of({ nextId: Effect.map(random.next, (n) => `id-${n}`) })
})
)
const AppLayer = Layer.mergeAll(ClockLive, IdsLive.pipe(Layer.provide(RandomLive)))
const runnable = use.pipe(Effect.provide(AppLayer)) // R = never
// One-off service injection without a layer:
const withTestClock = use.pipe(Effect.provideService(Clock, { now: Effect.succeed(0) }))
```
Also useful:
- `Context.Reference("key", { defaultValue: () => ... })` — a service **with a default**
(this replaces `FiberRef`); no layer needed, override with `Effect.provideService`.
- **Layer memoization changed:** layers are memoized *globally across separate
`Effect.provide` calls* by default in v4 (per-runtime MemoMap), so providing the same
layer to two effects builds it once.
## 3. Error handling
`Data.TaggedError` survives unchanged; `Schema.TaggedError` is now
`Schema.TaggedErrorClass`. Errors are yieldable directly.
```ts
import { Data, Effect, Schema } from "effect"
class SpawnError extends Data.TaggedError("SpawnError")<{
readonly command: string
readonly cause: unknown
}> {}
class TimeoutError extends Data.TaggedError("TimeoutError")<{ readonly ms: number }> {}
// Schema-validated variant:
class NotFound extends Schema.TaggedErrorClass<NotFound>()("NotFound", {
id: Schema.Number
}) {}
const risky: Effect.Effect<string, SpawnError | TimeoutError> = Effect.gen(function* () {
if (somethingBad) return yield* new SpawnError({ command: "codex", cause: "enoent" })
return "ok"
})
// catchTag narrows the error union:
const handled: Effect.Effect<string, TimeoutError> = risky.pipe(
Effect.catchTag("SpawnError", (e) => Effect.succeed(`spawn failed: ${e.command}`))
)
const handledAll: Effect.Effect<string> = risky.pipe(
Effect.catchTags({
SpawnError: (e) => Effect.succeed(`spawn: ${e.command}`),
TimeoutError: (e) => Effect.succeed(`timeout ${e.ms}ms`)
})
)
```
Renames: `catchAll` → `Effect.catch`, `catchAllCause` → `catchCause`,
`catchAllDefect` → `catchDefect`, `tapErrorCause` → `tapCause`, `Effect.either` →
`Effect.result` (returns `Result`, the renamed `Either`). `Effect.match`,
`Effect.exit`, `Effect.orDie`, `Effect.mapError`, `Effect.tapError` all still exist.
The `Cause` data structure was **flattened** (no nested `Sequential`/`Parallel` trees;
a cause is now a flat list of failures — simpler to render in job status output).
## 4. Promise / async / callback interop
```ts
import { Cause, Data, Effect } from "effect"
// Promise that "can't" reject — rejection becomes a defect:
const a = Effect.promise(() => Promise.resolve(42))
// Promise that may reject — default error type is Cause.UnknownError (v3: UnknownException):
const b = Effect.tryPromise((signal) => fetch("https://x", { signal }))
// Typed error:
class HttpError extends Data.TaggedError("HttpError")<{ cause: unknown }> {}
const c = Effect.tryPromise({
try: (signal) => fetch("https://x", { signal }),
catch: (cause) => new HttpError({ cause })
})
```
Both forms receive an `AbortSignal` that fires on fiber interruption — pass it to SDKs
(claude SDK, fetch, etc.) so interrupting a subagent fiber cancels the underlying call.
Callback APIs — `Effect.async` is now **`Effect.callback`**:
```ts
// signature: Effect.callback<A, E, R>((resume, signal) => void | cleanupEffect)
const waitForExit = (child: import("node:child_process").ChildProcess) =>
Effect.callback<number>((resume) => {
child.once("exit", (code) => resume(Effect.succeed(code ?? -1)))
})
// cleanup on interruption via the AbortSignal:
const sleepy = Effect.callback<number>((resume, signal) => {
const t = setTimeout(() => resume(Effect.succeed(1)), 1000)
signal.addEventListener("abort", () => clearTimeout(t))
})
```
## 5. Scopes & resource management
Same shape as v3. Relevant rename: `Scope.extend` → `Scope.provide`.
```ts
import { Effect, Exit, Scope } from "effect"
const managedProc = Effect.acquireRelease(
acquireEffect, // acquire (uninterruptible)
(proc, exit) => Effect.sync(() => proc.kill()) // release, gets the Exit
)
// Scoped region: release runs when the region ends
const useIt = Effect.scoped(
Effect.gen(function* () {
const proc = yield* managedProc
return proc.pid
})
)
// addFinalizer:
Effect.scoped(Effect.gen(function* () {
yield* Effect.addFinalizer((exit) => Effect.log(`closing, ok=${Exit.isSuccess(exit)}`))
}))
```
**Key pattern for background subagents** — a manually-controlled scope so the process
outlives the spawning tool call, and killing the job = closing the scope:
```ts
const startJob = Effect.gen(function* () {
const scope = yield* Scope.make()
const handle = yield* Scope.provide(managedProc, scope) // resource lives in `scope`
// store `scope` in your job registry; later, from any fiber:
// yield* Scope.close(scope, Exit.void) // runs finalizers -> kills process
return { handle, scope }
})
```
`Effect.acquireUseRelease(acquire, use, release)` for one-shot bracketing.
## 6. Child processes & FileSystem (the important part)
The v3 `@effect/platform` `Command`/`CommandExecutor` modules were redesigned into
**`effect/unstable/process`** with `ChildProcess` (command builder) and
`ChildProcessSpawner` (the service). The Node implementation comes from
`@effect/platform-node`.
Import gotcha: `import { ChildProcessSpawner } from "effect/unstable/process"` gives you
the **module namespace**, not the service class. Import the class from the submodule:
```ts
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
import { NodeServices, NodeFileSystem } from "@effect/platform-node"
```
`NodeServices.layer` provides everything at once:
`ChildProcessSpawner | Crypto | FileSystem | Path | Stdio | Terminal`.
### Building commands
```ts
// Template literal form:
const cmd1 = ChildProcess.make`echo hello`
// Options + template:
const cmd2 = ChildProcess.make({ cwd: "/tmp" })`ls -la`
// Array form (best for dynamic args — no shell parsing):
const cmd = ChildProcess.make("codex", ["exec", "--json", prompt], {
cwd: workDir,
env: { CODEX_API_KEY: key }, // merged over process.env when extendEnv: true
extendEnv: true,
stdin: "pipe", // "pipe" | "inherit" | "ignore" | a Stream<Uint8Array>
stdout: "pipe", // "pipe" | "inherit" | "ignore" | a Sink
stderr: "pipe"
})
// pipelines: cmdA.pipe(ChildProcess.pipeTo(cmdB, { from: "stderr" }))
// modifiers: ChildProcess.setCwd, ChildProcess.setEnv, ChildProcess.prefix
```
Note `detached` defaults to **true on non-Windows** — the child gets its own process
group (good: killing it kills the group).
### Running: two styles
**Style A — via the spawner service (simple, non-interactive):**
```ts
const simple = Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const text = yield* spawner.string(cmd) // full stdout as string
const lines = yield* spawner.lines(cmd) // string[]
const code = yield* spawner.exitCode(cmd) // ExitCode (branded number)
const lineStream = spawner.streamLines(cmd, { includeStderr: true }) // Stream<string>
})
```
**Style B — the Command IS an Effect.** Yielding a command spawns it and returns a
`ChildProcessHandle`, with the process lifetime tied to the ambient `Scope`
(`Command extends Effect<ChildProcessHandle, PlatformError, ChildProcessSpawner | Scope>`):
```ts
import { Effect, Stream } from "effect"
const streaming = Effect.scoped(
Effect.gen(function* () {
const handle = yield* cmd // <-- spawns; killed when scope closes
handle.pid // ProcessId (branded number)
// stdout is Stream<Uint8Array, PlatformError>; decode + split lines:
yield* handle.stdout.pipe(
Stream.decodeText(),
Stream.splitLines,
Stream.runForEach((line) => Effect.log(`out: ${line}`))
)
// stdin is a Sink<void, Uint8Array>; write by running a stream into it:
yield* Stream.fromArray([new TextEncoder().encode("hello\n")]).pipe(
Stream.run(handle.stdin)
)
// handle.stderr, handle.all (stdout+stderr interleaved) also available
const code = yield* handle.exitCode // waits for exit
const running = yield* handle.isRunning
yield* handle.kill({ killSignal: "SIGTERM", forceKillAfter: "5 seconds" })
return code
})
)
```
Verified at runtime: interrupting a fiber that spawned a process inside its scope
(e.g. `Fiber.interrupt` on a fiber running `Effect.scoped(...)` around `sleep 30`)
kills the child process. This is the backbone of subagent cancellation.
There's also `handle.unref` for letting the parent exit independently, and
`additionalFds` for extra file descriptor channels (fd3+).
### FileSystem / Path (now core)
```ts
import { Effect, FileSystem, Path } from "effect"
const files = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const content = yield* fs.readFileString("/tmp/in.txt")
yield* fs.writeFileString(path.join("/tmp", "out.txt"), content)
})
// provide NodeFileSystem.layer, or NodeServices.layer for everything
```
## 7. Concurrency toolbox for a job manager
All verified compiling + running:
```ts
import {
Cause, Deferred, Effect, Fiber, FiberMap, PubSub, Queue, Ref, Stream, Exit
} from "effect"
const forking = Effect.gen(function* () {
const f1 = yield* Effect.forkChild(work) // v3 fork — dies with parent
const f2 = yield* Effect.forkScoped(work) // tied to ambient Scope
const f3 = yield* Effect.forkDetach(work) // v3 forkDaemon — outlives parent ✅ for background jobs
// all fork variants accept { startImmediately?: boolean, uninterruptible?: boolean | "inherit" }
const r: number = yield* Fiber.join(f1) // propagates failure
const exit: Exit.Exit<number> = yield* Fiber.await(f3) // failure as data
yield* Fiber.interrupt(f2)
// fiber.pollUnsafe() -> Exit | undefined for sync status checks
})
```
**Deferred** — one-shot completion signal (job finished):
```ts
const done = yield* Deferred.make<Result, JobError>()
yield* Effect.forkDetach(job.pipe(Effect.exit, Effect.flatMap((e) => Deferred.done(done, e))))
const result = yield* Deferred.await(done) // keeps Node process alive in v4, no runMain needed
```
**Ref** — shared job-table state: `Ref.make({})`, `Ref.update`, `Ref.get`, `Ref.set`.
(`SynchronizedRef` for effectful updates.)
**Queue** — absorbed v3's `Mailbox`: it can be ended/failed, and converts to a Stream.
To use `Queue.end`, the error type must include `Cause.Done`:
```ts
const q = yield* Queue.make<string, Cause.Done>() // capacity/strategy via options
yield* Queue.offer(q, "line")
yield* Queue.offerAll(q, ["a", "b"])
yield* Queue.end(q) // signal "no more items"
const asStream = Stream.fromQueue(q) // ends when queue ends
// Queue.take / takeAll / takeN / poll for direct consumption
```
**PubSub** — broadcast (e.g. multiple watchers of one job's output):
```ts
const ps = yield* PubSub.unbounded<string>()
const sub = yield* PubSub.subscribe(ps) // scoped acquisition
yield* PubSub.publish(ps, "event")
const msg = yield* PubSub.take(sub) // function call, not sub.take
// Stream.fromPubSub(ps) for stream consumption
```
**FiberMap / FiberSet / FiberHandle** — keyed fiber registries, ideal for a subagent
manager (auto-interrupts everything when its scope closes; adding under an existing key
interrupts the old fiber):
```ts
const jobsDemo = Effect.scoped(Effect.gen(function* () {
const jobs = yield* FiberMap.make<string>() // FiberMap<string> (keys)
yield* FiberMap.run(jobs, "job-1", work) // fork into the map
const fiber = yield* FiberMap.get(jobs, "job-1")
yield* FiberMap.remove(jobs, "job-1") // interrupts it
}))
```
**Stream** essentials for process output: `Stream.decodeText()`, `Stream.splitLines`,
`Stream.runForEach`, `Stream.runCollect`, `Stream.run(sink)`, `Stream.fromQueue`,
`Stream.fromPubSub`, `Stream.toAsyncIterable`, `Stream.callback` (v3 `Stream.async`),
`Stream.fromArray` (v3 `fromChunk` — v4 uses plain arrays, Chunk is de-emphasized).
Also `Stream.mkString` to collect into one string.
Combinators: `Effect.all([...], { concurrency: n })`, `Effect.race`,
`Effect.timeout("5 seconds")` (fails with `Cause.TimeoutError`, tag `"TimeoutError"`),
`Semaphore.make(n)` (moved out of `Effect.makeSemaphore`), `Latch.make()`.
## 8. ManagedRuntime — the entry-point pattern for the extension
v3's `Runtime.runPromise(runtime)(effect)` API is gone; `effect/Runtime` now only holds
`runMain` plumbing. **`ManagedRuntime` is the way** to share layers across async entry
points:
```ts
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import { NodeServices } from "@effect/platform-node"
class JobStore extends Context.Service<JobStore, {
readonly list: Effect.Effect<Array<string>>
}>()("app/JobStore") {}
const JobStoreLive = Layer.sync(JobStore, () => ({ list: Effect.succeed([]) }))
const AppLayer = Layer.mergeAll(JobStoreLive, NodeServices.layer)
// Build ONCE at extension activation. Layers are built lazily on first run.
const runtime = ManagedRuntime.make(AppLayer)
// pi tool handler — the only place we touch Promises:
export async function handleToolCall() {
return await runtime.runPromise(
Effect.gen(function* () {
const store = yield* JobStore
return yield* store.list
})
)
}
// fire-and-forget background work from a sync/async context:
const fiber = runtime.runFork(backgroundEffect)
// extension deactivation — closes the runtime scope, runs ALL finalizers
// (i.e. kills any still-scoped child processes):
export async function shutdown() {
await runtime.dispose()
}
```
`ManagedRuntime` also exposes `runSync`, `runSyncExit`, `runPromiseExit`, `runCallback`,
a `memoMap` (share layer memoization between multiple runtimes), and `.scope`.
Prefer `runPromiseExit` in tool handlers if you want to map typed failures to
tool-result errors instead of catching thrown `Cause` wrappers.
---
## Architecture sketch for subagents
```
ManagedRuntime.make(Layer.mergeAll(NodeServices.layer, SubagentManagerLive))
│ built once at extension init; dispose() on shutdown
▼
SubagentManager service (Context.Service):
- Ref<HashMap<JobId, JobEntry>> job table
- start(cmd): Scope.make() → Scope.provide(ChildProcess handle, scope)
→ Effect.forkDetach(pump stdout → Queue<string, Cause.Done>)
→ Deferred<ExitCode> completed on exit
- status(id): Deferred.isDone / handle.isRunning / Ref lookup
- output(id): drain Queue (takeAll) or Stream.fromQueue for tailing
- kill(id): Scope.close(scope, Exit.void) → finalizer kills the process
Tool handlers: async fns calling runtime.runPromise(Effect.gen(...))
```
## Surprises & gotchas (learned the hard way)
1. **`effect/unstable/process` exports module namespaces.** The
`ChildProcessSpawner` class must be imported from
`"effect/unstable/process/ChildProcessSpawner"` (or use
`ChildProcessSpawner.ChildProcessSpawner` off the namespace).
2. **`Effect.fork` does not exist** — code (or an LLM) writing v3-style `Effect.fork`
fails to compile. Use `forkChild` / `forkScoped` / `forkDetach` / `forkIn`.
3. **`Queue.end` needs `Cause.Done` in the queue's error type** —
`Queue.make<A>()` defaults to `E = never` and won't accept `end`.
4. **Early-beta content mentioning `ServiceMap` is stale** — it was renamed back to
`Context` during the beta. Similarly, some AI-generated content mentions APIs that
never shipped.
5. **`Either` is `Result`**, `Effect.either` is `Effect.result`.
6. **`Data.TaggedError` errors are yieldable** — `yield* new SpawnError({...})` fails
the effect directly, no `Effect.fail` wrapper needed (works in v3 too, but idiomatic
in v4 docs).
7. **Chunk → Array**: stream element groups are plain arrays (`Stream.fromArray`,
`runCollect` returns `Array<A>`), not `Chunk`.
8. `tryPromise` default error is `Cause.UnknownError` (was `UnknownException`).
9. `unstable/*` modules can break between v4 minors — pin exact versions
(`4.0.0-beta.98`) and keep `effect` and `@effect/platform-node` in lockstep.
10. Scratch workspace with all verified test files: `/tmp/effect-v4-scratch`
(`test1-basics.ts` … `test8-runtime.ts`, `smoke.mts` executed successfully).
+746
View File
@@ -0,0 +1,746 @@
/**
* Subagents — spawn background subagents on one of three backends
* (pi, Claude Code, Codex) unified behind a single Effect service interface.
*
* Tools (for the parent LLM):
* - subagent_spawn: fire-and-forget spawn (prompt, title, agent, working_dir,
* model, reasoning_effort). Max 4 running at once across all backends.
* - subagent_wait: block until the listed subagents settle, return results.
* - subagent_cancel: stop one or more running subagents.
* - subagent_check: peek at a subagent's status and recent activity.
* - subagent_list: list all subagents.
*
* Unawaited subagents queue their result as a follow-up message when they
* settle. `/subagents` opens a picker + full interactive takeover view.
*
* Architecture: Effect v4 generators throughout (backends -> manager ->
* runtime); this file is the async boundary where tool handlers run effects
* against one shared ManagedRuntime. All three backends are real: pi runs
* in-process SDK sessions, claude drives the Claude Agent SDK, codex speaks
* JSON-RPC to a scoped `codex app-server` process.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { StringEnum } from "@earendil-works/pi-ai";
import type {
ExtensionAPI,
ExtensionCommandContext,
ExtensionContext,
ExtensionUIContext,
} from "@earendil-works/pi-coding-agent";
import {
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
formatSize,
getAgentDir,
getMarkdownTheme,
ProjectTrustStore,
truncateHead,
} from "@earendil-works/pi-coding-agent";
import { Markdown, Text } from "@earendil-works/pi-tui";
import { Type } from "typebox";
import { deriveBtwTitle, isModelVisible } from "./src/by-the-way.ts";
import {
BACKEND_NAMES,
formatElapsed,
latestText,
REASONING_EFFORTS,
type SubagentSnapshot,
} from "./src/domain.ts";
import {
formatActivityStatus,
formatContextUtilization,
} from "./src/format.ts";
import { SubagentManager, type SubagentManagerShape } from "./src/manager.ts";
import {
buildSubagentResultMessage,
buildSubagentSpawnResult,
SUBAGENT_CANCEL_PARAMETER_DESCRIPTIONS,
SUBAGENT_CANCEL_TOOL_DESCRIPTION,
SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS,
SUBAGENT_CHECK_TOOL_DESCRIPTION,
SUBAGENT_LIST_TOOL_DESCRIPTION,
SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS,
SUBAGENT_SPAWN_PROMPT_GUIDELINES,
SUBAGENT_SPAWN_PROMPT_SNIPPET,
SUBAGENT_SPAWN_TOOL_DESCRIPTION,
SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS,
SUBAGENT_WAIT_TOOL_DESCRIPTION,
} from "./src/prompt.ts";
import { createDeferredResultDelivery } from "./src/result-delivery.ts";
import {
createSubagentRuntime,
runTool,
type SubagentRuntime,
} from "./src/runtime.ts";
import { openSubagentPicker, openSubagentTakeover } from "./src/ui/takeover.ts";
const SUBAGENT_OUTPUT_MAX_BYTES = 24 * 1024;
const WAIT_OUTPUT_MAX_BYTES = 48 * 1024;
const WAIT_PER_AGENT_MAX_BYTES = 16 * 1024;
interface BtwResultData {
readonly id: string;
readonly title: string;
readonly status: SubagentSnapshot["status"];
readonly errorText?: string;
readonly prompt: string;
readonly answer: string;
readonly sessionFilePath?: string;
}
function describeSubagent(snap: SubagentSnapshot) {
const details = [
`${snap.backend}: ${snap.meta.modelLabel ?? "?"}`,
formatContextUtilization(snap.usage),
formatElapsed(snap),
snap.cwd,
].filter(Boolean);
return `${snap.id} [${snap.status}] "${snap.title}" (${details.join(", ")})`;
}
function truncatedOutput(
snap: SubagentSnapshot,
maxBytes = SUBAGENT_OUTPUT_MAX_BYTES,
): string {
const output = snap.finalText || "(no output)";
const truncation = truncateHead(output, {
maxBytes: Math.min(maxBytes, DEFAULT_MAX_BYTES),
maxLines: Math.min(600, DEFAULT_MAX_LINES),
});
let text = truncation.content;
if (truncation.truncated) {
text += `\n\n[Output truncated: ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)} shown. Full transcript in session file: ${snap.meta.sessionFilePath ?? "?"}]`;
}
return text;
}
/**
* Same-directory children inherit the live parent decision. An alternate cwd
* is trusted only when pi's persisted trust store explicitly trusts it (or a
* containing directory); unreadable/invalid trust data fails closed.
*/
function resolveChildProjectTrust(options: {
parentCwd: string;
childCwd: string;
parentTrusted: boolean;
}) {
if (path.resolve(options.childCwd) === path.resolve(options.parentCwd)) {
return options.parentTrusted;
}
try {
const trustStore = new ProjectTrustStore(getAgentDir());
return trustStore.get(options.childCwd) === true;
} catch {
return false;
}
}
export default function (pi: ExtensionAPI) {
let runtime: SubagentRuntime | undefined;
let managerPromise: Promise<SubagentManagerShape> | undefined;
let sessionContext: ExtensionContext | undefined;
let ui: ExtensionUIContext | undefined;
let unsubStatus: (() => void) | undefined;
const resultDelivery = createDeferredResultDelivery<SubagentSnapshot>();
const getRuntime = () => (runtime ??= createSubagentRuntime());
/** Resolve the manager service once per runtime and wire the extension hooks. */
const getManager = () => {
managerPromise ??= getRuntime()
.runPromise(SubagentManager)
.then((manager) => {
manager.view.setOnSettled(onSettled);
unsubStatus?.();
unsubStatus = manager.view.subscribe(() => updateStatus(manager));
updateStatus(manager);
return manager;
});
return managerPromise;
};
const updateStatus = (manager: SubagentManagerShape) => {
if (!ui) return;
const subs = manager.view.list();
if (subs.length === 0) {
ui.setStatus("subagents", undefined);
return;
}
const running = subs.filter((snap) => snap.status === "running").length;
const failed = subs.filter((snap) => snap.status === "error").length;
const done = subs.length - running - failed;
ui.setStatus(
"subagents",
formatActivityStatus(ui.theme, { running, done, failed }),
);
};
const deliverResult = (snap: SubagentSnapshot) => {
pi.sendMessage(
{
customType: "subagent-result",
content: buildSubagentResultMessage({
id: snap.id,
title: snap.title,
status: snap.status,
errorText: snap.errorText,
output: truncatedOutput(snap),
}),
display: true,
details: { id: snap.id, title: snap.title, status: snap.status },
},
{ deliverAs: "followUp", triggerTurn: true },
);
};
const flushResults = () => {
for (const snap of resultDelivery.drain()) deliverResult(snap);
};
const deliverBtwResult = (snap: SubagentSnapshot) => {
// appendEntry is a synchronous SessionManager operation and emits an
// entry_appended event, so it is safe while the parent is streaming and
// never enters the model's context or follow-up queue.
pi.appendEntry<BtwResultData>("btw-result", {
id: snap.id,
title: snap.title,
status: snap.status,
errorText: snap.errorText,
prompt: snap.prompt,
answer: truncatedOutput(snap),
sessionFilePath: snap.meta.sessionFilePath,
});
ui?.notify(
snap.status === "error"
? `by the way “${snap.title}” failed — reopen it with /subagents`
: `by the way “${snap.title}” answered — reopen it with /subagents`,
snap.status === "error" ? "error" : "info",
);
};
const onSettled = (snap: SubagentSnapshot, consumed: boolean) => {
// A shutdown can settle children while disposing their scopes. Never
// append into a session whose extension runtime is already closing.
if (!sessionContext) return;
if (snap.origin === "btw") {
deliverBtwResult({ ...snap, meta: { ...snap.meta } });
return;
}
if (consumed) {
resultDelivery.consume([snap.id]);
return;
}
// Keep the result retractable while the parent is working. A later
// subagent_wait can consume it before agent_settled flushes follow-ups.
// Defer a copy: the live snapshot keeps mutating if the subagent is
// restarted before the deferred result flushes.
resultDelivery.defer({ ...snap, meta: { ...snap.meta } });
if (sessionContext?.isIdle()) flushResults();
};
pi.on("session_start", (_event, ctx) => {
sessionContext = ctx;
if (ctx.hasUI) ui = ctx.ui;
});
pi.on("agent_settled", flushResults);
pi.on("session_shutdown", async () => {
sessionContext = undefined;
resultDelivery.clear();
unsubStatus?.();
unsubStatus = undefined;
ui?.setStatus("subagents", undefined);
ui = undefined;
const closing = runtime;
runtime = undefined;
managerPromise = undefined;
// Disposing the runtime runs the manager finalizer, which tears down all
// subagent scopes (and, later, their real child processes).
await closing?.dispose();
});
// --- Tools -------------------------------------------------------------
pi.registerTool({
name: "subagent_spawn",
label: "Spawn Subagent",
description: SUBAGENT_SPAWN_TOOL_DESCRIPTION,
promptSnippet: SUBAGENT_SPAWN_PROMPT_SNIPPET,
promptGuidelines: SUBAGENT_SPAWN_PROMPT_GUIDELINES,
parameters: Type.Object({
prompt: Type.String({
description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.prompt,
}),
name: Type.String({
description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.name,
}),
harness: StringEnum(BACKEND_NAMES, {
description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.harness,
}),
working_dir: Type.Optional(
Type.String({
description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.workingDir,
}),
),
model: Type.Optional(
Type.String({
description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.model,
}),
),
reasoning_effort: Type.Optional(
StringEnum(REASONING_EFFORTS, {
description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.reasoningEffort,
}),
),
}),
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
const manager = await getManager();
const harness = params.harness;
const cwd = path.resolve(ctx.cwd, params.working_dir ?? ".");
if (!fs.existsSync(cwd) || !fs.statSync(cwd).isDirectory()) {
throw new Error(`working_dir is not a directory: ${cwd}`);
}
const title = params.name.trim().slice(0, 160) || "subagent";
const snap = await runTool(
getRuntime(),
manager.spawn(harness, {
prompt: params.prompt,
title,
cwd,
model: params.model,
reasoningEffort: params.reasoning_effort,
parent: {
parentCwd: ctx.cwd,
projectTrusted: resolveChildProjectTrust({
parentCwd: ctx.cwd,
childCwd: cwd,
parentTrusted: ctx.isProjectTrusted(),
}),
inheritedModel: ctx.model
? { provider: ctx.model.provider, id: ctx.model.id }
: undefined,
inheritedThinkingLevel: pi.getThinkingLevel(),
modelRegistry: ctx.modelRegistry,
},
}),
{ signal, interruptMessage: "Subagent spawn aborted." },
);
return {
content: [
{
type: "text",
text: buildSubagentSpawnResult({
id: snap.id,
title: snap.title,
harness,
modelLabel: snap.meta.modelLabel ?? "?",
cwd,
}),
},
],
details: {
id: snap.id,
title: snap.title,
cwd,
harness,
model: snap.meta.modelLabel,
},
};
},
});
pi.registerTool({
name: "subagent_wait",
label: "Wait for Subagents",
description: SUBAGENT_WAIT_TOOL_DESCRIPTION,
parameters: Type.Object({
ids: Type.Array(Type.String(), {
maxItems: 64,
description: SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS.ids,
}),
}),
async execute(_toolCallId, params, signal, onUpdate) {
const manager = await getManager();
const ids = [...new Set(params.ids)];
if (ids.length === 0)
throw new Error("Provide at least one subagent id.");
const known = manager.view
.list()
.filter(isModelVisible)
.map((snap) => snap.id);
const unknown = ids.filter((id) => {
const snap = manager.view.get(id);
return !snap || !isModelVisible(snap);
});
if (unknown.length > 0) {
throw new Error(
`Unknown subagent id(s): ${unknown.join(", ")}. Known: ${known.join(", ") || "none"}.`,
);
}
await runTool(
getRuntime(),
manager.waitFor(ids, (pending) => {
onUpdate?.({
content: [
{ type: "text", text: `Waiting for ${pending.join(", ")}...` },
],
details: { pending },
});
}),
{ signal, interruptMessage: "Wait aborted. Subagents keep running." },
);
// Settlement may have happened before this wait began. Remove any
// deferred automatic delivery now that the tool is returning the result.
resultDelivery.consume(ids);
const sections: string[] = [];
let remainingBytes = WAIT_OUTPUT_MAX_BYTES;
for (const id of ids) {
const snap = manager.view.get(id);
if (!snap) {
sections.push(`## ${id}\n\n(no longer tracked)`);
continue;
}
const verb = snap.status === "error" ? "failed" : "finished";
let section = `## ${snap.id} "${snap.title}" ${verb}`;
if (snap.errorText) section += `\nError: ${snap.errorText}`;
const headerBytes = Buffer.byteLength(section, "utf8") + 2;
const outputBudget = Math.max(
512,
Math.min(WAIT_PER_AGENT_MAX_BYTES, remainingBytes - headerBytes),
);
section += `\n\n${truncatedOutput(snap, outputBudget)}`;
const sectionBytes = Buffer.byteLength(section, "utf8");
if (sectionBytes > remainingBytes) {
sections.push(
`## ${snap.id} "${snap.title}"\n\n[omitted: total wait output limit reached]`,
);
break;
}
sections.push(section);
remainingBytes -= sectionBytes;
}
const combined = sections.join("\n\n---\n\n");
const bounded = truncateHead(combined, {
maxBytes: WAIT_OUTPUT_MAX_BYTES - 128,
maxLines: DEFAULT_MAX_LINES,
});
const text = bounded.truncated
? `${bounded.content}\n\n[wait output truncated at the total output limit]`
: bounded.content;
return {
content: [{ type: "text", text }],
details: {
results: ids.map((id) => {
const snap = manager.view.get(id);
return { id, title: snap?.title, status: snap?.status };
}),
},
};
},
});
pi.registerTool({
name: "subagent_cancel",
label: "Cancel Subagents",
description: SUBAGENT_CANCEL_TOOL_DESCRIPTION,
parameters: Type.Object({
ids: Type.Array(Type.String(), {
description: SUBAGENT_CANCEL_PARAMETER_DESCRIPTIONS.ids,
}),
}),
async execute(_toolCallId, params, signal) {
const manager = await getManager();
const ids = [...new Set(params.ids)];
if (ids.length === 0)
throw new Error("Provide at least one subagent id.");
const known = manager.view
.list()
.filter(isModelVisible)
.map((snap) => snap.id);
const unknown = ids.filter((id) => {
const snap = manager.view.get(id);
return !snap || !isModelVisible(snap);
});
if (unknown.length > 0) {
throw new Error(
`Unknown subagent id(s): ${unknown.join(", ")}. Known: ${known.join(", ") || "none"}.`,
);
}
const report = await runTool(getRuntime(), manager.cancel(ids), {
signal,
interruptMessage: "Subagent cancellation aborted.",
});
const lines = report.map((entry) =>
entry.cancelled
? `Cancelled ${entry.id} "${entry.title}".`
: `${entry.id} "${entry.title}" was already ${entry.status}.`,
);
return {
content: [{ type: "text", text: lines.join("\n") }],
details: {
results: report.map((entry) => ({
id: entry.id,
title: entry.title,
status: entry.status,
})),
},
};
},
});
pi.registerTool({
name: "subagent_check",
label: "Check Subagent",
description: SUBAGENT_CHECK_TOOL_DESCRIPTION,
parameters: Type.Object({
id: Type.String({
description: SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS.id,
}),
}),
async execute(_toolCallId, params) {
const manager = await getManager();
const snap = manager.view.get(params.id);
if (!snap || !isModelVisible(snap)) {
const known = manager.view
.list()
.filter(isModelVisible)
.map((s) => s.id);
throw new Error(
`Unknown subagent id "${params.id}". Known: ${known.join(", ") || "none"}.`,
);
}
let text = `${describeSubagent(snap)}\nTurns: ${snap.turns}`;
if (snap.errorText) text += `\nError: ${snap.errorText}`;
const output = latestText(snap);
if (output) {
const preview = truncateHead(output, { maxBytes: 2048, maxLines: 20 });
text += `\n\nLatest output:\n${preview.content}`;
if (preview.truncated) text += "\n[...]";
} else if (snap.status === "running") {
text += "\n\n(no text output yet)";
}
return {
content: [{ type: "text", text }],
details: { id: snap.id, status: snap.status, turns: snap.turns },
};
},
});
pi.registerTool({
name: "subagent_list",
label: "List Subagents",
description: SUBAGENT_LIST_TOOL_DESCRIPTION,
parameters: Type.Object({}),
async execute() {
const manager = await getManager();
const subs = manager.view.list().filter(isModelVisible);
const text =
subs.length === 0
? "No subagents."
: subs.map((snap) => describeSubagent(snap)).join("\n");
return {
content: [{ type: "text", text }],
details: {
subagents: subs.map((snap) => ({
id: snap.id,
title: snap.title,
harness: snap.backend,
status: snap.status,
})),
},
};
},
});
// --- Result message rendering ------------------------------------------
pi.registerMessageRenderer(
"subagent-result",
(message, { expanded }, theme) => {
const details = (message.details ?? {}) as {
id?: string;
title?: string;
status?: string;
};
const failed = details.status === "error";
const icon = failed ? theme.fg("error", "x") : theme.fg("success", "■");
const header =
`${icon} ` +
theme.fg("accent", theme.bold(`subagent ${details.id ?? "?"}`)) +
theme.fg(
"muted",
` · ${details.title ?? ""} · ${failed ? "failed" : "finished"}`,
);
const content =
typeof message.content === "string" ? message.content : "";
// Remove only the summary line. The following Error line (when present)
// is part of the actual result and must remain visible.
const body = content.split("\n").slice(1).join("\n").trim();
if (expanded) {
const md = new Markdown(`${body}`, 0, 0, getMarkdownTheme());
const container = new Text(header, 0, 0);
return {
render: (width: number) => [
...container.render(width),
...md.render(width),
],
invalidate: () => {
container.invalidate();
md.invalidate();
},
};
}
const previewLines = body.split("\n").slice(0, 8);
let text = header;
for (const line of previewLines)
text += `\n${theme.fg("toolOutput", line)}`;
if (body.split("\n").length > 8)
text += `\n${theme.fg("dim", "... (ctrl+o to expand)")}`;
return new Text(text, 0, 0);
},
);
pi.registerEntryRenderer<BtwResultData>(
"btw-result",
(entry, { expanded }, theme) => {
const data = entry.data;
const failed = data?.status === "error";
const icon = failed ? theme.fg("error", "x") : theme.fg("success", "■");
const header =
`${icon} ` +
theme.fg("accent", theme.bold(`by the way · ${data?.title ?? "?"}`)) +
theme.fg(
"muted",
` · ${failed ? "failed" : "answered"} · ${data?.id ?? "?"}`,
);
const body = [
data?.errorText ? `Error: ${data.errorText}` : "",
data?.answer ?? "(no answer)",
]
.filter(Boolean)
.join("\n\n");
if (expanded) {
const md = new Markdown(body, 0, 0, getMarkdownTheme());
const container = new Text(header, 0, 0);
return {
render: (width: number) => [
...container.render(width),
...md.render(width),
],
invalidate: () => {
container.invalidate();
md.invalidate();
},
};
}
const lines = body.split("\n");
let text = header;
for (const line of lines.slice(0, 8))
text += `\n${theme.fg("toolOutput", line)}`;
if (lines.length > 8)
text += `\n${theme.fg("dim", "... (ctrl+o to expand)")}`;
return new Text(text, 0, 0);
},
);
// --- Commands -----------------------------------------------------------
const runByTheWay = async (rawArgs: string, ctx: ExtensionCommandContext) => {
if (ctx.mode !== "tui") {
if (ctx.hasUI)
ctx.ui.notify("by the way is only available in the TUI", "error");
return;
}
let prompt = rawArgs.trim();
if (!prompt) {
const input = await ctx.ui.input("by the way", "Ask a one-off question…");
prompt = input?.trim() ?? "";
if (!prompt) return;
}
const manager = await getManager();
let snap: SubagentSnapshot;
try {
snap = await runTool(
getRuntime(),
manager.spawn("pi", {
origin: "btw",
prompt,
title: deriveBtwTitle(prompt),
cwd: ctx.cwd,
parent: {
parentCwd: ctx.cwd,
projectTrusted: ctx.isProjectTrusted(),
inheritedModel: ctx.model
? { provider: ctx.model.provider, id: ctx.model.id }
: undefined,
inheritedThinkingLevel: pi.getThinkingLevel(),
modelRegistry: ctx.modelRegistry,
},
}),
);
} catch (error) {
ctx.ui.notify(
error instanceof Error ? error.message : String(error),
"error",
);
return;
}
await openSubagentTakeover(ctx, manager.view, snap.id, {
badge: "by the way",
});
};
pi.registerCommand("btw", {
description:
"Ask a one-off side question while the main agent keeps working",
handler: runByTheWay,
});
pi.registerCommand("subagents", {
description: "List, inspect, and take over subagents",
handler: async (_args, ctx) => {
if (ctx.mode !== "tui") {
if (ctx.hasUI)
ctx.ui.notify(
"Subagent takeover is only available in the TUI",
"error",
);
return;
}
const manager = await getManager();
if (manager.view.size() === 0) {
ctx.ui.notify(
"No subagents yet. The agent spawns them with subagent_spawn.",
"info",
);
return;
}
await openSubagentPicker(ctx, manager.view);
},
});
}
+276
View File
@@ -0,0 +1,276 @@
/**
* End-to-end smoke tests: manager behavior through a real ManagedRuntime,
* exactly as the tool handlers drive it. The registry is test-only: scripted
* stub sessions registered under the claude/codex names (the production
* backends launch real processes and have their own live test files), plus
* the real pi backend for its cheap registry precondition.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { Effect, Layer, ManagedRuntime } from "effect";
import { BackendRegistry, type SubagentBackend } from "./src/backend.ts";
import { piBackend } from "./src/backends/pi.ts";
import { makeStubBackend } from "./src/backends/stub.ts";
import type { BackendName, ParentContext, SpawnTask } from "./src/domain.ts";
import {
SubagentManager,
SubagentManagerLive,
type SubagentManagerShape,
} from "./src/manager.ts";
import { runTool } from "./src/runtime.ts";
const TestRegistryLive = Layer.sync(BackendRegistry, () => {
const backends: SubagentBackend[] = [
piBackend,
makeStubBackend({
backend: "claude",
defaultModelLabel: "claude/sonnet",
contextWindow: 200_000,
toolName: "Bash",
cadenceMs: 40,
}),
makeStubBackend({
backend: "codex",
defaultModelLabel: "codex/gpt-5-codex",
contextWindow: 272_000,
toolName: "shell",
cadenceMs: 30,
}),
];
return new Map<BackendName, SubagentBackend>(
backends.map((backend) => [backend.name, backend]),
);
});
const createTestRuntime = () =>
ManagedRuntime.make(
SubagentManagerLive.pipe(Layer.provide(TestRegistryLive)),
);
const parent: ParentContext = {
parentCwd: process.cwd(),
projectTrusted: false,
};
function task(prompt: string): SpawnTask {
return { prompt, title: "test", cwd: process.cwd(), parent };
}
async function withManager(
run: (
manager: SubagentManagerShape,
runtime: ReturnType<typeof createTestRuntime>,
) => Promise<void>,
) {
const runtime = createTestRuntime();
try {
const manager = await runtime.runPromise(SubagentManager);
await run(manager, runtime);
} finally {
await runtime.dispose();
}
}
test("stub subagent completes and delivers a final result", async () => {
await withManager(async (manager, runtime) => {
const settled: Array<{ id: string; consumed: boolean }> = [];
manager.view.setOnSettled((snap, consumed) =>
settled.push({ id: snap.id, consumed }),
);
const snap = await runTool(
runtime,
manager.spawn("claude", task("Say hello to the tests")),
);
assert.equal(snap.status, "running");
assert.equal(snap.backend, "claude");
assert.ok(snap.meta.sessionFilePath);
await runTool(runtime, manager.waitFor([snap.id]));
const done = manager.view.get(snap.id);
assert.ok(done);
assert.equal(done.status, "done");
assert.match(
done.finalText,
/\[stub:claude\] completed: Say hello to the tests/,
);
assert.ok(done.turns >= 2);
assert.ok(done.transcript.some((item) => item.kind === "toolResult"));
// The waitFor marked the settle as consumed.
assert.deepEqual(settled, [{ id: snap.id, consumed: true }]);
});
});
test("FAIL: prompts settle as errors; unconsumed settles are delivered", async () => {
await withManager(async (manager, runtime) => {
const settled: Array<{ id: string; consumed: boolean }> = [];
manager.view.setOnSettled((snap, consumed) =>
settled.push({ id: snap.id, consumed }),
);
const snap = await runTool(
runtime,
manager.spawn("codex", task("FAIL: blow up please")),
);
// Poll without wait-interest so the settle is delivered unconsumed.
while (manager.view.get(snap.id)?.status === "running") {
await new Promise((resolve) => setTimeout(resolve, 50));
}
const failed = manager.view.get(snap.id);
assert.equal(failed?.status, "error");
assert.match(failed?.errorText ?? "", /task failed/);
assert.deepEqual(settled, [{ id: snap.id, consumed: false }]);
});
});
test("cancel interrupts a running stub subagent", async () => {
await withManager(async (manager, runtime) => {
const snap = await runTool(
runtime,
manager.spawn("claude", task("Long running task")),
);
const report = await runTool(runtime, manager.cancel([snap.id]));
assert.deepEqual(report, [
{ id: snap.id, title: "test", status: "error", cancelled: true },
]);
assert.equal(manager.view.get(snap.id)?.errorText, "Run was aborted");
});
});
test("spawn origin propagates to ids, snapshots, and settlement", async () => {
await withManager(async (manager, runtime) => {
const settled: Array<{ id: string; origin: string }> = [];
manager.view.setOnSettled((snap) =>
settled.push({ id: snap.id, origin: snap.origin }),
);
const model = await runTool(
runtime,
manager.spawn("codex", task("model task")),
);
const btw = await runTool(
runtime,
manager.spawn("claude", { ...task("side question"), origin: "btw" }),
);
assert.match(model.id, /^sa-/);
assert.equal(model.origin, "model");
assert.match(btw.id, /^btw-/);
assert.equal(btw.origin, "btw");
await runTool(runtime, manager.cancel([model.id, btw.id]));
assert.deepEqual(
settled.sort((a, b) => a.id.localeCompare(b.id)),
[
{ id: btw.id, origin: "btw" },
{ id: model.id, origin: "model" },
].sort((a, b) => a.id.localeCompare(b.id)),
);
});
});
test("the global concurrency cap includes by-the-way sessions", async () => {
await withManager(async (manager, runtime) => {
const tasks: SpawnTask[] = [
{ ...task("side question"), origin: "btw" },
task("Task 2"),
task("Task 3"),
task("Task 4"),
];
const spawns = await runTool(
runtime,
Effect.forEach(tasks, (spawnTask) => manager.spawn("codex", spawnTask), {
concurrency: "unbounded",
}),
);
assert.equal(spawns.length, 4);
await assert.rejects(
runTool(
runtime,
manager.spawn("codex", {
...task("another side question"),
origin: "btw",
}),
),
/Max 4 subagents/,
);
});
});
test("the concurrency cap rejects a fifth running subagent", async () => {
await withManager(async (manager, runtime) => {
const spawns = await runTool(
runtime,
Effect.forEach(
[1, 2, 3, 4],
(n) => manager.spawn("codex", task(`Task ${n}`)),
{ concurrency: "unbounded" },
),
);
assert.equal(spawns.length, 4);
await assert.rejects(
runTool(runtime, manager.spawn("codex", task("Task 5"))),
/Max 4 subagents/,
);
});
});
test("pi spawn fails fast without the parent model registry", async () => {
await withManager(async (manager, runtime) => {
await assert.rejects(
runTool(runtime, manager.spawn("pi", task("needs a registry"))),
/model registry/,
);
// The failed spawn must release its concurrency reservation.
const snap = await runTool(runtime, manager.spawn("codex", task("ok")));
assert.equal(snap.backend, "codex");
});
});
test("idle restarts respect the concurrency cap", async () => {
await withManager(async (manager, runtime) => {
// Settle one subagent, then fill all four slots with running ones.
const settled = await runTool(
runtime,
manager.spawn("claude", task("early finisher")),
);
await runTool(runtime, manager.waitFor([settled.id]));
await runTool(
runtime,
Effect.forEach(
[1, 2, 3, 4],
(n) => manager.spawn("codex", task(`Task ${n}`)),
{ concurrency: "unbounded" },
),
);
// Restarting the settled one would be a fifth concurrent run.
await assert.rejects(
runTool(runtime, manager.send(settled.id, "go again")),
/Max 4 subagents/,
);
assert.equal(manager.view.get(settled.id)?.status, "done");
});
});
test("send steers an idle subagent into another turn", async () => {
await withManager(async (manager, runtime) => {
const snap = await runTool(
runtime,
manager.spawn("claude", task("First turn")),
);
await runTool(runtime, manager.waitFor([snap.id]));
const afterFirst = manager.view.get(snap.id);
assert.equal(afterFirst?.status, "done");
await runTool(runtime, manager.send(snap.id, "Second turn"));
// The fresh run flips the status back to running...
while (manager.view.get(snap.id)?.status !== "running") {
await new Promise((resolve) => setTimeout(resolve, 10));
}
await runTool(runtime, manager.waitFor([snap.id]));
const afterSecond = manager.view.get(snap.id);
assert.equal(afterSecond?.status, "done");
assert.match(afterSecond?.finalText ?? "", /Second turn/);
});
});
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createDeferredResultDelivery } from "./src/result-delivery.ts";
test("a result consumed by a later wait is not delivered", () => {
const delivery = createDeferredResultDelivery<{
id: string;
output: string;
}>();
delivery.defer({ id: "sa-1", output: "done" });
delivery.consume(["sa-1"]);
assert.deepEqual(delivery.drain(), []);
});
test("unconsumed results are delivered once in settlement order", () => {
const delivery = createDeferredResultDelivery<{ id: string }>();
const first = { id: "sa-1" };
const second = { id: "sa-2" };
delivery.defer(first);
delivery.defer(second);
assert.deepEqual(delivery.drain(), [first, second]);
assert.deepEqual(delivery.drain(), []);
});
+73
View File
@@ -0,0 +1,73 @@
/**
* The unified backend interface: one `SubagentBackend` per agent runtime
* (pi, Claude Code, Codex), all producing the same `SubagentSession` shape.
*
* Planned real implementations (currently stubbed in ./backends/):
* - pi: in-process `createAgentSession()` via the pi SDK.
* - claude: `@anthropic-ai/claude-agent-sdk` `query()` in streaming-input mode.
* - codex: `codex app-server` child process speaking JSON-RPC over stdio.
*/
import type { Effect, Scope, Stream } from "effect";
import { Context } from "effect";
import type {
BackendName,
SendError,
SpawnError,
SpawnTask,
SubagentEvent,
SubagentMeta,
} from "./domain.ts";
export interface BackendCapabilities {
/** Can send() steer a live run (vs. only starting a fresh run when idle). */
readonly steering: boolean;
readonly modelSelection: boolean;
readonly reasoningEffort: boolean;
}
/**
* A live subagent session. The manager is the single consumer of `events`;
* it folds them into the `SubagentSnapshot` everything else reads.
*/
export interface SubagentSession {
/** Current metadata snapshot. Updates also arrive as MetaChanged events. */
readonly meta: Effect.Effect<SubagentMeta>;
/**
* All activity, normalized. Ends when the session's scope closes. Every
* run started within the session terminates with a RunSettled event.
*/
readonly events: Stream.Stream<SubagentEvent>;
/**
* Steer the active run, or start a fresh run when idle (v1 `manager.send`
* semantics — the "is a run active" decision is backend-native state).
*/
send(text: string): Effect.Effect<void, SendError>;
/**
* Interrupt the active run. Resolves once the backend acknowledges; the
* corresponding RunSettled(Interrupted) arrives on `events`. Callers bound
* this with a timeout and fall back to closing the session scope.
*/
readonly interrupt: Effect.Effect<void>;
}
export interface SubagentBackend {
readonly name: BackendName;
readonly capabilities: BackendCapabilities;
/** Probe availability (binary on PATH, SDK importable, credentials). */
readonly available: Effect.Effect<boolean>;
/**
* Spawn a session. Scoped: closing the scope interrupts/kills the
* underlying session or process and ends `events`. Fire-and-forget
* semantics (background fibers, result delivery) live in the manager.
*/
spawn(
task: SpawnTask,
): Effect.Effect<SubagentSession, SpawnError, Scope.Scope>;
}
/** Registry of all wired backends, keyed by name. */
export class BackendRegistry extends Context.Service<
BackendRegistry,
ReadonlyMap<BackendName, SubagentBackend>
>()("subagents/BackendRegistry") {}
+701
View File
@@ -0,0 +1,701 @@
/**
* Claude Code backend — real implementation over the Claude Agent SDK.
*
* One SDK `query()` and one streaming-input bridge live for the whole scoped
* session. User sends are pushed into that bridge, while the query iterator is
* pumped in the background and translated to normalized SubagentEvents. The
* CLI therefore owns conversation continuity, tool execution, and persisted
* `~/.claude/projects` transcripts; this file only owns lifecycle guarantees
* and the normalized view consumed by the manager.
*/
import { randomUUID } from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import {
query,
type SDKAssistantMessage,
type SDKMessage,
type SDKResultMessage,
type SDKUserMessage,
} from "@anthropic-ai/claude-agent-sdk";
import type { Cause, Scope } from "effect";
import { Effect, Queue, Stream } from "effect";
import type { SubagentBackend, SubagentSession } from "../backend.ts";
import type {
QueuedMessage,
ReasoningEffort,
RunOutcome,
SpawnTask,
SubagentEvent,
SubagentMeta,
TranscriptPart,
} from "../domain.ts";
import { SendError, SpawnError } from "../domain.ts";
const CLAUDE_CONTEXT_WINDOW = 200_000;
const INTERRUPT_TIMEOUT_MS = 2_000;
const PREVIEW_MAX_LENGTH = 4_096;
// --- Binary resolution --------------------------------------------------------
let cachedClaudeBinary: string | null | undefined;
function executable(file: string) {
try {
fs.accessSync(file, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Resolve the Claude Code CLI once from PATH. The SDK can also run without
* this (it bundles a CLI), but pointing it at the user's installed binary
* keeps versions, settings, and login state consistent with their terminal.
*/
function resolveClaudeBinary() {
if (cachedClaudeBinary !== undefined) return cachedClaudeBinary ?? undefined;
const names =
process.platform === "win32"
? ["claude.exe", "claude.cmd", "claude"]
: ["claude"];
for (const directory of (process.env.PATH ?? "").split(path.delimiter)) {
if (!directory) continue;
for (const name of names) {
const candidate = path.join(directory, name);
if (executable(candidate)) {
cachedClaudeBinary = candidate;
return candidate;
}
}
}
cachedClaudeBinary = null;
return undefined;
}
// --- Streaming input ---------------------------------------------------------
/** A single-consumer push queue adapted to the SDK's AsyncIterable input. */
class ClaudeInput implements AsyncIterable<SDKUserMessage> {
private pending: SDKUserMessage[] = [];
private waiter:
((result: IteratorResult<SDKUserMessage>) => void) | undefined;
private closed = false;
push(text: string) {
if (this.closed) return undefined;
const message: SDKUserMessage = {
type: "user",
message: { role: "user", content: text },
parent_tool_use_id: null,
// Stamped UUIDs make queued messages visible in interrupt receipts.
uuid: randomUUID(),
};
const waiter = this.waiter;
if (waiter) {
this.waiter = undefined;
waiter({ value: message, done: false });
} else {
this.pending.push(message);
}
return message;
}
clear() {
return this.pending.splice(0);
}
end() {
if (this.closed) return;
this.closed = true;
const waiter = this.waiter;
this.waiter = undefined;
waiter?.({ value: undefined, done: true });
}
async *[Symbol.asyncIterator]() {
while (true) {
const message = this.pending.shift();
if (message) {
yield message;
continue;
}
if (this.closed) return;
const next = await new Promise<IteratorResult<SDKUserMessage>>(
(resolve) => {
this.waiter = resolve;
},
);
if (next.done) return;
yield next.value;
}
}
}
// --- Model, effort, and transcript helpers ----------------------------------
/**
* Claude's deprecated-but-supported maxThinkingTokens is the closest match to
* the shared numeric scale requested by this extension. Zero explicitly
* disables extended thinking in SDK 0.3.207; an omitted effort leaves the CLI
* default untouched.
*/
const THINKING_BUDGETS = {
off: 0,
minimal: 1_024,
low: 4_096,
medium: 10_000,
high: 16_000,
xhigh: 32_000,
max: 63_999,
} satisfies Record<ReasoningEffort, number>;
function boundedError(error: unknown) {
return (error instanceof Error ? error.message : String(error)).slice(
0,
4_096,
);
}
function singleLine(text: string) {
const flattened = text.replace(/\s+/g, " ").trim();
return flattened ? flattened.slice(0, PREVIEW_MAX_LENGTH) : undefined;
}
function safeJson(value: unknown) {
try {
const text = JSON.stringify(value);
if (!text || text === "{}") return undefined;
return singleLine(text);
} catch {
return undefined;
}
}
function outputPreview(value: unknown): string | undefined {
if (typeof value === "string") return singleLine(value);
if (Array.isArray(value)) {
const text = value
.flatMap((part) => {
if (!part || typeof part !== "object") return [];
const record = part as { type?: unknown; text?: unknown };
return record.type === "text" && typeof record.text === "string"
? [record.text]
: [];
})
.join(" ");
return singleLine(text) ?? safeJson(value);
}
return safeJson(value);
}
function assistantParts(message: SDKAssistantMessage): TranscriptPart[] {
const parts: TranscriptPart[] = [];
for (const block of message.message.content) {
if (block.type === "text") {
parts.push({ type: "text", text: block.text });
} else if (block.type === "thinking") {
parts.push({ type: "thinking", text: block.thinking });
} else if (block.type === "redacted_thinking") {
parts.push({ type: "thinking", text: "", redacted: true });
} else if (block.type === "tool_use") {
parts.push({
type: "toolCall",
toolId: block.id,
name: block.name,
argsPreview: safeJson(block.input),
});
}
}
return parts;
}
/** Claude Code's project-directory escaping, verified against CLI 2.1.207. */
function sessionFilePath(cwd: string, sessionId: string) {
const projectDirectory = cwd.replace(/[/.]/g, "-");
return path.join(
os.homedir(),
".claude",
"projects",
projectDirectory,
`${sessionId}.jsonl`,
);
}
/**
* Context occupancy after one API request. An assistant message's `usage`
* describes only that request: the full prompt (fresh + cache-read +
* cache-written input) plus this response's output — exactly what now sits
* in the context window. The result message's `usage` instead sums these
* per-request counts across the whole run, so a multi-request turn
* re-counts cached context once per request and quickly exceeds the real
* window; it must never be treated as occupancy.
*/
export function contextOccupancyTokens(
usage:
| {
input_tokens?: number | null;
cache_read_input_tokens?: number | null;
cache_creation_input_tokens?: number | null;
output_tokens?: number | null;
}
| null
| undefined,
) {
if (!usage || typeof usage.input_tokens !== "number") return undefined;
const count = (value: number | null | undefined) =>
typeof value === "number" && Number.isFinite(value) ? value : 0;
return (
count(usage.input_tokens) +
count(usage.cache_read_input_tokens) +
count(usage.cache_creation_input_tokens) +
count(usage.output_tokens)
);
}
function resultContextWindow(result: SDKResultMessage) {
return Object.values(result.modelUsage)[0]?.contextWindow;
}
function waitBounded(operation: Promise<unknown>, timeoutMs: number) {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs);
});
return Promise.race([
operation.then(
() => undefined,
() => undefined,
),
timeout,
]).finally(() => {
if (timer) clearTimeout(timer);
});
}
// --- The session -------------------------------------------------------------
interface NativeQueuedMessage extends QueuedMessage {
readonly uuid: string;
/** Raw response sequence after which this steer can be consumed. */
readonly afterResponse: number;
}
const makeClaudeSession = (
task: SpawnTask,
): Effect.Effect<SubagentSession, SpawnError, Scope.Scope> =>
Effect.gen(function* () {
const input = new ClaudeInput();
const abortController = new AbortController();
const events = yield* Queue.make<SubagentEvent, Cause.Done>();
const emit = (event: SubagentEvent) => {
Queue.offerUnsafe(events, event);
};
const state = {
closed: false,
activeRun: false,
interruptRequested: false,
runVersion: 0,
lastSettledVersion: 0,
responseSequence: 0,
queued: [] as NativeQueuedMessage[],
submittedUuids: new Set<string>(),
currentText: "",
liveText: "",
tools: new Map<string, string>(),
settleWaiters: new Set<() => void>(),
meta: {
backend: "claude",
modelLabel: task.model,
// Claude models used by this backend currently expose 200k context;
// result.modelUsage replaces this fallback when the CLI knows better.
contextWindow: CLAUDE_CONTEXT_WINDOW,
} satisfies SubagentMeta as SubagentMeta,
};
const thinkingBudget = task.reasoningEffort
? THINKING_BUDGETS[task.reasoningEffort]
: undefined;
const claudeBinary = resolveClaudeBinary();
const nativeQuery = yield* Effect.try({
try: () =>
query({
prompt: input,
options: {
cwd: task.cwd,
// Headless children cannot answer approval prompts. The caller
// already chose to launch an autonomous subagent, so let it use
// its tools without interactive permission checks.
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
// Keep child orchestration inside this extension's global manager
// and concurrency cap rather than Claude Code's native subagents.
disallowedTools: ["Agent", "Task"],
// For cwds pi marked untrusted, restrict to user-level settings so
// an untrusted project's config cannot reconfigure the child.
...(task.parent.projectTrusted
? {}
: { settingSources: ["user" as const] }),
includePartialMessages: true,
abortController,
...(claudeBinary
? { pathToClaudeCodeExecutable: claudeBinary }
: {}),
...(task.model ? { model: task.model } : {}),
...(thinkingBudget !== undefined
? { maxThinkingTokens: thinkingBudget }
: {}),
},
}),
catch: (error) => new SpawnError({ message: boundedError(error) }),
});
let resolvePumpDone: (() => void) | undefined;
const pumpDone = new Promise<void>((resolve) => {
resolvePumpDone = resolve;
});
const queuedView = (): ReadonlyArray<QueuedMessage> =>
state.queued.map(({ text, kind }) => ({ text, kind }));
const notifySettled = () => {
for (const waiter of state.settleWaiters) waiter();
state.settleWaiters.clear();
};
const partialText = () => state.liveText || state.currentText || undefined;
const settle = (outcome: RunOutcome) => {
if (!state.activeRun) return;
emit({ _tag: "RunSettled", outcome });
state.activeRun = false;
state.lastSettledVersion = state.runVersion;
state.interruptRequested = false;
state.liveText = "";
notifySettled();
};
const updateMeta = (patch: Partial<SubagentMeta>) => {
state.meta = { ...state.meta, ...patch };
emit({ _tag: "MetaChanged", meta: patch });
};
const beginQueuedRunIfNeeded = () => {
if (state.activeRun || state.queued.length === 0) return;
// A steer arriving too late for the prior turn becomes a fresh queued
// turn. The repeated init is the first reliable signal that it started.
state.activeRun = true;
state.runVersion++;
state.currentText = "";
state.liveText = "";
emit({ _tag: "RunStarted" });
};
const clearConsumedSteers = () => {
const remaining = state.queued.filter(
(message) => message.afterResponse >= state.responseSequence,
);
if (remaining.length === state.queued.length) return;
state.queued = remaining;
emit({ _tag: "QueueChanged", queued: queuedView() });
};
const handleAssistant = (message: SDKAssistantMessage) => {
const parts = assistantParts(message);
if (parts.length > 0) emit({ _tag: "AssistantMessage", parts });
// Top-level messages only: subagent (sidechain) requests have their own
// context and must not overwrite this conversation's occupancy.
if (message.parent_tool_use_id == null) {
const tokens = contextOccupancyTokens(message.message.usage);
if (tokens !== undefined) emit({ _tag: "UsageChanged", tokens });
}
const text = message.message.content
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("\n")
.trim();
if (text) state.currentText = text;
state.liveText = "";
if (message.message.model !== state.meta.modelLabel) {
updateMeta({ modelLabel: message.message.model });
}
for (const block of message.message.content) {
if (block.type !== "tool_use") continue;
state.tools.set(block.id, block.name);
emit({
_tag: "ToolStart",
toolId: block.id,
name: block.name,
argsPreview: safeJson(block.input),
});
}
};
const handleUser = (message: SDKUserMessage) => {
const content = message.message.content;
if (!Array.isArray(content)) return;
for (const block of content) {
if (block.type !== "tool_result") continue;
const name = state.tools.get(block.tool_use_id) ?? "Tool";
state.tools.delete(block.tool_use_id);
emit({
_tag: "ToolEnd",
toolId: block.tool_use_id,
name,
isError: block.is_error ?? false,
outputPreview: outputPreview(block.content),
});
}
// External prompts are emitted synchronously by submit(); ignoring text
// here prevents a future CLI echo from duplicating the transcript row.
};
const handleResult = (result: SDKResultMessage) => {
// result.usage is a whole-run aggregate, not occupancy (see
// contextOccupancyTokens); only the capacity is trustworthy here. The
// occupancy itself was already emitted by the last assistant message.
const contextWindow = resultContextWindow(result);
emit({
_tag: "UsageChanged",
contextWindow: contextWindow ?? state.meta.contextWindow,
});
if (
contextWindow !== undefined &&
contextWindow !== state.meta.contextWindow
) {
updateMeta({ contextWindow });
}
if (state.interruptRequested) {
settle({ _tag: "Interrupted", partialText: partialText() });
} else if (result.subtype === "success") {
settle({
_tag: "Completed",
finalText: result.result.trim() || state.currentText,
});
} else {
const details =
result.errors.filter((error) => error.trim()).join("\n") ||
result.stop_reason ||
`Claude Code ended with ${result.subtype}`;
settle({
_tag: "Failed",
errorText: boundedError(details),
partialText: partialText(),
});
}
};
const handleMessage = (message: SDKMessage) => {
if (state.closed) return;
// A queued steer's turn normally announces itself with a repeated
// system/init, but any turn activity is an equally valid begin signal —
// without this, a missed init would stream events into a "done" run
// that could then never settle.
if (
message.type === "stream_event" ||
message.type === "assistant" ||
message.type === "result"
) {
beginQueuedRunIfNeeded();
}
if (message.type === "system" && message.subtype === "init") {
beginQueuedRunIfNeeded();
updateMeta({
modelLabel: message.model,
nativeSessionId: message.session_id,
sessionFilePath: sessionFilePath(message.cwd, message.session_id),
contextWindow: CLAUDE_CONTEXT_WINDOW,
});
} else if (message.type === "stream_event") {
if (message.parent_tool_use_id !== null) return;
if (message.event.type === "message_start") {
state.responseSequence++;
clearConsumedSteers();
} else if (message.event.type === "content_block_delta") {
const delta = message.event.delta;
if (delta.type === "text_delta") {
state.liveText += delta.text;
emit({ _tag: "AssistantDelta", kind: "text", delta: delta.text });
} else if (delta.type === "thinking_delta") {
emit({
_tag: "AssistantDelta",
kind: "thinking",
delta: delta.thinking,
});
}
}
} else if (message.type === "assistant") {
handleAssistant(message);
} else if (message.type === "user") {
handleUser(message);
} else if (message.type === "result") {
handleResult(message);
}
};
const pump = async () => {
let failure: string | undefined;
try {
for await (const message of nativeQuery) handleMessage(message);
} catch (error) {
if (!state.closed && !abortController.signal.aborted) {
failure = boundedError(error);
}
} finally {
if (!state.closed) {
if (state.activeRun) {
settle(
state.interruptRequested
? { _tag: "Interrupted", partialText: partialText() }
: {
_tag: "Failed",
errorText:
failure ?? "Claude Code query ended unexpectedly",
partialText: partialText(),
},
);
} else if (failure) {
emit({ _tag: "BackendError", message: failure });
}
state.closed = true;
Queue.endUnsafe(events);
}
resolvePumpDone?.();
}
};
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
// Settle before marking closed: the pump's finally skips settlement
// once closed, and every run must end in a RunSettled even when the
// scope closes mid-run.
if (state.activeRun) {
settle({ _tag: "Interrupted", partialText: partialText() });
}
state.closed = true;
input.end();
abortController.abort();
nativeQuery.close();
await waitBounded(pumpDone, INTERRUPT_TIMEOUT_MS);
Queue.endUnsafe(events);
}),
);
void pump();
const submit = (text: string) => {
const wasActive = state.activeRun;
const message = input.push(text);
if (!message) return false;
if (!wasActive) {
state.activeRun = true;
state.runVersion++;
state.currentText = "";
state.liveText = "";
}
state.submittedUuids.add(message.uuid ?? "");
// Idle restarts flip status synchronously (like pi's startRun); a steer
// into an active run must NOT re-emit RunStarted — its own turn begins
// later via beginQueuedRunIfNeeded.
if (!wasActive) emit({ _tag: "RunStarted" });
emit({ _tag: "UserMessage", text });
if (wasActive) {
state.queued.push({
text,
kind: "steer",
uuid: message.uuid ?? "",
afterResponse: state.responseSequence,
});
emit({ _tag: "QueueChanged", queued: queuedView() });
}
return true;
};
const waitForVersion = (version: number) => {
if (state.lastSettledVersion >= version) return Promise.resolve();
return new Promise<void>((resolve) => {
const waiter = () => {
if (state.lastSettledVersion < version && !state.closed) return;
state.settleWaiters.delete(waiter);
resolve();
};
state.settleWaiters.add(waiter);
});
};
emit({ _tag: "MetaChanged", meta: state.meta });
submit(task.prompt);
return {
meta: Effect.sync(() => state.meta),
events: Stream.fromQueue(events),
send: (text) =>
Effect.suspend((): Effect.Effect<void, SendError> => {
if (state.closed) {
return new SendError({ message: "Subagent session is closed." });
}
return submit(text)
? Effect.void
: new SendError({ message: "Subagent session is closed." });
}),
interrupt: Effect.promise(async () => {
if (state.closed || !state.activeRun) return;
const version = state.runVersion;
state.interruptRequested = true;
input.clear();
state.queued = [];
emit({ _tag: "QueueChanged", queued: [] });
const interruptAndSettle = (async () => {
try {
const receipt = await nativeQuery.interrupt();
const hasOwnQueuedMessage = receipt?.still_queued?.some((uuid) =>
state.submittedUuids.has(uuid),
);
if (hasOwnQueuedMessage) {
// 0.3.207 exposes cancellation receipts but no public per-message
// cancel method. Closing is the only way to prevent a cancelled
// queued prompt from immediately starting another turn.
input.end();
nativeQuery.close();
}
} catch (error) {
if (!state.closed) {
emit({ _tag: "BackendError", message: boundedError(error) });
}
}
await waitForVersion(version);
})();
await waitBounded(interruptAndSettle, INTERRUPT_TIMEOUT_MS);
if (!state.closed && state.activeRun && state.runVersion === version) {
// Covers pre-init/pending races and SDK versions that acknowledge an
// interrupt without delivering a result. Force-close after settling
// so a late native result cannot resurrect or re-settle this run.
settle({ _tag: "Interrupted", partialText: partialText() });
state.closed = true;
input.end();
abortController.abort();
nativeQuery.close();
Queue.endUnsafe(events);
}
}),
} satisfies SubagentSession;
});
// --- Backend -----------------------------------------------------------------
export const claudeBackend: SubagentBackend = {
name: "claude",
capabilities: { steering: true, modelSelection: true, reasoningEffort: true },
available: Effect.sync(() => resolveClaudeBinary() !== undefined),
spawn: makeClaudeSession,
};
File diff suppressed because it is too large Load Diff
+575
View File
@@ -0,0 +1,575 @@
/**
* pi backend — real implementation over the pi SDK.
*
* Each subagent is an in-process `AgentSession` (a port of v1
* subagents/manager.ts + shared/child-session.ts):
* - real session files visible in /resume, child resources loaded per-cwd
* with trust gating, and the child tool denylist;
* - `session.subscribe()` events translated to normalized SubagentEvents;
* - send() steers a streaming run or starts a fresh prompt() when idle;
* - interrupt clears the queue and aborts; closing the session scope emits
* the child session_shutdown hook and disposes the session.
*/
import type { AssistantMessage, Message, Model } from "@earendil-works/pi-ai";
import type {
AgentSession,
AgentSessionEvent,
ModelRegistry,
} from "@earendil-works/pi-coding-agent";
import {
createAgentSession,
DefaultResourceLoader,
getAgentDir,
SessionManager,
SettingsManager,
} from "@earendil-works/pi-coding-agent";
import type { Cause, Scope } from "effect";
import { Effect, Queue, Stream } from "effect";
import type { SubagentBackend, SubagentSession } from "../backend.ts";
import type {
SpawnTask,
SubagentEvent,
SubagentMeta,
TranscriptPart,
} from "../domain.ts";
import { SendError, SpawnError } from "../domain.ts";
import { createToolCallTimeoutGuard } from "../../../shared/tool-call-timeout.ts";
const CHILD_SHUTDOWN_TIMEOUT_MS = 5_000;
/** Tools that headless children must not receive. Everything else stays enabled. */
const CHILD_EXCLUDED_TOOL_NAMES = [
"subagent_spawn",
"subagent_wait",
"subagent_cancel",
"subagent_check",
"subagent_list",
"workflow",
"ask_user",
] as const;
// --- Model + effort resolution -----------------------------------------------
type ThinkingLevel = NonNullable<
NonNullable<Parameters<typeof createAgentSession>[0]>["thinkingLevel"]
>;
/**
* Resolve the generic model hint against the parent registry (v1 semantics):
* "provider/model-id" is exact; a bare id prefers the inherited provider,
* then must be unambiguous across providers. No hint inherits the parent
* model; with nothing to inherit, the SDK default applies.
*/
function resolvePiModel(
registry: ModelRegistry,
hint: string | undefined,
inherited: { provider: string; id: string } | undefined,
): Model<any> | undefined {
if (!hint) {
if (!inherited) return undefined;
return registry.find(inherited.provider, inherited.id) ?? undefined;
}
const slash = hint.indexOf("/");
if (slash > 0) {
const provider = hint.slice(0, slash);
const id = hint.slice(slash + 1);
const found = registry.find(provider, id);
if (found) return found;
throw new Error(`Unknown model "${hint}".`);
}
if (inherited) {
const found = registry.find(inherited.provider, hint);
if (found) return found;
}
const matches = registry.getAll().filter((m) => m.id === hint);
if (matches.length === 1) return matches[0];
if (matches.length > 1) {
throw new Error(
`Model "${hint}" exists in multiple providers (${matches.map((m) => m.provider).join(", ")}). Use "provider/${hint}".`,
);
}
throw new Error(`Unknown model "${hint}".`);
}
// --- Child session helpers (ported from v1 shared/child-session.ts) -----------
/** Load normal global/package resources and trust-gated project resources. */
async function createChildResources(cwd: string, projectTrusted: boolean) {
const agentDir = getAgentDir();
const settingsManager = SettingsManager.create(cwd, agentDir, {
projectTrusted,
});
const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
await loader.reload();
return { loader, settingsManager };
}
function waitBounded(operation: Promise<unknown>, timeoutMs: number) {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs);
});
return Promise.race([
operation.then(
() => undefined,
() => undefined,
),
timeout,
])
.catch(() => {})
.finally(() => {
if (timer) clearTimeout(timer);
});
}
/** Emit child session_shutdown (bounded), then dispose. Never throws. */
async function shutdownAndDisposeChildSession(session: AgentSession) {
try {
if (session.extensionRunner.hasHandlers("session_shutdown")) {
await waitBounded(
session.extensionRunner.emit({
type: "session_shutdown",
reason: "quit",
}),
CHILD_SHUTDOWN_TIMEOUT_MS,
);
}
} catch {
// Extension runner inspection/emission is best-effort during teardown.
} finally {
try {
session.dispose();
} catch {
// Disposal is terminal and must remain idempotent for callers.
}
}
}
// --- Event translation ----------------------------------------------------------
function messageRole(msg: unknown): Message["role"] | undefined {
const role = (msg as { role?: string } | undefined)?.role;
if (role === "user" || role === "assistant" || role === "toolResult")
return role;
return undefined;
}
function lastAssistantMessage(
session: AgentSession,
): AssistantMessage | undefined {
const messages = session.messages;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (messageRole(msg) === "assistant") return msg as AssistantMessage;
}
return undefined;
}
/** Final assistant text output (last assistant message with text), v1 semantics. */
function finalOutput(session: AgentSession): string {
const messages = session.messages;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (messageRole(msg) !== "assistant") continue;
const text = (msg as AssistantMessage).content
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
.trim();
if (text) return text;
}
return "";
}
function safeJson(value: unknown): string | undefined {
try {
const text = JSON.stringify(value);
return text === "{}" ? undefined : text.slice(0, 4_096);
} catch {
return undefined;
}
}
/** First non-empty line of a tool result-ish value (v1 liveToolPreview). */
function toolPreview(value: unknown): string | undefined {
if (typeof value === "string") {
return value
.split("\n")
.find((line) => line.trim())
?.trim();
}
if (!value || typeof value !== "object") return undefined;
const content = (value as { content?: unknown }).content;
if (!Array.isArray(content)) return undefined;
for (const part of content) {
if (!part || typeof part !== "object") continue;
const record = part as { type?: unknown; text?: unknown };
if (record.type !== "text" || typeof record.text !== "string") continue;
const firstLine = record.text.split("\n").find((line) => line.trim());
if (firstLine) return firstLine.trim();
}
return undefined;
}
function assistantParts(msg: AssistantMessage): TranscriptPart[] {
const parts: TranscriptPart[] = [];
for (const part of msg.content) {
if (part.type === "text") {
parts.push({ type: "text", text: part.text });
} else if (part.type === "thinking") {
parts.push({
type: "thinking",
text: part.redacted ? "" : part.thinking,
redacted: part.redacted,
});
} else if (part.type === "toolCall") {
parts.push({
type: "toolCall",
toolId: part.id,
name: part.name,
argsPreview: safeJson(part.arguments),
});
}
}
return parts;
}
function userText(msg: Message): string {
const content = (msg as { content: unknown }).content;
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter(
(part): part is { type: "text"; text: string } =>
!!part &&
typeof part === "object" &&
(part as { type?: unknown }).type === "text",
)
.map((part) => part.text)
.join("\n");
}
// --- The session ------------------------------------------------------------------
function boundedError(error: unknown) {
return (error instanceof Error ? error.message : String(error)).slice(
0,
4096,
);
}
const makePiSession = (
task: SpawnTask,
): Effect.Effect<SubagentSession, SpawnError, Scope.Scope> =>
Effect.gen(function* () {
const registry = task.parent.modelRegistry;
if (!registry) {
return yield* new SpawnError({
message: "pi backend requires the parent session's model registry.",
});
}
const model = yield* Effect.try({
try: () =>
resolvePiModel(registry, task.model, task.parent.inheritedModel),
catch: (error) => new SpawnError({ message: boundedError(error) }),
});
// pi's thinking levels ARE the shared reasoning-effort scale.
const thinkingLevel = (task.reasoningEffort ??
task.parent.inheritedThinkingLevel) as ThinkingLevel | undefined;
const session = yield* Effect.tryPromise({
try: async () => {
const { loader, settingsManager } = await createChildResources(
task.cwd,
task.parent.projectTrusted,
);
const { session } = await createAgentSession({
cwd: task.cwd,
sessionManager: SessionManager.create(task.cwd),
settingsManager,
resourceLoader: loader,
model,
thinkingLevel,
excludeTools: [...CHILD_EXCLUDED_TOOL_NAMES],
});
// Start child extension session hooks/resources in headless mode.
// A rejection here would otherwise leak the freshly created session:
// the scope finalizer that owns cleanup is only registered later.
try {
await session.bindExtensions({ mode: "print" });
} catch (error) {
await shutdownAndDisposeChildSession(session);
throw error;
}
return session;
},
catch: (error) => new SpawnError({ message: boundedError(error) }),
});
const state = {
closed: false,
/** prompt() rejection for the active run; folded into RunSettled. */
runError: undefined as string | undefined,
/** One terminal event per run: lifecycle, prompt-rejection, and abort
* fallbacks can all race to settle; the first wins. */
settled: false,
};
const events = yield* Queue.make<SubagentEvent, Cause.Done>();
const emit = (event: SubagentEvent) => {
Queue.offerUnsafe(events, event);
};
const toolTimeout = createToolCallTimeoutGuard();
toolTimeout.apply(session);
const activeModel = (): Model<any> | undefined => {
const sessionModel = session.model;
const last = lastAssistantMessage(session);
if (!last) return sessionModel;
if (
sessionModel &&
(last.provider !== sessionModel.provider ||
last.model !== sessionModel.id)
) {
// The session changed models after this assistant response.
return sessionModel;
}
return (
registry.find(last.provider, last.responseModel ?? last.model) ??
sessionModel
);
};
const currentMeta = (): SubagentMeta => {
const m = activeModel();
return {
backend: "pi",
modelLabel: m ? `${m.provider}/${m.id}` : undefined,
contextWindow: m?.contextWindow,
sessionFilePath: session.sessionFile,
};
};
const emitUsage = () => {
const usage = session.getContextUsage();
emit({
_tag: "UsageChanged",
tokens: usage?.tokens ?? undefined,
contextWindow: activeModel()?.contextWindow ?? usage?.contextWindow,
});
};
const settle = () => {
if (state.settled) return;
state.settled = true;
const last = lastAssistantMessage(session);
const partialText = finalOutput(session) || undefined;
if (last?.stopReason === "aborted") {
emit({
_tag: "RunSettled",
outcome: { _tag: "Interrupted", partialText },
});
return;
}
const errorText =
state.runError ??
(last?.stopReason === "error"
? (last.errorMessage ?? "Run failed")
: undefined);
if (errorText !== undefined) {
emit({
_tag: "RunSettled",
outcome: {
_tag: "Failed",
errorText: boundedError(errorText),
partialText,
},
});
return;
}
emit({
_tag: "RunSettled",
outcome: { _tag: "Completed", finalText: finalOutput(session) },
});
};
const handleEvent = (event: AgentSessionEvent) => {
if (state.closed) return;
switch (event.type) {
case "agent_start":
// Extensions may register tools between runs; guard new ones too.
toolTimeout.apply(session);
state.settled = false;
emit({ _tag: "RunStarted" });
break;
case "message_update": {
const streamEvent = event.assistantMessageEvent;
if (streamEvent.type === "text_delta") {
emit({
_tag: "AssistantDelta",
kind: "text",
delta: streamEvent.delta,
});
} else if (streamEvent.type === "thinking_delta") {
emit({
_tag: "AssistantDelta",
kind: "thinking",
delta: streamEvent.delta,
});
}
break;
}
case "message_end": {
const role = messageRole(event.message);
if (role === "user") {
const text = userText(event.message as Message);
if (text.trim()) emit({ _tag: "UserMessage", text });
} else if (role === "assistant") {
emit({
_tag: "AssistantMessage",
parts: assistantParts(event.message as AssistantMessage),
});
emitUsage();
emit({ _tag: "MetaChanged", meta: currentMeta() });
}
// toolResult messages are covered by tool_execution_end.
break;
}
case "tool_execution_start":
emit({
_tag: "ToolStart",
toolId: event.toolCallId,
name: event.toolName,
argsPreview: safeJson(event.args),
});
break;
case "tool_execution_update":
emit({
_tag: "ToolUpdate",
toolId: event.toolCallId,
outputPreview: toolPreview(event.partialResult),
});
break;
case "tool_execution_end":
emit({
_tag: "ToolEnd",
toolId: event.toolCallId,
name: event.toolName,
isError: event.isError,
outputPreview: toolPreview(event.result),
});
break;
case "queue_update":
emit({
_tag: "QueueChanged",
queued: [
...event.steering.map((text) => ({
text,
kind: "steer" as const,
})),
...event.followUp.map((text) => ({
text,
kind: "follow-up" as const,
})),
],
});
break;
case "agent_settled":
settle();
break;
}
};
const unsubscribe = session.subscribe(handleEvent);
yield* Effect.addFinalizer(() =>
Effect.promise(async () => {
state.closed = true;
unsubscribe();
try {
session.clearQueue();
} catch {
// Continue with abort/dispose.
}
await waitBounded(session.abort(), CHILD_SHUTDOWN_TIMEOUT_MS);
await shutdownAndDisposeChildSession(session);
Queue.endUnsafe(events);
}),
);
/** Start a fresh run (v1 manager.run): fire-and-forget, errors -> events. */
const startRun = (text: string) => {
state.runError = undefined;
state.settled = false;
emit({ _tag: "RunStarted" });
void session.prompt(text).catch((error) => {
state.runError = boundedError(error);
// Preflight failures may never start the agent lifecycle, so no
// agent_settled will arrive for them.
if (!session.isStreaming) settle();
});
};
// Session naming is best-effort.
yield* Effect.try(() =>
session.sessionManager.appendSessionInfo(
`${task.origin === "btw" ? "btw" : "subagent"}: ${task.title}`,
),
).pipe(Effect.ignore);
emit({ _tag: "MetaChanged", meta: currentMeta() });
startRun(task.prompt);
return {
meta: Effect.sync(currentMeta),
events: Stream.fromQueue(events),
send: (text) =>
Effect.suspend((): Effect.Effect<void, SendError> => {
if (state.closed) {
return new SendError({ message: "Subagent session is closed." });
}
if (session.isStreaming) {
// Steer the active run via the SDK's queue; queue_update events
// render it, message_end(user) lands it in the transcript. A
// rejected steer is a real send failure, not a diagnostic.
return Effect.tryPromise({
try: () => session.steer(text),
catch: (error) => new SendError({ message: boundedError(error) }),
}).pipe(Effect.asVoid);
}
return Effect.sync(() => startRun(text));
}),
interrupt: Effect.promise(async () => {
if (state.closed) return;
try {
session.clearQueue();
} catch {
// Abort regardless.
}
await session.abort().catch(() => undefined);
// Only resolve once streaming has actually stopped: reporting the
// interrupt as complete while the run keeps working would let the
// manager settle a run that is still mutating the workspace. The
// manager bounds this effect at 5s and force-disposes on timeout.
while (!state.closed && session.isStreaming) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
// No streaming run means no agent_settled will arrive; emit the
// terminal event (once) so the run cannot look running forever.
if (!state.closed && !state.settled) {
state.settled = true;
emit({ _tag: "RunSettled", outcome: { _tag: "Interrupted" } });
}
}),
} satisfies SubagentSession;
});
export const piBackend: SubagentBackend = {
name: "pi",
capabilities: { steering: true, modelSelection: true, reasoningEffort: true },
// In-process SDK: always available.
available: Effect.succeed(true),
spawn: makePiSession,
};
+300
View File
@@ -0,0 +1,300 @@
/**
* Scripted stub sessions shared by all three backend implementations while
* the real integrations are pending. A stub session:
*
* - streams a plausible turn (thinking deltas, one fake tool cycle, text
* deltas, usage ramp, a final assistant message, RunSettled) over a few
* seconds so streaming UI, wait, and the footer counters are observable;
* - supports send() while running (queued-steer rendering) and while idle
* (fresh run);
* - supports interrupt (RunSettled Interrupted -> status "error", matching v1);
* - fails the run when the prompt starts with "FAIL:" (error-path testing);
* - appends every event to a JSONL "session file" in tmpdir so the
* "full transcript in session file" pointers resolve.
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { Cause, Scope } from "effect";
import { Duration, Effect, Fiber, Queue, Ref, Stream } from "effect";
import type { SubagentBackend, SubagentSession } from "../backend.ts";
import type {
BackendName,
QueuedMessage,
SpawnTask,
SubagentEvent,
SubagentMeta,
} from "../domain.ts";
import { SendError } from "../domain.ts";
export interface StubProfile {
readonly backend: BackendName;
readonly defaultModelLabel: string;
readonly contextWindow: number;
readonly toolName: string;
/** Delay between scripted events; varies per backend so streams differ. */
readonly cadenceMs: number;
}
const STUB_DIR = path.join(os.tmpdir(), "subagents-stub");
let sessionCounter = 0;
export function makeStubBackend(profile: StubProfile): SubagentBackend {
return {
name: profile.backend,
capabilities: {
steering: true,
modelSelection: true,
reasoningEffort: true,
},
// Real impls probe binary-on-PATH / SDK import / credentials here.
available: Effect.succeed(true),
spawn: (task) => makeStubSession(profile, task),
};
}
function firstLine(text: string): string {
return (
text
.split("\n")
.find((line) => line.trim())
?.trim() ?? ""
);
}
function chunked(text: string, size: number): string[] {
const chunks: string[] = [];
for (let i = 0; i < text.length; i += size)
chunks.push(text.slice(i, i + size));
return chunks;
}
const makeStubSession = (
profile: StubProfile,
task: SpawnTask,
): Effect.Effect<SubagentSession, never, Scope.Scope> =>
Effect.gen(function* () {
const sessionId = `stub-${profile.backend}-${++sessionCounter}`;
const sessionFile = path.join(STUB_DIR, `${sessionId}.jsonl`);
const state = {
meta: {
backend: profile.backend,
modelLabel: task.model ?? profile.defaultModelLabel,
contextWindow: profile.contextWindow,
sessionFilePath: sessionFile,
nativeSessionId: sessionId,
} satisfies SubagentMeta as SubagentMeta,
pending: [] as string[],
turnCount: 0,
closed: false,
/** True between the driver dequeuing a prompt and registering its turn fiber. */
dispatching: false,
};
const events = yield* Queue.make<SubagentEvent, Cause.Done>();
const inbox = yield* Queue.make<string, Cause.Done>();
const activeTurn = yield* Ref.make<Fiber.Fiber<void> | undefined>(
undefined,
);
const emit = (event: SubagentEvent) =>
Effect.suspend(() => {
try {
fs.appendFileSync(sessionFile, `${JSON.stringify(event)}\n`);
} catch {
// The fake session file is best-effort.
}
if (event._tag === "MetaChanged") {
state.meta = { ...state.meta, ...event.meta };
}
return Queue.offer(events, event);
}).pipe(Effect.asVoid);
const pause = Effect.sleep(Duration.millis(profile.cadenceMs));
const runTurn = (userText: string, turn: number) =>
Effect.gen(function* () {
yield* emit({ _tag: "RunStarted" });
const failing = userText.trimStart().startsWith("FAIL:");
const thinking = "Looking at the task and planning an approach...";
for (const delta of chunked(thinking, 16)) {
yield* emit({ _tag: "AssistantDelta", kind: "thinking", delta });
yield* pause;
}
const toolId = `${sessionId}-tool-${turn}`;
const argsPreview = `{"command":"ls ${task.cwd}"}`;
yield* emit({
_tag: "AssistantMessage",
parts: [
{ type: "thinking", text: thinking },
{
type: "text",
text: `I'll run ${profile.toolName} to look around first.`,
},
{ type: "toolCall", toolId, name: profile.toolName, argsPreview },
],
});
yield* emit({
_tag: "ToolStart",
toolId,
name: profile.toolName,
argsPreview,
});
yield* pause;
yield* emit({
_tag: "ToolUpdate",
toolId,
outputPreview: "src docs package.json",
});
yield* pause;
yield* emit({
_tag: "ToolEnd",
toolId,
name: profile.toolName,
isError: false,
outputPreview: "src docs package.json",
});
yield* emit({
_tag: "UsageChanged",
tokens: Math.min(profile.contextWindow, 2400 * (turn + 1)),
contextWindow: profile.contextWindow,
});
if (failing) {
yield* pause;
yield* emit({
_tag: "RunSettled",
outcome: {
_tag: "Failed",
errorText: `[stub:${profile.backend}] task failed as requested by FAIL: prefix`,
},
});
return;
}
const finalText =
`[stub:${profile.backend}] completed: ${firstLine(userText).slice(0, 200)}\n\n` +
`This is a stubbed ${profile.backend} subagent turn ${turn + 1}. ` +
`The real backend integration will replace this scripted output.`;
for (const delta of chunked(finalText, 24)) {
yield* emit({ _tag: "AssistantDelta", kind: "text", delta });
yield* pause;
}
yield* emit({
_tag: "AssistantMessage",
parts: [{ type: "text", text: finalText }],
});
yield* emit({
_tag: "UsageChanged",
tokens: Math.min(profile.contextWindow, 2400 * (turn + 1) + 900),
contextWindow: profile.contextWindow,
});
yield* emit({
_tag: "RunSettled",
outcome: { _tag: "Completed", finalText },
});
});
const queuedView = (): ReadonlyArray<QueuedMessage> =>
state.pending.map((text) => ({ text, kind: "steer" as const }));
// Driver: one turn at a time, in submission order. Turns run as child
// fibers so interrupt() stops the turn without killing the driver.
const driver = Effect.gen(function* () {
while (true) {
const text = yield* Queue.take(inbox);
state.dispatching = true;
state.pending.shift();
yield* emit({ _tag: "QueueChanged", queued: queuedView() });
yield* emit({ _tag: "UserMessage", text });
const turn = state.turnCount++;
const fiber = yield* Effect.forkChild(
runTurn(text, turn).pipe(
Effect.onInterrupt(() =>
emit({
_tag: "RunSettled",
outcome: { _tag: "Interrupted" },
}).pipe(Effect.ignore),
),
),
);
yield* Ref.set(activeTurn, fiber);
state.dispatching = false;
yield* Fiber.await(fiber);
yield* Ref.set(activeTurn, undefined);
}
});
yield* Effect.forkScoped(driver.pipe(Effect.ignore));
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
state.closed = true;
yield* Queue.end(inbox).pipe(Effect.ignore);
yield* Queue.end(events).pipe(Effect.ignore);
}),
);
const submit = (text: string) =>
Effect.gen(function* () {
if (state.closed) {
return yield* new SendError({
message: "Subagent session is closed.",
});
}
state.pending.push(text);
const busy = (yield* Ref.get(activeTurn)) !== undefined;
if (busy) {
// Show the queued steer line until the driver picks it up.
yield* emit({ _tag: "QueueChanged", queued: queuedView() });
}
yield* Queue.offer(inbox, text);
});
// Announce metadata, then kick off the initial run.
yield* Effect.try(() => fs.mkdirSync(STUB_DIR, { recursive: true })).pipe(
Effect.ignore, // The fake session file directory is best-effort.
);
yield* emit({ _tag: "MetaChanged", meta: state.meta });
// The session cannot be closed yet, so the initial submit cannot fail.
yield* submit(task.prompt).pipe(Effect.orDie);
return {
meta: Effect.sync(() => state.meta),
events: Stream.fromQueue(events),
send: submit,
interrupt: Effect.gen(function* () {
// Drop queued prompts so interrupting cannot immediately start
// another turn, then stop the active turn. A prompt may be mid-flight
// between the driver dequeuing it and registering its fiber, so wait
// that window out instead of silently missing the turn.
const cleared = yield* Queue.clear(inbox).pipe(
Effect.orElseSucceed(() => []),
);
state.pending = [];
yield* emit({ _tag: "QueueChanged", queued: [] });
while (true) {
const fiber = yield* Ref.get(activeTurn);
if (fiber) {
yield* Fiber.interrupt(fiber);
return;
}
if (!state.dispatching) {
// No turn ever started. If we cancelled queued prompts, the run
// still needs a terminal event or it would look running forever.
if (cleared.length > 0) {
yield* emit({
_tag: "RunSettled",
outcome: { _tag: "Interrupted" },
});
}
return;
}
yield* Effect.sleep(Duration.millis(5));
}
}),
} satisfies SubagentSession;
});
+21
View File
@@ -0,0 +1,21 @@
import type { SubagentOrigin } from "./domain.ts";
export const BTW_TITLE_MAX_LENGTH = 60;
/** Build a compact dashboard title from the first non-empty prompt line. */
export function deriveBtwTitle(prompt: string) {
const firstLine = prompt
.split("\n")
.find((line) => line.trim())
?.trim();
const title = firstLine?.replace(/\s+/g, " ") ?? "";
if (!title) return "by the way";
const codePoints = Array.from(title);
if (codePoints.length <= BTW_TITLE_MAX_LENGTH) return title;
return `${codePoints.slice(0, BTW_TITLE_MAX_LENGTH - 1).join("")}…`;
}
/** User asides remain visible in the dashboard but hidden from model tools. */
export function isModelVisible(snap: { readonly origin: SubagentOrigin }) {
return snap.origin === "model";
}
+253
View File
@@ -0,0 +1,253 @@
/**
* Domain model for subagents.
*
* Everything downstream of a backend (manager, tools, UI) speaks only these
* types. Backends translate their native streams (pi session events, Claude
* Agent SDK messages, Codex app-server JSON-RPC notifications) into the
* normalized `SubagentEvent` union.
*/
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
import { Data } from "effect";
export const BACKEND_NAMES = ["pi", "claude", "codex"] as const;
export type BackendName = (typeof BACKEND_NAMES)[number];
/** Who initiated the session. User asides stay out of model-facing tooling. */
export type SubagentOrigin = "model" | "btw";
/**
* Shared reasoning-effort scale (pi's thinking levels). Each backend maps a
* value to its nearest native equivalent: pi uses it directly, codex
* translates to its reasoning-effort slugs, claude translates to thinking
* budgets. Omitted = backend default (pi inherits the parent level).
*/
export const REASONING_EFFORTS = [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
] as const;
export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
export type SubagentStatus = "running" | "done" | "error";
/** Parent-session context resolved by the tool layer and passed opaquely. */
export interface ParentContext {
readonly parentCwd: string;
readonly projectTrusted: boolean;
/** Parent pi model, for the pi backend's "inherit" default. */
readonly inheritedModel?: { readonly provider: string; readonly id: string };
readonly inheritedThinkingLevel?: string;
/** Parent model registry; required by the pi backend to resolve models. */
readonly modelRegistry?: ModelRegistry;
}
export interface SpawnTask {
/** Omitted for normal tool-driven spawns. */
readonly origin?: SubagentOrigin;
readonly prompt: string;
readonly title: string;
readonly cwd: string;
/**
* Generic model hint, interpreted per backend:
* pi: "provider/model-id" or bare model id; claude: model alias;
* codex: model slug. Omitted = backend default / inherit.
*/
readonly model?: string;
/** Shared effort scale; each backend maps it to its native equivalent. */
readonly reasoningEffort?: ReasoningEffort;
readonly parent: ParentContext;
}
export interface SubagentMeta {
readonly backend: BackendName;
/** Display label, e.g. "anthropic/claude-opus-4-5" or "gpt-5-codex". */
readonly modelLabel?: string;
/** Context window capacity for utilization display, when known. */
readonly contextWindow?: number;
/** pi session file / Claude projects JSONL / Codex rollout path. */
readonly sessionFilePath?: string;
/** Claude session id / Codex conversation id. */
readonly nativeSessionId?: string;
}
// --- Transcript ------------------------------------------------------------
export type TranscriptPart =
| { readonly type: "text"; readonly text: string }
| {
readonly type: "thinking";
readonly text: string;
readonly redacted?: boolean;
}
| {
readonly type: "toolCall";
readonly toolId: string;
readonly name: string;
readonly argsPreview?: string;
};
export type TranscriptItem =
| { readonly kind: "user"; readonly text: string }
| {
readonly kind: "assistant";
readonly parts: ReadonlyArray<TranscriptPart>;
}
| {
readonly kind: "toolResult";
readonly toolId: string;
readonly name: string;
readonly isError: boolean;
readonly outputPreview?: string;
};
export interface LiveToolState {
readonly toolId: string;
readonly name: string;
readonly argsPreview?: string;
readonly outputPreview?: string;
readonly done?: boolean;
readonly isError?: boolean;
}
export interface QueuedMessage {
readonly text: string;
readonly kind: "steer" | "follow-up";
}
// --- Events ------------------------------------------------------------------
export type RunOutcome =
| { readonly _tag: "Completed"; readonly finalText: string }
| {
readonly _tag: "Failed";
readonly errorText: string;
readonly partialText?: string;
}
| { readonly _tag: "Interrupted"; readonly partialText?: string };
/**
* Normalized activity stream. Previews (`argsPreview`, `outputPreview`) are
* pre-flattened single-line strings because the UI only ever renders one
* sanitized line, which keeps three different native tool-result shapes out
* of the interface.
*/
export type SubagentEvent =
// lifecycle (a session can run multiple turns via send())
| { readonly _tag: "RunStarted" }
| { readonly _tag: "RunSettled"; readonly outcome: RunOutcome }
// transcript building blocks
| { readonly _tag: "UserMessage"; readonly text: string }
| {
readonly _tag: "AssistantDelta";
readonly kind: "text" | "thinking";
readonly delta: string;
}
| {
readonly _tag: "AssistantMessage";
readonly parts: ReadonlyArray<TranscriptPart>;
}
| {
readonly _tag: "ToolStart";
readonly toolId: string;
readonly name: string;
readonly argsPreview?: string;
}
| {
readonly _tag: "ToolUpdate";
readonly toolId: string;
readonly outputPreview?: string;
}
| {
readonly _tag: "ToolEnd";
readonly toolId: string;
readonly name: string;
readonly isError: boolean;
readonly outputPreview?: string;
}
// bookkeeping
| {
readonly _tag: "QueueChanged";
readonly queued: ReadonlyArray<QueuedMessage>;
}
| {
readonly _tag: "UsageChanged";
readonly tokens?: number;
readonly contextWindow?: number;
}
| { readonly _tag: "MetaChanged"; readonly meta: Partial<SubagentMeta> }
/** Non-fatal diagnostics. Fatal failures arrive as a RunSettled outcome. */
| { readonly _tag: "BackendError"; readonly message: string };
// --- Snapshot ---------------------------------------------------------------
/**
* The manager folds `SubagentEvent`s into one snapshot per subagent. This is
* everything the tools, footer status, and both TUI views read.
*/
export interface SubagentSnapshot {
readonly id: string;
readonly origin: SubagentOrigin;
readonly backend: BackendName;
readonly title: string;
readonly prompt: string;
readonly cwd: string;
readonly status: SubagentStatus;
readonly createdAt: number;
readonly settledAt?: number;
readonly errorText?: string;
readonly meta: SubagentMeta;
readonly usage: { readonly tokens?: number; readonly contextWindow?: number };
readonly transcript: ReadonlyArray<TranscriptItem>;
/** Streaming assistant buffers, cleared when the finalized message lands. */
readonly liveAssistant?: { readonly text: string; readonly thinking: string };
readonly liveTools: ReadonlyArray<LiveToolState>;
readonly queued: ReadonlyArray<QueuedMessage>;
/** Final text of the most recent completed run (v1 `finalOutput`). */
readonly finalText: string;
/** Count of finalized assistant messages (for subagent_check). */
readonly turns: number;
}
/** Final text, or the live streaming buffer while a run is active (v1 `latestOutput`). */
export function latestText(snap: SubagentSnapshot) {
const live = snap.liveAssistant?.text.trim();
if (live) return live;
return snap.finalText;
}
export function formatElapsed(snap: SubagentSnapshot) {
const end = snap.settledAt ?? Date.now();
const totalSeconds = Math.max(0, Math.round((end - snap.createdAt) / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0
? `${minutes}m${seconds.toString().padStart(2, "0")}s`
: `${seconds}s`;
}
// --- Errors -------------------------------------------------------------------
export class SpawnError extends Data.TaggedError("SpawnError")<{
readonly message: string;
}> {}
export class BackendUnavailableError extends Data.TaggedError(
"BackendUnavailableError",
)<{
readonly message: string;
}> {}
export class ConcurrencyLimitError extends Data.TaggedError(
"ConcurrencyLimitError",
)<{
readonly message: string;
}> {}
export class SendError extends Data.TaggedError("SendError")<{
readonly message: string;
}> {}
+74
View File
@@ -0,0 +1,74 @@
/**
* Formatting helpers (self-contained copies of the v1 shared helpers:
* context-utilization + activity-status).
*/
import type { Theme } from "@earendil-works/pi-coding-agent";
export interface ContextUtilization {
/** Current conversation context occupancy; undefined while unknown. */
tokens?: number | null;
/** Capacity of the model currently serving the conversation. */
contextWindow?: number | null;
}
function usableTokens(value: number | null | undefined) {
return typeof value === "number" && Number.isFinite(value) && value >= 0
? value
: undefined;
}
function usableCapacity(value: number | null | undefined) {
return typeof value === "number" && Number.isFinite(value) && value > 0
? value
: undefined;
}
export function contextPercent(usage: ContextUtilization) {
const tokens = usableTokens(usage.tokens);
const capacity = usableCapacity(usage.contextWindow);
if (tokens === undefined || capacity === undefined) return undefined;
return Math.round(Math.min(100, Math.max(0, (tokens / capacity) * 100)));
}
export function formatCompactTokens(count: number) {
if (count < 1000) return Math.round(count).toString();
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
if (count < 1000000) return `${Math.round(count / 1000)}k`;
return `${(count / 1000000).toFixed(1)}M`;
}
/**
* Render `%/capacity`. If occupancy is unknown, retain the useful capacity
* as `?%/capacity`; with no valid capacity, omit the statistic entirely.
*/
export function formatContextUtilization(usage: ContextUtilization) {
const capacity = usableCapacity(usage.contextWindow);
if (capacity === undefined) return "";
const percent = contextPercent(usage);
return `${percent === undefined ? "?" : percent}%/${formatCompactTokens(capacity)}`;
}
interface ActivityCounts {
running: number;
done: number;
failed: number;
}
const SQUARE = "■";
export function formatActivityStatus(theme: Theme, counts: ActivityCounts) {
const parts: string[] = [];
if (counts.running > 0) {
parts.push(theme.fg("warning", `${SQUARE} ${counts.running} running`));
}
if (counts.done > 0) {
parts.push(theme.fg("success", `${SQUARE} ${counts.done} done`));
}
if (counts.failed > 0) {
parts.push(theme.fg("error", `${SQUARE} ${counts.failed} failed`));
}
parts.push(theme.fg("accent", "/subagents") + theme.fg("dim", " to view"));
return `${theme.fg("muted", "subagents:")} ${parts.join(theme.fg("dim", " · "))}`;
}
+736
View File
@@ -0,0 +1,736 @@
/**
* SubagentManager — owns the registry of running/finished subagents.
*
* Each subagent is a scoped `SubagentSession` from a `SubagentBackend` plus a
* pump fiber that folds its normalized event stream into a mutable
* `SubagentSnapshot`. Closing a subagent's scope kills the underlying
* session/process and stops the pump.
*
* The manager also exposes a synchronous `SubagentReadModel` so the
* imperative TUI components (which render synchronously) can read snapshots
* and issue fire-and-forget commands without touching the Effect runtime.
*/
import {
Context,
Effect,
Exit,
Fiber,
Layer,
Result,
Scope,
Stream,
} from "effect";
import type { SubagentBackend, SubagentSession } from "./backend.ts";
import { BackendRegistry } from "./backend.ts";
import type {
BackendName,
LiveToolState,
RunOutcome,
SpawnTask,
SubagentEvent,
SubagentOrigin,
SubagentMeta,
SubagentSnapshot,
SubagentStatus,
TranscriptItem,
} from "./domain.ts";
import {
BackendUnavailableError,
ConcurrencyLimitError,
SendError,
SpawnError,
} from "./domain.ts";
export const MAX_RUNNING = 4;
export const MAX_TRACKED = 64;
const STOP_TIMEOUT_MS = 5_000;
const ERROR_TEXT_MAX_LENGTH = 4_096;
const TRANSCRIPT_TEXT_MAX_LENGTH = 64 * 1_024;
const LIVE_ASSISTANT_MAX_LENGTH = 128 * 1_024;
const FINAL_TEXT_MAX_LENGTH = 1_024 * 1_024;
const MAX_TRANSCRIPT_ITEMS = 512;
function bounded(text: string) {
return text.slice(0, ERROR_TEXT_MAX_LENGTH);
}
function boundedTranscriptText(text: string) {
return text.slice(0, TRANSCRIPT_TEXT_MAX_LENGTH);
}
function appendTranscript(snapshot: MutableSnapshot, item: TranscriptItem) {
snapshot.transcript.push(item);
if (snapshot.transcript.length > MAX_TRANSCRIPT_ITEMS) {
snapshot.transcript.splice(
0,
snapshot.transcript.length - MAX_TRANSCRIPT_ITEMS,
);
}
}
// --- Internal state -----------------------------------------------------------
/** Mutable snapshot; exposed to readers via the readonly SubagentSnapshot type. */
interface MutableSnapshot {
id: string;
origin: SubagentOrigin;
backend: BackendName;
title: string;
prompt: string;
cwd: string;
status: SubagentStatus;
createdAt: number;
settledAt?: number;
errorText?: string;
meta: SubagentMeta;
usage: { tokens?: number; contextWindow?: number };
transcript: TranscriptItem[];
liveAssistant?: { text: string; thinking: string };
liveTools: LiveToolState[];
queued: SubagentSnapshot["queued"];
finalText: string;
turns: number;
}
interface Entry {
snapshot: MutableSnapshot;
session: SubagentSession;
scope: Scope.Closeable;
pump?: Fiber.Fiber<void>;
liveToolMap: Map<string, LiveToolState>;
/** Idle restart dispatched but RunStarted not folded yet; counts as running
* so concurrent restarts cannot race past the cap. */
restarting?: boolean;
}
// --- Read model ----------------------------------------------------------------
/** Synchronous bridge for the TUI. Snapshots are live objects; do not mutate. */
export interface SubagentReadModel {
list(): ReadonlyArray<SubagentSnapshot>;
get(id: string): SubagentSnapshot | undefined;
size(): number;
/** Any-change notification (footer status, dashboard). */
subscribe(listener: () => void): () => void;
/** Per-subagent notification (takeover view). */
subscribeTo(id: string, listener: () => void): () => void;
/** Fire-and-forget: steer/continue a subagent (takeover input). */
requestSend(id: string, text: string): void;
/** Fire-and-forget: abort a running subagent (dashboard `x`, takeover). */
requestAbort(id: string): void;
/**
* Register the settle hook. `consumed` is true when an active
* subagent_wait/cancel is collecting the result (so it must not also be
* delivered as a follow-up message).
*/
setOnSettled(
hook: ((snap: SubagentSnapshot, consumed: boolean) => void) | undefined,
): void;
}
// --- Service --------------------------------------------------------------------
export interface CancelResult {
readonly id: string;
readonly title: string;
readonly status: SubagentStatus;
readonly cancelled: boolean;
}
export interface SubagentManagerShape {
spawn(
backend: BackendName,
task: SpawnTask,
): Effect.Effect<
SubagentSnapshot,
SpawnError | ConcurrencyLimitError | BackendUnavailableError
>;
/**
* Wait until all listed subagents are settled. Unknown ids are treated as
* settled (the tool layer validates ids first). While waiting, settles for
* these ids are marked "consumed". Interruption (tool abort) releases the
* interest and leaves the subagents running.
*/
waitFor(
ids: ReadonlyArray<string>,
onPending?: (pending: string[]) => void,
): Effect.Effect<void>;
/** Cancel running subagents; resolves when they have settled. */
cancel(
ids: ReadonlyArray<string>,
): Effect.Effect<ReadonlyArray<CancelResult>>;
send(id: string, text: string): Effect.Effect<void, SendError>;
get(id: string): Effect.Effect<SubagentSnapshot | undefined>;
readonly list: Effect.Effect<ReadonlyArray<SubagentSnapshot>>;
readonly disposeAll: Effect.Effect<void>;
readonly view: SubagentReadModel;
}
export class SubagentManager extends Context.Service<
SubagentManager,
SubagentManagerShape
>()("subagents/SubagentManager") {}
// --- Implementation --------------------------------------------------------------
const makeManager = Effect.gen(function* () {
const registry = yield* BackendRegistry;
// Detached forker for sync contexts (read-model commands, pruning) that
// preserves the manager's services instead of using the global runtime.
const runDetached = Effect.runForkWith(yield* Effect.context());
const entries = new Map<string, Entry>();
const waitInterest = new Map<string, number>();
const listeners = new Set<() => void>();
/** One-shot nextChange waiters, swapped out before invocation so waiters
* re-registering during notification are not visited in the same sweep. */
let changeWaiters: Array<() => void> = [];
const idListeners = new Map<string, Set<() => void>>();
const cleanups = new Set<Fiber.Fiber<unknown>>();
let modelCounter = 0;
let btwCounter = 0;
let reserved = 0;
let disposed = false;
let onSettled:
((snap: SubagentSnapshot, consumed: boolean) => void) | undefined;
const notify = (id?: string) => {
const waiters = changeWaiters;
changeWaiters = [];
for (const waiter of waiters) waiter();
for (const listener of [...listeners]) {
try {
listener();
} catch {
// A failed status/render listener must not corrupt lifecycle state.
}
}
if (id) {
for (const listener of idListeners.get(id) ?? []) {
try {
listener();
} catch {
// Same.
}
}
}
};
/** Resolves on the next state change. Interruption unregisters the waiter. */
const nextChange = Effect.callback<void>((resume) => {
const waiter = () => resume(Effect.void);
changeWaiters.push(waiter);
return Effect.sync(() => {
const index = changeWaiters.indexOf(waiter);
if (index >= 0) changeWaiters.splice(index, 1);
});
});
const runningCount = () =>
[...entries.values()].filter(
(e) => e.snapshot.status === "running" || e.restarting === true,
).length;
const addInterest = (ids: ReadonlyArray<string>) => {
for (const id of ids) waitInterest.set(id, (waitInterest.get(id) ?? 0) + 1);
};
const releaseInterest = (ids: ReadonlyArray<string>) => {
for (const id of ids) {
const count = (waitInterest.get(id) ?? 1) - 1;
if (count <= 0) waitInterest.delete(id);
else waitInterest.set(id, count);
}
};
const closeEntryScope = (entry: Entry) =>
Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
const pruneSettled = () => {
if (entries.size <= MAX_TRACKED) return;
const candidates = [...entries.values()]
.filter(
(e) =>
e.snapshot.status !== "running" && !waitInterest.has(e.snapshot.id),
)
.sort(
(a, b) =>
(a.snapshot.settledAt ?? a.snapshot.createdAt) -
(b.snapshot.settledAt ?? b.snapshot.createdAt),
);
for (const entry of candidates) {
if (entries.size <= MAX_TRACKED) break;
entries.delete(entry.snapshot.id);
const fiber = runDetached(closeEntryScope(entry));
cleanups.add(fiber);
fiber.addObserver(() => cleanups.delete(fiber));
}
};
const settle = (entry: Entry, outcome: RunOutcome) => {
const s = entry.snapshot;
entry.restarting = false;
if (s.status !== "running") return;
s.settledAt = Date.now();
switch (outcome._tag) {
case "Completed":
s.status = "done";
s.errorText = undefined;
s.finalText = outcome.finalText.slice(0, FINAL_TEXT_MAX_LENGTH);
break;
case "Failed":
s.status = "error";
s.errorText = bounded(outcome.errorText);
// Never let a failed run report the previous run's successful output.
s.finalText = (outcome.partialText ?? "").slice(
0,
FINAL_TEXT_MAX_LENGTH,
);
break;
case "Interrupted":
s.status = "error";
s.errorText = "Run was aborted";
s.finalText = (outcome.partialText ?? "").slice(
0,
FINAL_TEXT_MAX_LENGTH,
);
break;
}
s.liveAssistant = undefined;
entry.liveToolMap.clear();
s.liveTools = [];
s.queued = [];
const consumed = (waitInterest.get(s.id) ?? 0) > 0;
notify(s.id);
try {
// During teardown, don't queue results into a shutting-down session.
if (!disposed) onSettled?.(s, consumed);
} catch {
// The parent session may be unavailable; settlement stays final.
}
pruneSettled();
};
const foldEvent = (entry: Entry, event: SubagentEvent) => {
const s = entry.snapshot;
switch (event._tag) {
case "RunStarted":
entry.restarting = false;
s.status = "running";
s.settledAt = undefined;
s.errorText = undefined;
break;
case "RunSettled":
settle(entry, event.outcome);
return; // settle() already notified
case "UserMessage":
appendTranscript(s, {
kind: "user",
text: boundedTranscriptText(event.text),
});
break;
case "AssistantDelta": {
const live = s.liveAssistant ?? { text: "", thinking: "" };
s.liveAssistant =
event.kind === "text"
? {
...live,
text: (live.text + event.delta).slice(
-LIVE_ASSISTANT_MAX_LENGTH,
),
}
: {
...live,
thinking: (live.thinking + event.delta).slice(
-LIVE_ASSISTANT_MAX_LENGTH,
),
};
break;
}
case "AssistantMessage":
appendTranscript(s, {
kind: "assistant",
parts: event.parts.map((part) =>
part.type === "toolCall"
? {
...part,
argsPreview: part.argsPreview
? boundedTranscriptText(part.argsPreview)
: undefined,
}
: { ...part, text: boundedTranscriptText(part.text) },
),
});
s.liveAssistant = undefined;
s.turns++;
break;
case "ToolStart":
entry.liveToolMap.set(event.toolId, {
toolId: event.toolId,
name: event.name,
argsPreview: event.argsPreview
? boundedTranscriptText(event.argsPreview)
: undefined,
});
s.liveTools = [...entry.liveToolMap.values()];
break;
case "ToolUpdate": {
const current = entry.liveToolMap.get(event.toolId);
if (current) {
entry.liveToolMap.set(event.toolId, {
...current,
outputPreview: event.outputPreview
? boundedTranscriptText(event.outputPreview)
: current.outputPreview,
});
s.liveTools = [...entry.liveToolMap.values()];
}
break;
}
case "ToolEnd":
entry.liveToolMap.delete(event.toolId);
s.liveTools = [...entry.liveToolMap.values()];
appendTranscript(s, {
kind: "toolResult",
toolId: event.toolId,
name: event.name,
isError: event.isError,
outputPreview: event.outputPreview
? boundedTranscriptText(event.outputPreview)
: undefined,
});
break;
case "QueueChanged":
s.queued = event.queued;
break;
case "UsageChanged":
s.usage = {
tokens: event.tokens ?? s.usage.tokens,
contextWindow: event.contextWindow ?? s.usage.contextWindow,
};
break;
case "MetaChanged":
s.meta = { ...s.meta, ...event.meta };
break;
case "BackendError":
s.errorText = bounded(event.message);
break;
}
notify(s.id);
};
const spawn = (backendName: BackendName, task: SpawnTask) =>
Effect.gen(function* () {
// Reserve synchronously (before the first yield inside doSpawn) so
// parallel tool calls cannot race past the global cap.
yield* Effect.suspend(
(): Effect.Effect<void, SpawnError | ConcurrencyLimitError> => {
if (disposed) {
return new SpawnError({
message: "Subagent manager is shutting down.",
});
}
if (runningCount() + reserved >= MAX_RUNNING) {
return new ConcurrencyLimitError({
message: `Max ${MAX_RUNNING} subagents can run concurrently. Wait for one to finish before spawning another.`,
});
}
reserved++;
return Effect.void;
},
);
const doSpawn = Effect.gen(function* () {
const backend: SubagentBackend | undefined = registry.get(backendName);
if (!backend) {
return yield* new BackendUnavailableError({
message: `Unknown backend "${backendName}".`,
});
}
const available = yield* backend.available;
if (!available) {
return yield* new BackendUnavailableError({
message: `Backend "${backendName}" is not available on this machine (binary/SDK/credentials missing).`,
});
}
const scope = yield* Scope.make();
const session = yield* Scope.provide(backend.spawn(task), scope).pipe(
Effect.onError(() => Scope.close(scope, Exit.void)),
);
if (disposed) {
yield* Scope.close(scope, Exit.void);
return yield* new SpawnError({
message: "Subagent manager shut down while spawning.",
});
}
const origin = task.origin ?? "model";
const id =
origin === "btw" ? `btw-${++btwCounter}` : `sa-${++modelCounter}`;
const meta = yield* session.meta;
const entry: Entry = {
snapshot: {
id,
origin,
backend: backendName,
title: task.title,
prompt: task.prompt,
cwd: task.cwd,
status: "running",
createdAt: Date.now(),
meta,
usage: { contextWindow: meta.contextWindow },
transcript: [],
liveTools: [],
queued: [],
finalText: "",
turns: 0,
},
session,
scope,
liveToolMap: new Map(),
};
entries.set(id, entry);
// Pump: fold the event stream into the snapshot. Tied to the entry
// scope, so closing the scope stops it. If the stream ends while the
// subagent still looks running, the backend died out from under us.
const pump = Stream.runForEach(session.events, (event) =>
Effect.sync(() => foldEvent(entry, event)),
).pipe(
Effect.ensuring(
Effect.sync(() => {
if (entry.snapshot.status === "running") {
settle(entry, {
_tag: "Failed",
errorText: "Backend event stream ended unexpectedly",
});
}
}),
),
);
entry.pump = yield* Scope.provide(Effect.forkScoped(pump), scope);
notify(id);
return entry.snapshot as SubagentSnapshot;
});
return yield* doSpawn.pipe(
Effect.ensuring(
Effect.sync(() => {
reserved--;
notify();
}),
),
);
});
const waitFor = (
ids: ReadonlyArray<string>,
onPending?: (pending: string[]) => void,
) =>
Effect.suspend(() => {
const unique = [...new Set(ids)];
addInterest(unique);
const loop = Effect.gen(function* () {
while (true) {
const pending = unique.filter(
(id) => entries.get(id)?.snapshot.status === "running",
);
if (pending.length === 0) return;
onPending?.(pending);
yield* nextChange;
}
});
return loop.pipe(
Effect.ensuring(
Effect.sync(() => {
releaseInterest(unique);
pruneSettled();
}),
),
);
});
/** Interrupt one running entry, force-closing its scope after 5s. */
const abortEntry = (entry: Entry) =>
Effect.gen(function* () {
if (entry.snapshot.status !== "running") return;
const graceful = yield* entry.session.interrupt.pipe(
Effect.timeout(STOP_TIMEOUT_MS),
Effect.result,
);
if (Result.isFailure(graceful)) {
// Settle before closing the scope so the pump's stream-ended
// fallback ("Backend event stream ended unexpectedly") cannot win
// the race and report the wrong terminal reason.
yield* Effect.sync(() => {
settle(entry, { _tag: "Interrupted" });
entry.snapshot.errorText =
"Abort deadline exceeded; session was force-disposed";
notify(entry.snapshot.id);
});
// Bound the close like disposeAll does: a stuck backend finalizer
// must not hang cancel after the run is already settled.
yield* closeEntryScope(entry).pipe(
Effect.timeout(STOP_TIMEOUT_MS),
Effect.ignore,
);
}
});
const cancel = (ids: ReadonlyArray<string>) =>
Effect.suspend(() => {
const unique = [...new Set(ids)];
const running = unique
.map((id) => entries.get(id))
.filter(
(entry): entry is Entry => entry?.snapshot.status === "running",
);
const runningIds = running.map((entry) => entry.snapshot.id);
// Mark consumed before interrupting so cancellation does not also
// enqueue duplicate automatic result messages into the parent.
addInterest(runningIds);
const work = Effect.gen(function* () {
yield* Effect.forEach(running, abortEntry, {
concurrency: "unbounded",
});
while (running.some((entry) => entry.snapshot.status === "running")) {
yield* nextChange;
}
});
return work.pipe(
Effect.ensuring(
Effect.sync(() => {
releaseInterest(runningIds);
pruneSettled();
}),
),
Effect.map((): ReadonlyArray<CancelResult> =>
unique.map((id) => {
const snapshot = entries.get(id)?.snapshot;
return {
id,
title: snapshot?.title ?? "?",
status: snapshot?.status ?? "error",
cancelled: runningIds.includes(id),
};
}),
),
);
});
const send = (id: string, text: string) =>
Effect.suspend((): Effect.Effect<void, SendError> => {
const entry = entries.get(id);
if (!entry || disposed) {
return new SendError({
message: `Subagent "${id}" is no longer tracked.`,
});
}
// Restarting a settled subagent occupies a running slot again, so it
// must respect the same cap as spawn. Steering an already-running one
// does not consume additional capacity.
if (entry.snapshot.status !== "running") {
if (runningCount() + reserved >= MAX_RUNNING) {
return new SendError({
message: `Max ${MAX_RUNNING} subagents can run concurrently; restarting "${id}" would exceed that.`,
});
}
// Occupy the slot synchronously: the RunStarted that flips status
// arrives via the async pump, and two concurrent restarts must not
// both pass the check in that window. Cleared by RunStarted/settle,
// or here when the backend rejects the send.
entry.restarting = true;
return entry.session.send(text).pipe(
Effect.onError(() =>
Effect.sync(() => {
entry.restarting = false;
}),
),
);
}
return entry.session.send(text);
});
const disposeAll = Effect.gen(function* () {
disposed = true;
const all = [...entries.values()];
entries.clear();
yield* Effect.forEach(
all,
(entry) =>
closeEntryScope(entry).pipe(
Effect.timeout(STOP_TIMEOUT_MS),
Effect.ignore,
),
{ concurrency: "unbounded" },
);
// Pruning cleanups are detached; bound them like everything else so a
// stuck backend finalizer cannot block runtime shutdown indefinitely.
yield* Effect.forEach(
[...cleanups],
(fiber) =>
Fiber.await(fiber).pipe(Effect.timeout(STOP_TIMEOUT_MS), Effect.ignore),
{ concurrency: "unbounded" },
).pipe(Effect.ignore);
yield* Effect.sync(() => notify());
});
const view: SubagentReadModel = {
list: () => [...entries.values()].map((entry) => entry.snapshot),
get: (id) => entries.get(id)?.snapshot,
size: () => entries.size,
subscribe: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
subscribeTo: (id, listener) => {
let set = idListeners.get(id);
if (!set) {
set = new Set();
idListeners.set(id, set);
}
set.add(listener);
return () => {
set.delete(listener);
if (set.size === 0) idListeners.delete(id);
};
},
requestSend: (id, text) => {
runDetached(send(id, text).pipe(Effect.ignore));
},
requestAbort: (id) => {
const entry = entries.get(id);
if (!entry) return;
// UI-initiated aborts are not "consumed": the failed result still
// flows back to the parent as a follow-up message, matching v1.
runDetached(abortEntry(entry).pipe(Effect.ignore));
},
setOnSettled: (hook) => {
onSettled = hook;
},
};
// Safety net: disposing the ManagedRuntime tears everything down even if
// the extension forgot to call disposeAll explicitly.
yield* Effect.addFinalizer(() => disposeAll);
return SubagentManager.of({
spawn,
waitFor,
cancel,
send,
get: (id) => Effect.sync(() => entries.get(id)?.snapshot),
list: Effect.sync(() => [...entries.values()].map((e) => e.snapshot)),
disposeAll,
view,
});
});
export const SubagentManagerLive: Layer.Layer<
SubagentManager,
never,
BackendRegistry
> = Layer.effect(SubagentManager, makeManager);
+92
View File
@@ -0,0 +1,92 @@
/** All model-facing strings for the subagents tools. */
/** Describes subagent_spawn, including harnesses and the fixed concurrency cap. */
export const SUBAGENT_SPAWN_TOOL_DESCRIPTION =
"Spawn a background subagent: a fully autonomous, headless agent with its own context window and the selected harness's normal host permissions. You choose the harness it runs on: pi (in-process pi session, inherits this environment's tools and config), claude (Claude Code), or codex (Codex CLI). Fire-and-forget: this returns immediately with an id. The subagent's final output is queued back to you as a message when it settles, or collect it explicitly with subagent_wait. Children cannot orchestrate more agents/workflows or ask the user, and cannot see this conversation, so the prompt must be self-contained. Only use trusted working directories. Max 4 subagents can be running at once across all harnesses.";
/** Adds background subagent delegation to the parent model's available-tools prompt. */
export const SUBAGENT_SPAWN_PROMPT_SNIPPET =
"Spawn a background subagent on a chosen harness (pi, Claude Code, or Codex; own context, normal tools) for a self-contained task";
/** Guides the parent model to delegate standalone tasks and avoid unnecessary blocking waits. */
export const SUBAGENT_SPAWN_PROMPT_GUIDELINES = [
"Use subagent_spawn to delegate self-contained tasks that can run in the background; give it a complete, standalone prompt.",
"Pick the subagent harness deliberately: pi unless you have a reason to prefer Claude Code or Codex (e.g. the user asked for one, or the task suits that harness).",
"After subagent_spawn, keep working; results arrive automatically. Only call subagent_wait when you cannot proceed without the result.",
];
/** Model-facing schema descriptions for subagent_spawn task and execution options. */
export const SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS = {
prompt:
"Task prompt for the subagent. Must be self-contained: include all needed context, file paths, and what to report back.",
name: "Short human-readable name for this subagent, shown in listings and the UI",
harness:
'Harness to run the subagent on: "pi" (in-process pi session; inherits this environment), "claude" (Claude Code), or "codex" (Codex CLI). Choose deliberately per task.',
workingDir:
"Trusted working directory for the autonomous child (default: current working directory)",
model:
'Model hint, interpreted by the chosen harness (pi: "provider/model-id" or model id; claude: model alias like "sonnet"/"opus"; codex: model slug). Omit for the harness default (pi inherits the current model).',
reasoningEffort:
"Reasoning effort on a shared scale; the harness maps it to its nearest native equivalent (pi thinking level, codex reasoning effort, claude thinking budget). Omit for the harness default (pi inherits the current level).",
};
/** Builds the subagent_spawn result that tells the parent model how to continue or inspect the child. */
export function buildSubagentSpawnResult(options: {
id: string;
title: string;
harness: string;
modelLabel: string;
cwd: string;
}) {
return (
`Spawned subagent ${options.id} "${options.title}" (${options.harness}: ${options.modelLabel}, ${options.cwd}).\n` +
`It runs in the background. Its result will be delivered to you when it finishes, ` +
`or use subagent_wait(ids: ["${options.id}"]) to block for it, subagent_cancel to stop it, subagent_check to peek, subagent_list to see all.`
);
}
/** Describes explicit blocking collection of one or more subagent results. */
export const SUBAGENT_WAIT_TOOL_DESCRIPTION =
"Block until all listed subagents have settled, then return their final outputs. Prefer letting results arrive automatically; use this only when you need a result before continuing.";
/** Model-facing schema description for the subagent ids to await. */
export const SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS = {
ids: 'Subagent ids to wait for, e.g. ["sa-1", "sa-2"]',
};
/** Describes aborting running subagents while retaining their partial transcripts. */
export const SUBAGENT_CANCEL_TOOL_DESCRIPTION =
"Cancel one or more running subagents. This aborts their active work but preserves their partial session transcripts on disk.";
/** Model-facing schema description for the subagent ids to cancel. */
export const SUBAGENT_CANCEL_PARAMETER_DESCRIPTIONS = {
ids: 'Subagent ids to cancel, e.g. ["sa-1", "sa-2"]',
};
/** Describes nonblocking inspection of a subagent without consuming its result. */
export const SUBAGENT_CHECK_TOOL_DESCRIPTION =
"Peek at a subagent's status and recent activity without blocking. Does not consume its result.";
/** Model-facing schema description for the subagent id to inspect. */
export const SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS = {
id: "Subagent id",
};
/** Describes listing all tracked running and settled subagents. */
export const SUBAGENT_LIST_TOOL_DESCRIPTION =
"List all subagents (running and finished) with their harness and status.";
/** Builds the child completion/failure wrapper injected into the parent model's context. */
export function buildSubagentResultMessage(options: {
id: string;
title: string;
status: "running" | "done" | "error";
errorText?: string;
output: string;
}) {
const verb = options.status === "error" ? "failed" : "finished";
let text = `Subagent ${options.id} "${options.title}" ${verb}.`;
if (options.errorText) text += `\nError: ${options.errorText}`;
text += `\n\n${options.output}`;
return text;
}
@@ -0,0 +1,20 @@
export function createDeferredResultDelivery<T extends { id: string }>() {
const pending = new Map<string, T>();
return {
defer(result: T) {
pending.set(result.id, result);
},
consume(ids: Iterable<string>) {
for (const id of ids) pending.delete(id);
},
drain() {
const results = [...pending.values()];
pending.clear();
return results;
},
clear() {
pending.clear();
},
};
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Layer composition and the async entry-point boundary.
*
* Everything inside the extension is Effect generators; this module is where
* tool handlers (plain async functions) run those effects against one shared
* ManagedRuntime.
*/
import { Cause, Exit, Layer, ManagedRuntime, type Effect } from "effect";
import { BackendRegistry, type SubagentBackend } from "./backend.ts";
import { claudeBackend } from "./backends/claude.ts";
import { codexBackend } from "./backends/codex.ts";
import { piBackend } from "./backends/pi.ts";
import type { BackendName } from "./domain.ts";
const BackendRegistryLive = Layer.sync(BackendRegistry, () => {
const backends: SubagentBackend[] = [piBackend, claudeBackend, codexBackend];
return new Map<BackendName, SubagentBackend>(
backends.map((backend) => [backend.name, backend]),
);
});
import { SubagentManagerLive } from "./manager.ts";
const AppLayer = SubagentManagerLive.pipe(Layer.provide(BackendRegistryLive));
export function createSubagentRuntime() {
return ManagedRuntime.make(AppLayer);
}
export type SubagentRuntime = ReturnType<typeof createSubagentRuntime>;
/**
* Run an effect from an async tool handler. Typed failures and defects are
* converted to thrown Errors (what pi's tool contract expects); interruption
* (tool AbortSignal) throws `interruptMessage`.
*/
export async function runTool<A, E>(
runtime: SubagentRuntime,
effect: Effect.Effect<A, E>,
options: { signal?: AbortSignal; interruptMessage?: string } = {},
) {
const exit = await runtime.runPromiseExit(
effect,
options.signal ? { signal: options.signal } : undefined,
);
if (Exit.isSuccess(exit)) return exit.value;
if (Cause.hasInterruptsOnly(exit.cause)) {
throw new Error(options.interruptMessage ?? "Operation was aborted.");
}
const [first] = Cause.prettyErrors(exit.cause);
throw new Error(first?.message ?? Cause.pretty(exit.cause));
}
+583
View File
@@ -0,0 +1,583 @@
/**
* Takeover UI for subagents (ported from v1, rendering from the synchronous
* SubagentReadModel instead of live pi sessions):
* - SubagentDashboard: full popup (overlay) listing all subagents.
* - TakeoverView: full interactive view of one subagent with an input line
* to steer/continue it.
*/
import type {
ExtensionCommandContext,
KeybindingsManager,
Theme,
} from "@earendil-works/pi-coding-agent";
import type { Component, Focusable, TUI } from "@earendil-works/pi-tui";
import { Input, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import { formatElapsed, type SubagentSnapshot } from "../domain.ts";
import { formatContextUtilization } from "../format.ts";
import type { SubagentReadModel } from "../manager.ts";
import { buildTranscriptLines } from "./transcript.ts";
function configuredKeys(
keybindings: KeybindingsManager,
binding: Parameters<KeybindingsManager["getKeys"]>[0],
) {
return keybindings.getKeys(binding).join("/") || "unbound";
}
function statusGlyph(snap: SubagentSnapshot, theme: Theme): string {
switch (snap.status) {
case "running":
return theme.fg("warning", "■");
case "done":
return theme.fg("success", "■");
case "error":
return theme.fg("error", "■");
}
}
function statusWord(snap: SubagentSnapshot, theme: Theme): string {
switch (snap.status) {
case "running":
return theme.fg("warning", "running");
case "done":
return theme.fg("success", "done");
case "error":
return theme.fg("error", "failed");
}
}
// --- Entry points --------------------------------------------------------------
export interface TakeoverOptions {
readonly badge?: string;
}
export async function openSubagentTakeover(
ctx: ExtensionCommandContext,
view: SubagentReadModel,
id: string,
options?: TakeoverOptions,
) {
if (!view.get(id)) return;
await ctx.ui.custom<null>(
(tui, theme, keybindings, done) =>
new TakeoverView(tui, theme, keybindings, id, view, done, options),
{
overlay: true,
overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
},
);
}
export async function openSubagentPicker(
ctx: ExtensionCommandContext,
view: SubagentReadModel,
) {
const selection: DashboardSelection = { index: 0 };
while (true) {
if (view.size() === 0) {
ctx.ui.notify("No subagents", "info");
return;
}
const picked = await ctx.ui.custom<string | null>(
(tui, theme, keybindings, done) =>
new SubagentDashboard(tui, theme, keybindings, view, selection, done),
{
overlay: true,
overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" },
},
);
if (!picked) return;
if (!view.get(picked)) continue;
await openSubagentTakeover(ctx, view, picked);
// After leaving the takeover view, fall back to the dashboard.
}
}
// --- Dashboard (fullscreen overlay) ----------------------------------------------
export interface DashboardSelection {
id?: string;
index: number;
}
export function reconcileDashboardSelection(
selection: DashboardSelection,
subs: ReadonlyArray<Pick<SubagentSnapshot, "id">>,
) {
const stableIndex = selection.id
? subs.findIndex((snap) => snap.id === selection.id)
: -1;
selection.index =
stableIndex >= 0
? stableIndex
: Math.min(Math.max(0, selection.index), Math.max(0, subs.length - 1));
selection.id = subs[selection.index]?.id;
}
class SubagentDashboard implements Component {
private tui: TUI;
private theme: Theme;
private keybindings: KeybindingsManager;
private view: SubagentReadModel;
private selection: DashboardSelection;
private done: (value: string | null) => void;
private closed = false;
private ticker: ReturnType<typeof setInterval>;
private unsubChange: () => void;
constructor(
tui: TUI,
theme: Theme,
keybindings: KeybindingsManager,
view: SubagentReadModel,
selection: DashboardSelection,
done: (value: string | null) => void,
) {
this.tui = tui;
this.theme = theme;
this.keybindings = keybindings;
this.view = view;
this.selection = selection;
this.done = done;
// Elapsed times, token counts, and statuses tick along at 1Hz.
this.ticker = setInterval(() => this.tui.requestRender(), 1000);
this.unsubChange = view.subscribe(() => this.tui.requestRender());
}
private subs(): ReadonlyArray<SubagentSnapshot> {
return this.view.list();
}
private cleanup() {
if (this.closed) return false;
this.closed = true;
clearInterval(this.ticker);
this.unsubChange();
return true;
}
private close(result: string | null) {
if (this.cleanup()) this.done(result);
}
dispose(): void {
this.cleanup();
}
handleInput(data: string): void {
const subs = this.subs();
reconcileDashboardSelection(this.selection, subs);
if (this.keybindings.matches(data, "tui.select.cancel")) {
this.close(null);
return;
}
if (this.keybindings.matches(data, "tui.select.confirm")) {
const snap = subs[this.selection.index];
if (snap) this.close(snap.id);
return;
}
if (this.keybindings.matches(data, "tui.select.up") || data === "k") {
if (subs.length > 0) {
this.selection.index =
(this.selection.index - 1 + subs.length) % subs.length;
this.selection.id = subs[this.selection.index]?.id;
this.tui.requestRender();
}
return;
}
if (this.keybindings.matches(data, "tui.select.down") || data === "j") {
if (subs.length > 0) {
this.selection.index = (this.selection.index + 1) % subs.length;
this.selection.id = subs[this.selection.index]?.id;
this.tui.requestRender();
}
return;
}
if (data === "x") {
const snap = subs[this.selection.index];
if (snap && snap.status === "running") this.view.requestAbort(snap.id);
return;
}
}
private pad(text: string, width: number): string {
const truncated = truncateToWidth(text, width);
return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated)));
}
private borderSegment(width: number, title: string): string {
const theme = this.theme;
const label = title
? ` ${truncateToWidth(title, Math.max(0, width - 3))} `
: "";
const labelWidth = visibleWidth(label);
return (
theme.fg("border", "─") +
(label ? theme.fg("text", label) : "") +
theme.fg("border", "─".repeat(Math.max(0, width - 1 - labelWidth)))
);
}
render(width: number): string[] {
const theme = this.theme;
const subs = this.subs();
reconcileDashboardSelection(this.selection, subs);
const rows = this.tui.terminal.rows || 30;
// Render exactly terminal rows - 1 so the overlay covers the header,
// chat, editor, and extra footer lines while leaving pi's final footer
// row visible.
const bodyHeight = Math.max(6, rows - 5);
const innerWidth = width - 2;
const lines: string[] = [];
// Header: title left, count right
const headerLeft = theme.fg("accent", theme.bold("Subagents"));
const headerRight = theme.fg(
"muted",
`${subs.length} agent${subs.length === 1 ? "" : "s"}`,
);
const headerPad = Math.max(
1,
width - visibleWidth(headerLeft) - visibleWidth(headerRight) - 4,
);
lines.push(
truncateToWidth(
` ${headerLeft}${" ".repeat(headerPad)}${headerRight} `,
width,
),
);
// Top border with panel title
const settled = subs.filter((s) => s.status !== "running").length;
lines.push(
theme.fg("border", "╭") +
this.borderSegment(innerWidth, `agents · ${settled}/${subs.length}`) +
theme.fg("border", "╮"),
);
// Rows
const divider = theme.fg("border", "│");
const rowLines = this.renderRows(subs, innerWidth, bodyHeight);
for (let i = 0; i < bodyHeight; i++) {
lines.push(divider + this.pad(rowLines[i] ?? "", innerWidth) + divider);
}
// Bottom border
lines.push(
theme.fg("border", "╰") +
theme.fg("border", "─".repeat(innerWidth)) +
theme.fg("border", "╯"),
);
// Hints
lines.push(
truncateToWidth(
theme.fg(
"dim",
` ${configuredKeys(this.keybindings, "tui.select.up")}/${configuredKeys(this.keybindings, "tui.select.down")}/jk select · ${configuredKeys(this.keybindings, "tui.select.confirm")} take over · x abort · ${configuredKeys(this.keybindings, "tui.select.cancel")} close`,
),
width,
),
);
return lines;
}
private renderRows(
subs: ReadonlyArray<SubagentSnapshot>,
width: number,
height: number,
): string[] {
const theme = this.theme;
const out: string[] = [];
// Scroll window around selection
let start = 0;
if (subs.length > height) {
start = Math.min(
Math.max(0, this.selection.index - Math.floor(height / 2)),
subs.length - height,
);
}
const visible = subs.slice(start, start + height);
for (let i = 0; i < visible.length; i++) {
const snap = visible[i];
const index = start + i;
const isSelected = index === this.selection.index;
// Left: marker, status square, title, dim id
const marker = isSelected ? theme.fg("accent", "❯") : " ";
const title = isSelected
? theme.fg("accent", snap.title)
: theme.fg("text", snap.title);
const left = ` ${marker} ${statusGlyph(snap, theme)} ${title} ${theme.fg("dim", snap.id)}`;
// Right: backend · model · context utilization · elapsed · status
const utilization = formatContextUtilization(snap.usage);
const dot = theme.fg("dim", " · ");
const rightParts = [
theme.fg("muted", snap.backend),
theme.fg("muted", snap.meta.modelLabel ?? "?"),
...(utilization ? [theme.fg("muted", utilization)] : []),
theme.fg("muted", formatElapsed(snap)),
statusWord(snap, theme),
];
const right = `${rightParts.join(dot)} `;
const rightWidth = visibleWidth(right);
const leftMax = Math.max(0, width - rightWidth - 2);
const leftTruncated = truncateToWidth(left, leftMax);
const gap = Math.max(2, width - visibleWidth(leftTruncated) - rightWidth);
out.push(truncateToWidth(leftTruncated + " ".repeat(gap) + right, width));
}
if (start > 0) {
out[0] = truncateToWidth(theme.fg("dim", ` ... ${start} more`), width);
}
if (start + height < subs.length) {
out[out.length - 1] = truncateToWidth(
theme.fg("dim", ` ... ${subs.length - start - height} more`),
width,
);
}
return out;
}
invalidate(): void {}
}
// --- Takeover view ------------------------------------------------------------
const TRANSCRIPT_SCROLL_STEP = 6;
class TakeoverView implements Component, Focusable {
private tui: TUI;
private theme: Theme;
private keybindings: KeybindingsManager;
private id: string;
private view: SubagentReadModel;
private done: (value: null) => void;
private options?: TakeoverOptions;
private input = new Input();
/** Scroll offset in lines from the bottom of the transcript. 0 = pinned to bottom. */
private scrollOffset = 0;
private unsubscribe: () => void;
private renderTimer?: ReturnType<typeof setTimeout>;
private ticker: ReturnType<typeof setInterval>;
private closed = false;
private _focused = false;
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.input.focused = value;
}
constructor(
tui: TUI,
theme: Theme,
keybindings: KeybindingsManager,
id: string,
view: SubagentReadModel,
done: (value: null) => void,
options?: TakeoverOptions,
) {
this.tui = tui;
this.theme = theme;
this.keybindings = keybindings;
this.id = id;
this.view = view;
this.done = done;
this.options = options;
this.unsubscribe = view.subscribeTo(id, () => this.scheduleRender());
// Elapsed time in the header ticks along at 1Hz.
this.ticker = setInterval(() => this.tui.requestRender(), 1000);
this.input.onSubmit = (value: string) => {
const text = value.trim();
if (!text) return;
this.input.setValue("");
this.view.requestSend(this.id, text);
this.scrollOffset = 0;
this.tui.requestRender();
};
}
private snap(): SubagentSnapshot | undefined {
return this.view.get(this.id);
}
private scheduleRender() {
if (this.renderTimer) return;
// Streaming can emit an event per token. Limit terminal repaints so this
// view cannot starve input handling or make the child look frozen.
this.renderTimer = setTimeout(() => {
this.renderTimer = undefined;
if (!this.closed) this.tui.requestRender();
}, 50);
}
private cleanup() {
if (this.closed) return false;
this.closed = true;
this.unsubscribe();
clearInterval(this.ticker);
if (this.renderTimer) clearTimeout(this.renderTimer);
this.renderTimer = undefined;
return true;
}
private close() {
if (this.cleanup()) this.done(null);
}
dispose(): void {
this.cleanup();
}
handleInput(data: string): void {
if (this.keybindings.matches(data, "app.clear")) {
const snap = this.snap();
if (snap?.status === "running") this.view.requestAbort(this.id);
return;
}
if (
this.keybindings.matches(data, "app.interrupt") ||
this.keybindings.matches(data, "tui.select.cancel")
) {
this.close();
return;
}
if (this.keybindings.matches(data, "tui.editor.cursorUp")) {
this.scrollOffset += TRANSCRIPT_SCROLL_STEP;
this.tui.requestRender();
return;
}
if (this.keybindings.matches(data, "tui.editor.cursorDown")) {
this.scrollOffset = Math.max(
0,
this.scrollOffset - TRANSCRIPT_SCROLL_STEP,
);
this.tui.requestRender();
return;
}
if (this.keybindings.matches(data, "tui.editor.pageUp")) {
this.scrollOffset += this.viewportHeight();
this.tui.requestRender();
return;
}
if (this.keybindings.matches(data, "tui.editor.pageDown")) {
this.scrollOffset = Math.max(
0,
this.scrollOffset - this.viewportHeight(),
);
this.tui.requestRender();
return;
}
this.input.handleInput(data);
this.tui.requestRender();
}
private viewportHeight(): number {
const rows = this.tui.terminal.rows || 30;
// The complete view renders viewport + 7 chrome rows. Using rows - 8
// makes the overlay exactly terminal rows - 1.
return Math.max(6, rows - 8);
}
render(width: number): string[] {
const theme = this.theme;
const border = theme.fg("borderAccent", "─".repeat(Math.max(1, width)));
const lines: string[] = [];
const snap = this.snap();
if (!snap) {
lines.push(border);
lines.push(theme.fg("dim", `${this.id} is no longer tracked`));
lines.push(border);
return lines;
}
lines.push(border);
const utilization = formatContextUtilization(snap.usage);
const header =
`${statusGlyph(snap, theme)} ` +
theme.fg("accent", theme.bold(`${snap.id} · ${snap.title}`)) +
theme.fg("muted", ` · ${snap.status} · ${formatElapsed(snap)}`) +
(this.options?.badge
? theme.fg("muted", ` · ${this.options.badge}`)
: "") +
theme.fg("dim", ` · ${snap.backend}: ${snap.meta.modelLabel ?? "?"}`) +
(utilization ? theme.fg("dim", ` · ${utilization}`) : "");
lines.push(truncateToWidth(header, width));
lines.push(border);
// Fixed-height transcript viewport. Error and scroll status consume rows
// inside the viewport so streaming/scrolling never changes overlay height.
const transcript = buildTranscriptLines(snap, width, theme);
const viewport = this.viewportHeight();
const errorRows = snap.errorText ? 1 : 0;
const scrollRows = this.scrollOffset > 0 ? 1 : 0;
const transcriptCapacity = Math.max(1, viewport - errorRows - scrollRows);
const maxOffset = Math.max(0, transcript.length - transcriptCapacity);
if (this.scrollOffset > maxOffset) this.scrollOffset = maxOffset;
const body: string[] = [];
if (snap.errorText) {
body.push(
truncateToWidth(theme.fg("error", `error: ${snap.errorText}`), width),
);
}
const capacity = Math.max(
1,
viewport - body.length - (this.scrollOffset > 0 ? 1 : 0),
);
const end = transcript.length - this.scrollOffset;
const visible = transcript.slice(Math.max(0, end - capacity), end);
if (visible.length === 0) body.push(theme.fg("dim", "(no output yet)"));
else body.push(...visible);
if (this.scrollOffset > 0) {
body.push(
truncateToWidth(
theme.fg("dim", `... ${this.scrollOffset} lines below · ↓/pgdn`),
width,
),
);
}
while (body.length < viewport) body.push("");
lines.push(...body.slice(0, viewport));
lines.push(border);
lines.push(...this.input.render(width));
lines.push(
truncateToWidth(
theme.fg(
"dim",
`${configuredKeys(this.keybindings, "tui.input.submit")} send · ${configuredKeys(this.keybindings, "app.interrupt")} back · ${configuredKeys(this.keybindings, "app.clear")} abort run · ${configuredKeys(this.keybindings, "tui.editor.cursorUp")}/${configuredKeys(this.keybindings, "tui.editor.cursorDown")} scroll · ${configuredKeys(this.keybindings, "tui.editor.pageUp")}/${configuredKeys(this.keybindings, "tui.editor.pageDown")} page`,
),
width,
),
);
lines.push(border);
return lines;
}
invalidate(): void {
this.input.invalidate();
}
}
+182
View File
@@ -0,0 +1,182 @@
/**
* Transcript rendering for the takeover view: turns a SubagentSnapshot's
* normalized transcript + live state into plain wrapped lines. Ported from
* v1, with the session-poking replaced by snapshot reads.
*/
import type { Theme } from "@earendil-works/pi-coding-agent";
import {
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from "@earendil-works/pi-tui";
import type { SubagentSnapshot, TranscriptItem } from "../domain.ts";
const ANSI_PATTERN =
// eslint-disable-next-line no-control-regex
/[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[a-zA-Z\d]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
/**
* Strip raw ANSI codes, expand tabs, and drop control chars. Terminal-expanded
* tabs (and stray escapes) make lines wider than the width we declare to the
* TUI, which desyncs the renderer and smears the overlay.
*/
export function sanitizeText(text: string): string {
return text
.replace(ANSI_PATTERN, "")
.replaceAll("\t", " ")
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "");
}
function renderUserText(
theme: Theme,
text: string,
width: number,
out: string[],
) {
const clean = sanitizeText(text).trim();
if (!clean) return;
const wrapped = wrapTextWithAnsi(clean, Math.max(10, width - 2));
for (let i = 0; i < wrapped.length; i++) {
const prefix = i === 0 ? theme.fg("accent", "> ") : " ";
out.push(
truncateToWidth(prefix + theme.fg("userMessageText", wrapped[i]), width),
);
}
}
function renderThinking(
theme: Theme,
text: string,
width: number,
out: string[],
) {
const reasoning = sanitizeText(text).trim();
if (!reasoning) return;
const prefix = theme.fg("dim", "~ ");
const wrapped = wrapTextWithAnsi(reasoning, Math.max(10, width - 2));
for (let i = 0; i < wrapped.length; i++) {
out.push(
truncateToWidth(
(i === 0 ? prefix : " ") + theme.fg("muted", theme.italic(wrapped[i])),
width,
),
);
}
}
function renderAssistantItem(
theme: Theme,
item: Extract<TranscriptItem, { kind: "assistant" }>,
width: number,
out: string[],
) {
for (const part of item.parts) {
if (part.type === "text") {
const text = sanitizeText(part.text).trim();
if (!text) continue;
out.push(...wrapTextWithAnsi(text, width));
} else if (part.type === "thinking") {
renderThinking(
theme,
part.redacted ? "[redacted reasoning]" : part.text,
width,
out,
);
} else if (part.type === "toolCall") {
const preview = part.argsPreview ? sanitizeText(part.argsPreview) : "";
const line =
theme.fg("muted", "→ ") +
theme.fg("toolTitle", part.name) +
(preview && preview !== "{}" ? theme.fg("dim", ` ${preview}`) : "");
out.push(truncateToWidth(line, width));
}
}
}
function renderToolResultItem(
theme: Theme,
item: Extract<TranscriptItem, { kind: "toolResult" }>,
width: number,
out: string[],
) {
const firstLine =
sanitizeText(item.outputPreview ?? "")
.split("\n")
.find((line) => line.trim()) ?? "";
const label = item.isError
? theme.fg("error", " error: ")
: theme.fg("dim", " output: ");
out.push(
truncateToWidth(label + theme.fg("dim", firstLine || "(no output)"), width),
);
}
/** Render a subagent's conversation as plain lines, wrapped to `width`. */
export function buildTranscriptLines(
snap: SubagentSnapshot,
width: number,
theme: Theme,
): string[] {
const out: string[] = [];
for (const item of snap.transcript) {
const before = out.length;
if (item.kind === "user") {
renderUserText(theme, item.text, width, out);
} else if (item.kind === "assistant") {
renderAssistantItem(theme, item, width, out);
} else {
renderToolResultItem(theme, item, width, out);
}
if (out.length > before) out.push("");
}
while (out.length > 0 && out[out.length - 1] === "") out.pop();
// Live streaming assistant buffers (cleared when the finalized message lands).
if (snap.liveAssistant) {
const { thinking, text } = snap.liveAssistant;
const before = out.length;
if (out.length > 0) out.push("");
if (thinking.trim()) renderThinking(theme, thinking, width, out);
if (text.trim())
out.push(...wrapTextWithAnsi(sanitizeText(text).trim(), width));
if (out.length === before + 1) out.pop();
}
// Live tool executions (present until the ToolEnd lands in the transcript).
for (const tool of snap.liveTools) {
if (out.length > 0) out.push("");
const marker = tool.done
? tool.isError
? theme.fg("error", "error")
: theme.fg("success", "done")
: theme.fg("warning", "running");
let line = `${theme.fg("toolTitle", tool.name)} · ${marker}`;
const preview = tool.outputPreview && sanitizeText(tool.outputPreview);
if (preview) line += theme.fg("dim", ` · ${preview}`);
out.push(truncateToWidth(line, width));
}
// Queued steering/follow-up messages: show them immediately so Enter
// visibly acknowledges the user's input instead of appearing to do nothing.
for (const message of snap.queued) {
if (out.length > 0) out.push("");
const prefix = theme.fg("warning", `> [queued ${message.kind}] `);
const wrapped = wrapTextWithAnsi(
sanitizeText(message.text),
Math.max(10, width - visibleWidth(prefix)),
);
for (let i = 0; i < wrapped.length; i++) {
out.push(
truncateToWidth(
(i === 0 ? prefix : " ".repeat(visibleWidth(prefix))) +
theme.fg("muted", wrapped[i]),
width,
),
);
}
}
return out;
}
+29
View File
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
reconcileDashboardSelection,
type DashboardSelection,
} from "./src/ui/takeover.ts";
test("dashboard selection follows its subagent id and falls back by row", () => {
const selection: DashboardSelection = { id: "sa-7", index: 6 };
reconcileDashboardSelection(selection, [
{ id: "sa-new" },
...Array.from({ length: 8 }, (_, index) => ({ id: `sa-${index + 1}` })),
]);
assert.deepEqual(selection, { id: "sa-7", index: 7 });
reconcileDashboardSelection(selection, [
...Array.from({ length: 6 }, (_, index) => ({ id: `sa-${index + 1}` })),
{ id: "sa-8" },
{ id: "sa-9" },
]);
assert.deepEqual(selection, { id: "sa-9", index: 7 });
reconcileDashboardSelection(selection, [{ id: "sa-1" }, { id: "sa-2" }]);
assert.deepEqual(selection, { id: "sa-2", index: 1 });
reconcileDashboardSelection(selection, []);
assert.deepEqual(selection, { id: undefined, index: 0 });
});