# 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>(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(); 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; }; ``` - [ ] **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 & { runId?: string; systemPrompt?: string }, ): Promise { 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(); 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 & { 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>; }; }) { 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) => Promise; 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 }) { 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