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

43 KiB

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.tssubagent 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

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

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:

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

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

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:

npx tsx --test src/presets.test.ts src/models.test.ts

Expected:

  • PASS for all preset/model tests

  • Step 5: Commit

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

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

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

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:

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

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

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:

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

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

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

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

assert.equal(saved.preset, "repo-scout");
assert.equal(saved.exitCode, 1);
  • Step 2: Run tests to verify they fail

Run:

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

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

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

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:

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

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

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

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:

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

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

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

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:

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

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

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

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

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:

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

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

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

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

## 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

---
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:

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:

npm test

Expected:

  • PASS with 0 failures

Then commit:

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