Added nono profiles & orca integration - Added some extensions to start working on
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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).
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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(), []);
|
||||
});
|
||||
@@ -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") {}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}> {}
|
||||
@@ -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", " · "))}`;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user