Files
pi-subagents/docs/superpowers/plans/2026-04-12-subagent-presets-and-background-agents.md

1299 lines
43 KiB
Markdown

# Subagent Presets and Background Agents Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add markdown-discovered subagent presets, remove `chain`, add `background_agent` plus `background_agent_status`, and restore preset-owned prompt/tool wiring while preserving the existing runner and artifact guarantees.
**Architecture:** Keep the current foreground runner/wrapper pipeline, but insert preset discovery before each run so foreground and background launches share one metadata shape. Add a small background-run registry in the extension layer that persists launch/update entries to the session, rebuilds state on reload, watches detached process results, updates footer counts, and emits completion notifications without auto-triggering a new turn.
**Tech Stack:** TypeScript, Node.js, `node:test`, `@sinclair/typebox`, `@mariozechner/pi-coding-agent`, wrapper scripts in `src/wrapper/`
---
## File structure and responsibilities
- `src/presets.ts` — discover and parse named preset markdown files from global and project directories
- `src/presets.test.ts` — preset discovery and override behavior
- `src/schema.ts``subagent` schema; remove `chain`, require `preset`
- `src/background-schema.ts` — schemas and types for `background_agent` and `background_agent_status`
- `src/models.ts` — resolve effective model from call override or preset default
- `src/tool.ts` — preset-aware foreground `subagent` execution for single and parallel modes
- `src/background-registry.ts` — background run state, counts, persistence payload shapes, reload reconstruction helpers
- `src/background-registry.test.ts` — registry counts, replay/rebuild, completion transitions
- `src/background-tool.ts` — detached process launch, registry registration, immediate handle response
- `src/background-tool.test.ts` — detached launch behavior and return shape
- `src/background-status-tool.ts` — poll one/all background runs and format counts
- `src/background-status-tool.test.ts` — polling behavior and filtering
- `src/process-runner.ts` — shared process launch primitive for waited vs detached runs
- `src/artifacts.ts` — restore `system-prompt.md` and serialize preset metadata into `meta.json`
- `src/wrapper/cli.mjs` — restore `--append-system-prompt` and `--tools` support
- `index.ts` — register all tools, bootstrap registry, rebuild watchers, footer updates, notifications
- `README.md` / `prompts/*.md` / `AGENTS.md` — package-facing docs and workflow prompt updates
---
### Task 1: Add preset discovery and effective-model resolution
**Files:**
- Create: `src/presets.ts`
- Create: `src/presets.test.ts`
- Modify: `src/models.ts`
- Modify: `src/models.test.ts`
- Test: `src/presets.test.ts`
- Test: `src/models.test.ts`
- [ ] **Step 1: Write the failing preset-discovery tests**
`src/presets.test.ts`
```ts
import test from "node:test";
import assert from "node:assert/strict";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { discoverSubagentPresets } from "./presets.ts";
test("discoverSubagentPresets loads global presets and lets nearest project presets override by name", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-subagents-presets-"));
const homeDir = join(root, "home");
const repo = join(root, "repo", "apps", "web");
const globalDir = join(homeDir, ".pi", "agent", "subagents");
const projectDir = join(root, "repo", ".pi", "subagents");
await mkdir(globalDir, { recursive: true });
await mkdir(projectDir, { recursive: true });
await mkdir(repo, { recursive: true });
await writeFile(
join(globalDir, "reviewer.md"),
`---\nname: reviewer\ndescription: Global reviewer\nmodel: openai/gpt-5\ntools: read,grep\n---\nGlobal prompt`,
"utf8",
);
await writeFile(
join(projectDir, "reviewer.md"),
`---\nname: reviewer\ndescription: Project reviewer\nmodel: anthropic/claude-sonnet-4-5\n---\nProject prompt`,
"utf8",
);
const result = discoverSubagentPresets(repo, { homeDir });
const reviewer = result.presets.find((preset) => preset.name === "reviewer");
assert.equal(reviewer?.source, "project");
assert.equal(reviewer?.description, "Project reviewer");
assert.equal(reviewer?.model, "anthropic/claude-sonnet-4-5");
assert.equal(reviewer?.tools, undefined);
assert.equal(reviewer?.systemPrompt, "Project prompt");
assert.match(result.projectPresetsDir ?? "", /\.pi\/subagents$/);
});
test("discoverSubagentPresets ignores markdown files without required frontmatter", async () => {
const root = await mkdtemp(join(tmpdir(), "pi-subagents-presets-"));
const homeDir = join(root, "home");
const repo = join(root, "repo");
const globalDir = join(homeDir, ".pi", "agent", "subagents");
await mkdir(globalDir, { recursive: true });
await mkdir(repo, { recursive: true });
await writeFile(join(globalDir, "broken.md"), `---\nname: broken\n---\nMissing description`, "utf8");
const result = discoverSubagentPresets(repo, { homeDir });
assert.deepEqual(result.presets, []);
});
```
`src/models.test.ts`
```ts
test("resolveChildModel prefers explicit call model over preset default model", () => {
const selection = resolveChildModel({
callModel: "openai/gpt-5",
presetModel: "anthropic/claude-sonnet-4-5",
});
assert.equal(selection.requestedModel, "openai/gpt-5");
assert.equal(selection.resolvedModel, "openai/gpt-5");
});
test("resolveChildModel falls back to preset default model", () => {
const selection = resolveChildModel({
callModel: undefined,
presetModel: "anthropic/claude-sonnet-4-5",
});
assert.equal(selection.requestedModel, "anthropic/claude-sonnet-4-5");
assert.equal(selection.resolvedModel, "anthropic/claude-sonnet-4-5");
});
```
- [ ] **Step 2: Run tests to verify they fail for the intended reason**
Run:
```bash
npx tsx --test src/presets.test.ts src/models.test.ts
```
Expected:
- FAIL because `discoverSubagentPresets` does not exist yet
- FAIL because `resolveChildModel` still expects `topLevelModel`
- [ ] **Step 3: Write the minimal preset-discovery and model-resolution code**
`src/presets.ts`
```ts
import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from "node:fs";
import { dirname, join } from "node:path";
import { getAgentDir, parseFrontmatter } from "@mariozechner/pi-coding-agent";
export interface SubagentPreset {
name: string;
description: string;
model?: string;
tools?: string[];
systemPrompt: string;
source: "global" | "project";
filePath: string;
}
export interface SubagentPresetDiscoveryResult {
presets: SubagentPreset[];
projectPresetsDir: string | null;
}
function findNearestProjectPresetsDir(cwd: string): string | null {
let current = cwd;
while (true) {
const candidate = join(current, ".pi", "subagents");
try {
if (statSync(candidate).isDirectory()) return candidate;
} catch {}
const parent = dirname(current);
if (parent === current) return null;
current = parent;
}
}
function loadPresetDir(dir: string, source: "global" | "project"): SubagentPreset[] {
if (!existsSync(dir)) return [];
const entries = readdirSync(dir, { withFileTypes: true });
const presets: SubagentPreset[] = [];
for (const entry of entries) {
if (!entry.name.endsWith(".md")) continue;
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
const filePath = join(dir, entry.name);
const content = readFileSync(filePath, "utf8");
const { frontmatter, body } = parseFrontmatter<Record<string, unknown>>(content);
if (typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string") continue;
const tools = typeof frontmatter.tools === "string"
? frontmatter.tools.split(",").map((value) => value.trim()).filter(Boolean)
: undefined;
presets.push({
name: frontmatter.name,
description: frontmatter.description,
model: typeof frontmatter.model === "string" ? frontmatter.model : undefined,
tools: tools && tools.length > 0 ? tools : undefined,
systemPrompt: body.trim(),
source,
filePath,
});
}
return presets;
}
export function discoverSubagentPresets(cwd: string, options: { homeDir?: string } = {}): SubagentPresetDiscoveryResult {
const globalDir = join(options.homeDir ?? getAgentDir(), "subagents");
const projectPresetsDir = findNearestProjectPresetsDir(cwd);
const map = new Map<string, SubagentPreset>();
for (const preset of loadPresetDir(globalDir, "global")) map.set(preset.name, preset);
if (projectPresetsDir) {
for (const preset of loadPresetDir(projectPresetsDir, "project")) map.set(preset.name, preset);
}
return { presets: [...map.values()], projectPresetsDir };
}
```
`src/models.ts`
```ts
export function resolveChildModel(input: {
callModel?: string;
presetModel?: string;
}): ModelSelection {
const requestedModel = input.callModel ?? input.presetModel;
return {
requestedModel,
resolvedModel: requestedModel,
};
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run:
```bash
npx tsx --test src/presets.test.ts src/models.test.ts
```
Expected:
- PASS for all preset/model tests
- [ ] **Step 5: Commit**
```bash
git add src/presets.ts src/presets.test.ts src/models.ts src/models.test.ts
git commit -m "feat: add subagent preset discovery"
```
---
### Task 2: Rework `subagent` around presets and remove `chain`
**Files:**
- Modify: `src/schema.ts`
- Modify: `src/tool.ts`
- Modify: `src/tool.test.ts`
- Modify: `src/tool-parallel.test.ts`
- Remove: `src/tool-chain.test.ts`
- Modify: `src/extension.test.ts`
- Test: `src/tool.test.ts`
- Test: `src/tool-parallel.test.ts`
- Test: `src/extension.test.ts`
- [ ] **Step 1: Write failing schema/runtime tests for preset-only single and parallel modes**
`src/tool.test.ts`
```ts
test("single-mode subagent resolves preset prompt/tools/model and emits progress", async () => {
const updates: string[] = [];
const tool = createSubagentTool({
discoverSubagentPresets: () => ({
presets: [{
name: "repo-scout",
description: "Scout",
model: "anthropic/claude-sonnet-4-5",
tools: ["read", "grep"],
systemPrompt: "Explore quickly",
source: "global",
filePath: "/home/dev/.pi/agent/subagents/repo-scout.md",
}],
projectPresetsDir: null,
}),
runSingleTask: async ({ onEvent, meta }: any) => {
onEvent?.({ type: "tool_call", toolName: "read", args: { path: "src/auth.ts" } });
return {
runId: "run-1",
preset: meta.preset,
task: meta.task,
requestedModel: meta.requestedModel,
resolvedModel: meta.resolvedModel,
exitCode: 0,
finalText: "done",
};
},
} as any);
const result = await tool.execute(
"tool-1",
{ preset: "repo-scout", task: "inspect auth" },
undefined,
(partial: any) => {
const first = partial.content?.[0];
if (first?.type === "text") updates.push(first.text);
},
{
cwd: "/repo",
modelRegistry: {
getAvailable: () => [{ provider: "anthropic", id: "claude-sonnet-4-5" }],
},
hasUI: false,
} as any,
);
assert.equal(result.isError, false);
assert.equal(result.details.mode, "single");
assert.equal(result.details.results[0]?.requestedModel, "anthropic/claude-sonnet-4-5");
assert.match(updates.join("\n"), /Running subagent/);
});
test("single-mode subagent errors when neither call nor preset supplies a model", async () => {
const tool = createSubagentTool({
discoverSubagentPresets: () => ({
presets: [{
name: "repo-scout",
description: "Scout",
systemPrompt: "Explore quickly",
source: "global",
filePath: "/tmp/repo-scout.md",
}],
projectPresetsDir: null,
}),
} as any);
const result = await tool.execute(
"tool-1",
{ preset: "repo-scout", task: "inspect auth" },
undefined,
undefined,
{
cwd: "/repo",
modelRegistry: {
getAvailable: () => [{ provider: "anthropic", id: "claude-sonnet-4-5" }],
},
hasUI: false,
} as any,
);
assert.equal(result.isError, true);
assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /requires a model/i);
});
```
`src/tool-parallel.test.ts`
```ts
test("parallel mode requires each task to name a preset and lets each task resolve its own model", async () => {
const seenModels: string[] = [];
const tool = createSubagentTool({
discoverSubagentPresets: () => ({
presets: [
{ name: "repo-scout", description: "Scout", model: "openai/gpt-5", systemPrompt: "Scout", source: "global", filePath: "/tmp/repo-scout.md" },
{ name: "repo-review", description: "Review", model: "anthropic/claude-opus-4-5", systemPrompt: "Review", source: "global", filePath: "/tmp/repo-review.md" },
],
projectPresetsDir: null,
}),
runSingleTask: async ({ meta }: any) => {
seenModels.push(meta.requestedModel);
return { runId: `run-${seenModels.length}`, task: meta.task, exitCode: 0, finalText: meta.task };
},
} as any);
const result = await tool.execute(
"tool-1",
{
tasks: [
{ preset: "repo-scout", task: "inspect auth" },
{ preset: "repo-review", task: "review auth", model: "anthropic/claude-opus-4-5" },
],
},
undefined,
undefined,
{
cwd: "/repo",
modelRegistry: {
getAvailable: () => [
{ provider: "openai", id: "gpt-5" },
{ provider: "anthropic", id: "claude-opus-4-5" },
],
},
hasUI: false,
} as any,
);
assert.equal(result.isError, false);
assert.deepEqual(seenModels, ["openai/gpt-5", "anthropic/claude-opus-4-5"]);
});
```
`src/extension.test.ts`
```ts
assert.deepEqual(registeredTools[0]?.parameters.required, []);
assert.equal("preset" in registeredTools[0]?.parameters.properties, true);
assert.equal("chain" in registeredTools[0]?.parameters.properties, false);
assert.equal("preset" in registeredTools[0]?.parameters.properties.tasks.items.properties, true);
```
- [ ] **Step 2: Run tests to verify they fail**
Run:
```bash
npx tsx --test src/tool.test.ts src/tool-parallel.test.ts src/extension.test.ts
```
Expected:
- FAIL because schema still requires `model`
- FAIL because `preset` fields do not exist yet
- FAIL because runtime still expects `taskModel/topLevelModel` and supports `chain`
- [ ] **Step 3: Write the minimal schema/runtime changes**
`src/schema.ts`
```ts
function createTaskItemSchema(availableModels: readonly string[]) {
return Type.Object({
preset: Type.String({ description: "Name of the preset to invoke" }),
task: Type.String({ description: "Task to delegate to the child agent" }),
model: createTaskModelSchema(availableModels),
cwd: Type.Optional(Type.String({ description: "Optional working directory override" })),
});
}
export function createSubagentParamsSchema(availableModels: readonly string[]) {
return Type.Object({
preset: Type.Optional(Type.String({ description: "Single-mode preset name" })),
task: Type.Optional(Type.String({ description: "Single-mode delegated task" })),
model: Type.Optional(
StringEnum(availableModels, {
description: "Optional child model override. Must be one of the currently available models.",
}),
),
tasks: Type.Optional(Type.Array(createTaskItemSchema(availableModels), { description: "Parallel tasks" })),
cwd: Type.Optional(Type.String({ description: "Single-mode working directory override" })),
});
}
```
`src/tool.ts`
```ts
const hasSingle = Boolean(params.preset && params.task);
const hasParallel = Boolean(params.tasks?.length);
const modeCount = Number(hasSingle) + Number(hasParallel);
const mode = hasParallel ? "parallel" : "single";
const discovery = (deps.discoverSubagentPresets ?? discoverSubagentPresets)(ctx.cwd);
const resolvePreset = (name: string) => {
const preset = discovery.presets.find((candidate) => candidate.name === name);
if (!preset) throw new Error(`Unknown preset: ${name}`);
return preset;
};
const resolveEffectiveModel = (callModel: string | undefined, presetModel: string | undefined) => {
const requested = normalizeModelReference(callModel ?? presetModel);
if (!requested) {
throw new Error(`Preset run requires a model chosen from the available models: ${availableModelsText}`);
}
return requested;
};
const runTask = async (input: { presetName: string; task: string; cwd?: string; taskModel?: string; taskIndex?: number; mode: "single" | "parallel" }) => {
const preset = resolvePreset(input.presetName);
const requestedModel = resolveEffectiveModel(input.taskModel, preset.model);
return deps.runSingleTask?.({
cwd: input.cwd ?? ctx.cwd,
onEvent(event) {
const text = progressFormatter.format(event);
if (!text) return;
onUpdate?.({ content: [{ type: "text", text }], details: makeDetails(input.mode, []) });
},
meta: {
mode: input.mode,
taskIndex: input.taskIndex,
preset: preset.name,
presetSource: preset.source,
task: input.task,
cwd: input.cwd ?? ctx.cwd,
requestedModel,
resolvedModel: requestedModel,
systemPrompt: preset.systemPrompt,
tools: preset.tools,
},
}) as Promise<SubagentRunResult>;
};
```
- [ ] **Step 4: Run tests to verify they pass**
Run:
```bash
npx tsx --test src/tool.test.ts src/tool-parallel.test.ts src/extension.test.ts
```
Expected:
- PASS for single/parallel preset flows
- PASS for extension schema assertions
- [ ] **Step 5: Commit**
```bash
git add src/schema.ts src/tool.ts src/tool.test.ts src/tool-parallel.test.ts src/extension.test.ts
git rm src/tool-chain.test.ts
git commit -m "feat: make subagent preset-driven"
```
---
### Task 3: Restore preset-owned prompt/tool artifacts in the wrapper path
**Files:**
- Modify: `src/artifacts.ts`
- Modify: `src/artifacts.test.ts`
- Modify: `src/process-runner.ts`
- Modify: `src/process-runner.test.ts`
- Modify: `src/wrapper/cli.mjs`
- Modify: `src/wrapper/cli.test.ts`
- Modify: `src/wrapper/render.mjs`
- Modify: `src/wrapper/render.test.ts`
- Test: `src/artifacts.test.ts`
- Test: `src/process-runner.test.ts`
- Test: `src/wrapper/cli.test.ts`
- Test: `src/wrapper/render.test.ts`
- [ ] **Step 1: Write failing tests for restored prompt/tool wiring**
Add these assertions first.
`src/artifacts.test.ts`
```ts
const artifacts = await createRunArtifacts(cwd, {
runId: "run-1",
preset: "repo-scout",
systemPrompt: "Explore quickly",
tools: ["read", "grep"],
});
const meta = JSON.parse(await readFile(artifacts.metaPath, "utf8"));
assert.equal(meta.preset, "repo-scout");
assert.deepEqual(meta.tools, ["read", "grep"]);
assert.equal(meta.systemPromptPath, join(artifacts.dir, "system-prompt.md"));
assert.equal(await readFile(artifacts.systemPromptPath, "utf8"), "Explore quickly");
```
`src/wrapper/cli.test.ts`
```ts
test("wrapper passes preset tool allowlist and system prompt file to pi when present", async () => {
const captured = await runWrapperWithFakePi("anthropic/claude-sonnet-4-5", undefined, {
tools: ["read", "grep"],
systemPrompt: "Explore quickly",
});
assert.equal(captured.flags.argv.includes("--tools"), true);
assert.equal(captured.flags.argv.includes("read,grep"), true);
assert.equal(captured.flags.argv.includes("--append-system-prompt"), true);
});
```
`src/process-runner.test.ts`
```ts
assert.equal(saved.preset, "repo-scout");
assert.equal(saved.exitCode, 1);
```
- [ ] **Step 2: Run tests to verify they fail**
Run:
```bash
npx tsx --test src/artifacts.test.ts src/process-runner.test.ts src/wrapper/cli.test.ts src/wrapper/render.test.ts
```
Expected:
- FAIL because `systemPromptPath` and restored wrapper argv are missing
- [ ] **Step 3: Write the minimal artifact/wrapper changes**
`src/artifacts.ts`
```ts
export interface RunArtifacts {
runId: string;
dir: string;
metaPath: string;
eventsPath: string;
resultPath: string;
stdoutPath: string;
stderrPath: string;
transcriptPath: string;
sessionPath: string;
systemPromptPath: string;
}
export async function createRunArtifacts(
cwd: string,
meta: Record<string, unknown> & { runId?: string; systemPrompt?: string },
): Promise<RunArtifacts> {
const artifacts: RunArtifacts = {
runId,
dir,
metaPath: join(dir, "meta.json"),
eventsPath: join(dir, "events.jsonl"),
resultPath: join(dir, "result.json"),
stdoutPath: join(dir, "stdout.log"),
stderrPath: join(dir, "stderr.log"),
transcriptPath: join(dir, "transcript.log"),
sessionPath: join(dir, "child-session.jsonl"),
systemPromptPath: join(dir, "system-prompt.md"),
};
await writeFile(artifacts.systemPromptPath, typeof meta.systemPrompt === "string" ? meta.systemPrompt : "", "utf8");
// keep best-effort log file initialization unchanged
}
```
`src/wrapper/cli.mjs`
```js
const args = ["--mode", "json", "--session", meta.sessionPath];
if (effectiveModel) args.push("--model", effectiveModel);
if (Array.isArray(meta.tools) && meta.tools.length > 0) args.push("--tools", meta.tools.join(","));
if (meta.systemPromptPath) args.push("--append-system-prompt", meta.systemPromptPath);
args.push(meta.task);
```
`src/wrapper/render.mjs`
```js
export function renderHeader(meta) {
return [
"=== subagent ===",
`Preset: ${meta.preset ?? "(none)"}`,
`Task: ${meta.task}`,
`CWD: ${meta.cwd}`,
`Requested model: ${meta.requestedModel ?? "(default)"}`,
`Resolved model: ${meta.resolvedModel ?? "(pending)"}`,
`Session: ${meta.sessionPath}`,
"---------------------",
].join("\n");
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run:
```bash
npx tsx --test src/artifacts.test.ts src/process-runner.test.ts src/wrapper/cli.test.ts src/wrapper/render.test.ts
```
Expected:
- PASS for restored prompt/tool wiring and artifact metadata
- [ ] **Step 5: Commit**
```bash
git add src/artifacts.ts src/artifacts.test.ts src/process-runner.ts src/process-runner.test.ts src/wrapper/cli.mjs src/wrapper/cli.test.ts src/wrapper/render.mjs src/wrapper/render.test.ts
git commit -m "feat: restore preset wrapper metadata"
```
---
### Task 4: Add background registry and polling tool
**Files:**
- Create: `src/background-schema.ts`
- Create: `src/background-registry.ts`
- Create: `src/background-registry.test.ts`
- Create: `src/background-status-tool.ts`
- Create: `src/background-status-tool.test.ts`
- Test: `src/background-registry.test.ts`
- Test: `src/background-status-tool.test.ts`
- [ ] **Step 1: Write failing tests for counts, replay, and polling**
`src/background-registry.test.ts`
```ts
import test from "node:test";
import assert from "node:assert/strict";
import { createBackgroundRegistry } from "./background-registry.ts";
test("background registry tracks counts and applies terminal updates", () => {
const registry = createBackgroundRegistry();
registry.recordLaunch({ runId: "run-1", preset: "repo-scout", task: "inspect auth", status: "running" });
registry.recordLaunch({ runId: "run-2", preset: "repo-review", task: "review auth", status: "running" });
registry.recordUpdate({ runId: "run-2", status: "failed", finalText: "boom", exitCode: 1 });
assert.deepEqual(registry.getCounts(), {
running: 1,
completed: 0,
failed: 1,
aborted: 0,
total: 2,
});
});
test("background registry rebuilds latest state from persisted entries", () => {
const registry = createBackgroundRegistry();
registry.replay([
{ customType: "pi-subagents:bg-run", data: { runId: "run-1", preset: "repo-scout", task: "inspect auth", status: "running" } },
{ customType: "pi-subagents:bg-update", data: { runId: "run-1", status: "completed", exitCode: 0, finalText: "done" } },
] as any);
const run = registry.getRun("run-1");
assert.equal(run?.status, "completed");
assert.equal(run?.finalText, "done");
});
```
`src/background-status-tool.test.ts`
```ts
import test from "node:test";
import assert from "node:assert/strict";
import { createBackgroundAgentStatusTool } from "./background-status-tool.ts";
test("background_agent_status returns active runs by default and can target a single run", async () => {
const tool = createBackgroundAgentStatusTool({
getSnapshot: () => ({
counts: { running: 1, completed: 1, failed: 0, aborted: 0, total: 2 },
runs: [
{ runId: "run-1", status: "running", preset: "repo-scout", task: "inspect auth" },
{ runId: "run-2", status: "completed", preset: "repo-review", task: "review auth", finalText: "done" },
],
}),
});
const active = await tool.execute("tool-1", {}, undefined, undefined, {} as any);
const single = await tool.execute("tool-2", { runId: "run-2" }, undefined, undefined, {} as any);
assert.equal(active.isError, false);
assert.match(active.content[0]?.type === "text" ? active.content[0].text : "", /1 running \/ 2 total/);
assert.match(single.content[0]?.type === "text" ? single.content[0].text : "", /run-2/);
assert.match(single.content[0]?.type === "text" ? single.content[0].text : "", /done/);
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run:
```bash
npx tsx --test src/background-registry.test.ts src/background-status-tool.test.ts
```
Expected:
- FAIL because registry and status tool do not exist yet
- [ ] **Step 3: Write the minimal registry and polling implementation**
`src/background-schema.ts`
```ts
import { StringEnum } from "@mariozechner/pi-ai";
import { Type } from "@sinclair/typebox";
export function createBackgroundAgentParamsSchema(availableModels: readonly string[]) {
return Type.Object({
preset: Type.String({ description: "Name of the preset to invoke" }),
task: Type.String({ description: "Task to delegate to the background agent" }),
model: Type.Optional(
StringEnum(availableModels, {
description: "Optional child model override. Must be one of the currently available models.",
}),
),
cwd: Type.Optional(Type.String({ description: "Optional working directory override" })),
});
}
export const BackgroundAgentParamsSchema = createBackgroundAgentParamsSchema([]);
export const BackgroundAgentStatusParamsSchema = Type.Object({
runId: Type.Optional(Type.String({ description: "Inspect a specific background run" })),
includeCompleted: Type.Optional(Type.Boolean({ default: false })),
});
```
`src/background-registry.ts`
```ts
export interface BackgroundRunState {
runId: string;
preset: string;
task: string;
status: "running" | "completed" | "failed" | "aborted";
requestedModel?: string;
resolvedModel?: string;
resultPath?: string;
eventsPath?: string;
stdoutPath?: string;
stderrPath?: string;
transcriptPath?: string;
sessionPath?: string;
startedAt?: string;
finishedAt?: string;
exitCode?: number;
stopReason?: string;
finalText?: string;
errorMessage?: string;
}
export function createBackgroundRegistry() {
const runs = new Map<string, BackgroundRunState>();
const getCounts = () => {
const counts = { running: 0, completed: 0, failed: 0, aborted: 0, total: runs.size };
for (const run of runs.values()) counts[run.status] += 1;
return counts;
};
return {
recordLaunch(run: BackgroundRunState) { runs.set(run.runId, run); },
recordUpdate(update: Partial<BackgroundRunState> & { runId: string; status: BackgroundRunState["status"] }) {
const previous = runs.get(update.runId) ?? ({ runId: update.runId, preset: "(unknown)", task: "(unknown)" } as BackgroundRunState);
runs.set(update.runId, { ...previous, ...update });
},
replay(entries: Array<{ customType: string; data?: unknown }>) {
for (const entry of entries) {
if (entry.customType === "pi-subagents:bg-run") this.recordLaunch(entry.data as BackgroundRunState);
if (entry.customType === "pi-subagents:bg-update") this.recordUpdate(entry.data as any);
}
},
getRun(runId: string) { return runs.get(runId); },
getSnapshot(options: { includeCompleted?: boolean; runId?: string } = {}) {
const allRuns = [...runs.values()];
const selected = options.runId
? allRuns.filter((run) => run.runId === options.runId)
: options.includeCompleted
? allRuns
: allRuns.filter((run) => run.status === "running");
return { counts: getCounts(), runs: selected };
},
getCounts,
};
}
```
`src/background-status-tool.ts`
```ts
import { Text } from "@mariozechner/pi-tui";
import { BackgroundAgentStatusParamsSchema } from "./background-schema.ts";
export function createBackgroundAgentStatusTool(deps: {
getSnapshot: (options?: { runId?: string; includeCompleted?: boolean }) => {
counts: { running: number; completed: number; failed: number; aborted: number; total: number };
runs: Array<Record<string, unknown>>;
};
}) {
return {
name: "background_agent_status",
label: "Background Agent Status",
description: "Poll background-agent counts and detached run status.",
parameters: BackgroundAgentStatusParamsSchema,
async execute(_toolCallId: string, params: any) {
const snapshot = deps.getSnapshot(params);
const lines = [
`${snapshot.counts.running} running / ${snapshot.counts.total} total`,
...snapshot.runs.map((run: any) => `${run.runId} ${run.status} ${run.preset}: ${run.task}${run.finalText ? ` => ${run.finalText}` : ""}`),
];
return {
content: [{ type: "text" as const, text: lines.join("\n") }],
details: snapshot,
isError: false,
};
},
renderCall() {
return new Text("background_agent_status", 0, 0);
},
renderResult(result: { content: Array<{ type: string; text?: string }> }) {
const first = result.content[0];
return new Text(first?.type === "text" ? first.text ?? "" : "", 0, 0);
},
};
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run:
```bash
npx tsx --test src/background-registry.test.ts src/background-status-tool.test.ts
```
Expected:
- PASS for registry counting and polling behavior
- [ ] **Step 5: Commit**
```bash
git add src/background-schema.ts src/background-registry.ts src/background-registry.test.ts src/background-status-tool.ts src/background-status-tool.test.ts
git commit -m "feat: add background run registry"
```
---
### Task 5: Add detached launcher, extension integration, notifications, docs, and prompts
**Files:**
- Create: `src/background-tool.ts`
- Create: `src/background-tool.test.ts`
- Modify: `src/process-runner.ts`
- Modify: `index.ts`
- Modify: `src/extension.test.ts`
- Modify: `README.md`
- Modify: `prompts/scout-and-plan.md`
- Modify: `prompts/implement.md`
- Modify: `prompts/implement-and-review.md`
- Modify: `src/prompts.test.ts`
- Modify: `AGENTS.md`
- Test: `src/background-tool.test.ts`
- Test: `src/extension.test.ts`
- Test: `src/prompts.test.ts`
- Test: `src/package-manifest.test.ts`
- [ ] **Step 1: Write failing tests for detached launch and extension-side completion notifications**
`src/background-tool.test.ts`
```ts
import test from "node:test";
import assert from "node:assert/strict";
import { createBackgroundAgentTool } from "./background-tool.ts";
test("background_agent returns immediately with handle metadata and counts", async () => {
const appended: Array<{ type: string; data: any }> = [];
let watchCalled = false;
const tool = createBackgroundAgentTool({
discoverSubagentPresets: () => ({
presets: [{
name: "repo-scout",
description: "Scout",
model: "openai/gpt-5",
systemPrompt: "Scout",
source: "global",
filePath: "/tmp/repo-scout.md",
}],
projectPresetsDir: null,
}),
launchDetachedTask: async () => ({
runId: "run-1",
task: "inspect auth",
requestedModel: "openai/gpt-5",
resolvedModel: "openai/gpt-5",
resultPath: "/repo/.pi/subagents/runs/run-1/result.json",
eventsPath: "/repo/.pi/subagents/runs/run-1/events.jsonl",
stdoutPath: "/repo/.pi/subagents/runs/run-1/stdout.log",
stderrPath: "/repo/.pi/subagents/runs/run-1/stderr.log",
transcriptPath: "/repo/.pi/subagents/runs/run-1/transcript.log",
sessionPath: "/repo/.pi/subagents/runs/run-1/child-session.jsonl",
}),
registerBackgroundRun(run: any) {
appended.push({ type: "run", data: run });
return { running: 1, completed: 0, failed: 0, aborted: 0, total: 1 };
},
watchBackgroundRun() {
watchCalled = true;
},
} as any);
const result = await tool.execute(
"tool-1",
{ preset: "repo-scout", task: "inspect auth" },
undefined,
undefined,
{
cwd: "/repo",
modelRegistry: { getAvailable: () => [{ provider: "openai", id: "gpt-5" }] },
hasUI: false,
} as any,
);
assert.equal(result.isError, false);
assert.equal(result.details.runId, "run-1");
assert.deepEqual(result.details.counts, { running: 1, completed: 0, failed: 0, aborted: 0, total: 1 });
assert.equal(watchCalled, true);
assert.equal(appended.length, 1);
});
```
`src/extension.test.ts`
```ts
test("background completion updates footer status, notifies UI, and injects a visible session message without auto-turn", async () => {
const sentMessages: any[] = [];
const appendedEntries: any[] = [];
const notifications: any[] = [];
const statuses: Array<{ key: string; text: string | undefined }> = [];
// after extension boot + mocked watcher completion
assert.deepEqual(notifications, [{ message: "Background agent repo-scout finished: done", type: "info" }]);
assert.deepEqual(statuses.at(-1), { key: "pi-subagents", text: "bg: 0 running / 1 total" });
assert.deepEqual(appendedEntries.at(-1), {
type: "pi-subagents:bg-update",
data: { runId: "run-1", status: "completed", finalText: "done", exitCode: 0 },
});
assert.deepEqual(sentMessages.at(-1), {
message: {
customType: "pi-subagents:bg-complete",
content: "Background agent repo-scout completed (run-1): done",
display: true,
details: { runId: "run-1", status: "completed" },
},
options: { triggerTurn: false },
});
});
```
`src/prompts.test.ts`
```ts
for (const name of ["implement.md", "scout-and-plan.md", "implement-and-review.md"]) {
const content = readFileSync(join(packageRoot, "prompts", name), "utf8");
assert.doesNotMatch(content, /chain mode/i);
assert.match(content, /subagent/);
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run:
```bash
npx tsx --test src/background-tool.test.ts src/extension.test.ts src/prompts.test.ts src/package-manifest.test.ts
```
Expected:
- FAIL because `background_agent` does not exist yet
- FAIL because extension does not maintain registry or notifications
- FAIL because shipped prompts still mention `chain`
- [ ] **Step 3: Write the minimal detached-launch, extension, and docs changes**
`src/process-runner.ts`
```ts
export function createDetachedProcessLauncher(deps: {
createArtifacts: (cwd: string, meta: Record<string, unknown>) => Promise<any>;
buildWrapperSpawn: (metaPath: string) => { command: string; args: string[]; env?: NodeJS.ProcessEnv };
spawnChild?: typeof spawn;
}) {
const spawnChild = deps.spawnChild ?? spawn;
return async function launchDetachedTask(input: { cwd: string; meta: Record<string, unknown> }) {
const artifacts = await deps.createArtifacts(input.cwd, input.meta);
const spawnSpec = deps.buildWrapperSpawn(artifacts.metaPath);
const child = spawnChild(spawnSpec.command, spawnSpec.args, {
cwd: input.cwd,
env: { ...process.env, ...(spawnSpec.env ?? {}) },
stdio: ["ignore", "ignore", "ignore"],
});
child.once("error", async (error) => {
const result = makeLaunchFailureResult(artifacts, input.meta, input.cwd, error);
await writeFile(artifacts.resultPath, JSON.stringify(result, null, 2), "utf8");
});
return {
runId: artifacts.runId,
sessionPath: artifacts.sessionPath,
stdoutPath: artifacts.stdoutPath,
stderrPath: artifacts.stderrPath,
transcriptPath: artifacts.transcriptPath,
resultPath: artifacts.resultPath,
eventsPath: artifacts.eventsPath,
...input.meta,
};
};
}
```
`src/background-tool.ts`
```ts
import { Text } from "@mariozechner/pi-tui";
import { discoverSubagentPresets } from "./presets.ts";
import { normalizeAvailableModelReference, listAvailableModelReferences, resolveChildModel } from "./models.ts";
import { BackgroundAgentParamsSchema } from "./background-schema.ts";
export function createBackgroundAgentTool(deps: any) {
return {
name: "background_agent",
label: "Background Agent",
description: "Spawn a detached background child session and return its run handle immediately.",
parameters: deps.parameters ?? BackgroundAgentParamsSchema,
async execute(_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: any, ctx: any) {
const discovery = (deps.discoverSubagentPresets ?? discoverSubagentPresets)(ctx.cwd);
const preset = discovery.presets.find((candidate: any) => candidate.name === params.preset);
if (!preset) {
return { content: [{ type: "text" as const, text: `Unknown preset: ${params.preset}` }], isError: true };
}
const available = (deps.listAvailableModelReferences ?? listAvailableModelReferences)(ctx.modelRegistry);
const requestedModel = (deps.normalizeAvailableModelReference ?? normalizeAvailableModelReference)(params.model ?? preset.model, available);
if (!requestedModel) {
return { content: [{ type: "text" as const, text: `Background agent requires a model chosen from the available models: ${available.join(", ")}` }], isError: true };
}
const launched = await deps.launchDetachedTask({
cwd: params.cwd ?? ctx.cwd,
meta: {
mode: "background",
preset: preset.name,
presetSource: preset.source,
task: params.task,
cwd: params.cwd ?? ctx.cwd,
requestedModel,
resolvedModel: requestedModel,
systemPrompt: preset.systemPrompt,
tools: preset.tools,
},
});
const counts = deps.registerBackgroundRun({ ...launched, status: "running", preset: preset.name, task: params.task });
deps.watchBackgroundRun(launched.runId);
return {
content: [{ type: "text" as const, text: `Background agent started: ${launched.runId}` }],
details: { ...launched, counts },
isError: false,
};
},
renderCall() { return new Text("background_agent", 0, 0); },
renderResult(result: { content: Array<{ type: string; text?: string }> }) {
const first = result.content[0];
return new Text(first?.type === "text" ? first.text ?? "" : "", 0, 0);
},
};
}
```
`index.ts`
```ts
const registry = createBackgroundRegistry();
let latestUi: { notify(message: string, type?: "info" | "warning" | "error"): void; setStatus(key: string, text: string | undefined): void } | undefined;
function renderCounts() {
const counts = registry.getCounts();
return counts.total === 0 ? undefined : `bg: ${counts.running} running / ${counts.total} total`;
}
function updateStatus() {
latestUi?.setStatus("pi-subagents", renderCounts());
}
function watchBackgroundRun(runId: string) {
const run = registry.getRun(runId);
if (!run?.resultPath || !run.eventsPath) return;
void monitorRun({
eventsPath: run.eventsPath,
resultPath: run.resultPath,
}).then((result) => {
const status = result.stopReason === "aborted" ? "aborted" : result.exitCode === 0 ? "completed" : "failed";
registry.recordUpdate({ runId, status, ...result });
pi.appendEntry("pi-subagents:bg-update", { runId, status, finalText: result.finalText, exitCode: result.exitCode });
updateStatus();
latestUi?.notify(`Background agent ${run.preset} finished: ${result.finalText || status}`, status === "completed" ? "info" : "error");
pi.sendMessage({
customType: "pi-subagents:bg-complete",
content: `Background agent ${run.preset} completed (${runId}): ${result.finalText || status}`,
display: true,
details: { runId, status },
}, { triggerTurn: false });
});
}
pi.on("session_start", (_event, ctx) => {
latestUi = ctx.ui;
registry.replay(ctx.sessionManager.getEntries().filter((entry: any) => entry.type === "custom") as any);
updateStatus();
for (const run of registry.getSnapshot({ includeCompleted: true }).runs) {
if (run.status === "running") watchBackgroundRun(run.runId as string);
}
syncTools(ctx);
});
pi.on("before_agent_start", (_event, ctx) => {
latestUi = ctx.ui;
syncTools(ctx);
});
pi.registerTool(createBackgroundAgentTool({ launchDetachedTask, registerBackgroundRun, watchBackgroundRun, parameters: createBackgroundAgentParamsSchema(availableModels) }));
pi.registerTool(createBackgroundAgentStatusTool({ getSnapshot: (options) => registry.getSnapshot(options) }));
```
`README.md`
```md
## Presets
Named presets live in:
- `~/.pi/agent/subagents/*.md`
- nearest project `.pi/subagents/*.md`
Project presets override global presets by name.
Preset frontmatter may define:
- `name`
- `description`
- `model`
- `tools`
Preset body is appended to the child session system prompt.
`tools` maps to Pi CLI `--tools`, so it limits built-in tools only.
## Tools
- `subagent` — foreground single or parallel runs using named presets
- `background_agent` — detached process-backed run that returns immediately
- `background_agent_status` — poll background counts and run status
```
`prompts/scout-and-plan.md`
```md
---
description: Inspect the codebase, then produce a plan using preset-driven subagents
---
Run `subagent` once to inspect the codebase with an appropriate preset.
Then run `subagent` again with a planning preset, using the first result as context.
User request: $@
```
- [ ] **Step 4: Run tests to verify they pass**
Run:
```bash
npx tsx --test src/background-tool.test.ts src/extension.test.ts src/prompts.test.ts src/package-manifest.test.ts
```
Expected:
- PASS for detached launch, notification behavior, and prompt/docs assertions
- [ ] **Step 5: Run the full suite and commit**
Run:
```bash
npm test
```
Expected:
- PASS with 0 failures
Then commit:
```bash
git add index.ts src/background-tool.ts src/background-tool.test.ts src/process-runner.ts README.md prompts/*.md src/prompts.test.ts AGENTS.md
git commit -m "feat: add background preset subagents"
```
---
## Plan self-review
- Spec coverage checked:
- preset discovery, override rules, and model defaults: Task 1
- `subagent` preset API and chain removal: Task 2
- restored wrapper prompt/tool support: Task 3
- background counts, polling, persistence, reload: Task 4
- detached launch, notifications, docs, and prompt rewrites: Task 5
- Placeholder scan checked:
- no `TODO`, `TBD`, or “handle edge cases” placeholders remain
- Type consistency checked:
- tool names, entry types, and schema field names match across tasks