Files
pi-subagents/docs/superpowers/plans/2026-04-12-generic-subagents.md

42 KiB

Generic Subagents 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: Replace named and specialized subagents with generic child Pi sessions that accept only task, model, and optional cwd, while preserving runner behavior, model registration, and artifact guarantees.

Architecture: Keep the existing process/tmux runner pipeline and model-registration logic, but simplify the public schema, orchestration layer, and artifact metadata so no runtime path depends on agent discovery, role prompts, or tool restrictions. Remove the obsolete discovery modules only after the generic contract, wrapper, and docs are updated and passing.

Tech Stack: TypeScript, Node.js, node:test, @sinclair/typebox, Pi extension API, wrapper scripts in src/wrapper/


Execution context: Run this plan from the dedicated feature worktree for this change, not /home/dev/projects/pi-subagents.

File structure and responsibilities

  • src/schema.ts — public tool schema plus SubagentRunResult / SubagentToolDetails types
  • src/tool.ts — runtime validation, mode dispatch, progress updates, parallel summaries, chain {previous} substitution
  • src/artifacts.ts — creates .pi/subagents/runs/<runId>/ and serializes meta.json
  • src/process-runner.ts — process-launch failure result shape
  • src/wrapper/cli.mjs — spawns pi, forwards env/argv, writes result artifacts
  • src/wrapper/render.mjs — transcript header/event rendering only
  • prompts/*.md — parent-side workflow helpers; keep filenames stable, rewrite contents to generic subagents
  • README.md — package-facing documentation
  • Remove after migration: src/builtin-agents.ts, src/agents.ts, src/agents.test.ts

Task 1: Simplify the public tool contract and orchestration

Files:

  • Modify: src/schema.ts

  • Modify: src/tool.ts

  • Modify: src/tool.test.ts

  • Modify: src/tool-parallel.test.ts

  • Modify: src/tool-chain.test.ts

  • Modify: src/extension.test.ts

  • Test: src/tool.test.ts

  • Test: src/tool-parallel.test.ts

  • Test: src/tool-chain.test.ts

  • Test: src/extension.test.ts

  • Step 1: Rewrite the contract/orchestration tests to use generic tasks instead of agent names

Apply these edits first.

src/tool.test.ts

@@
 test("single-mode subagent uses the required top-level model, emits progress, and returns final text plus metadata", async () => {
   const updates: string[] = [];
 
   const tool = createSubagentTool({
-    discoverAgents: () => ({
-      agents: [
-        {
-          name: "scout",
-          description: "Scout",
-          model: "claude-haiku-4-5",
-          systemPrompt: "Scout prompt",
-          source: "builtin",
-        },
-      ],
-      projectAgentsDir: null,
-    }),
     runSingleTask: async ({ onEvent, meta }: any) => {
       onEvent?.({ type: "tool_call", toolName: "read", args: { path: "src/auth.ts" } });
       return {
         runId: "run-1",
-        agent: "scout",
         task: "inspect auth",
         requestedModel: meta.requestedModel,
         resolvedModel: meta.resolvedModel,
@@
   const result = await tool.execute(
     "tool-1",
     {
-      agent: "scout",
       task: "inspect auth",
       model: "anthropic/claude-sonnet-4-5",
     },
@@
   const text = result.content[0]?.type === "text" ? result.content[0].text : "";
   assert.equal(text, "Auth code is in src/auth.ts");
+  assert.equal(result.details.mode, "single");
+  assert.equal(result.details.results[0]?.task, "inspect auth");
   assert.equal(result.details.results[0]?.paneId, "%3");
   assert.equal(result.details.results[0]?.requestedModel, "anthropic/claude-sonnet-4-5");
-  assert.match(updates.join("\n"), /Running scout/);
+  assert.equal("agent" in (result.details.results[0] ?? {}), false);
+  assert.match(updates.join("\n"), /Running subagent/);
 });
@@
   const tool = createSubagentTool({
-    discoverAgents: () => ({
-      agents: [{ name: "scout", description: "Scout", systemPrompt: "Scout prompt", source: "builtin" }],
-      projectAgentsDir: null,
-    }),
     runSingleTask: async () => {
       didRun = true;
       throw new Error("should not run");
@@
   const tool = createSubagentTool({
-    discoverAgents: () => ({
-      agents: [{ name: "scout", description: "Scout", systemPrompt: "Scout prompt", source: "builtin" }],
-      projectAgentsDir: null,
-    }),
     runSingleTask: async () => {
       didRun = true;
       throw new Error("should not run");
@@
-test("single-mode subagent asks before running a project-local agent", async () => {
-  const tool = createSubagentTool({
-    discoverAgents: () => ({
-      agents: [
-        {
-          name: "reviewer",
-          description: "Reviewer",
-          systemPrompt: "Review prompt",
-          source: "project",
-        },
-      ],
-      projectAgentsDir: "/repo/.pi/agents",
-    }),
-    runSingleTask: async () => {
-      throw new Error("should not run");
-    },
-  } as any);
+test("subagent rejects requests that combine single and parallel modes", async () => {
+  const tool = createSubagentTool();
 
   const result = await tool.execute(
     "tool-1",
     {
-      agent: "reviewer",
-      task: "review auth",
+      task: "inspect auth",
       model: "anthropic/claude-sonnet-4-5",
-      agentScope: "both",
+      tasks: [{ task: "review auth" }],
     },
     undefined,
     undefined,
     {
       cwd: "/repo",
       modelRegistry: {
         getAvailable: () => [{ provider: "anthropic", id: "claude-sonnet-4-5" }],
       },
-      hasUI: true,
-      ui: { confirm: async () => false },
+      hasUI: false,
     } as any,
   );
 
   assert.equal(result.isError, true);
-  assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /not approved/);
+  assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /exactly one mode/i);
 });

src/tool-parallel.test.ts

@@
 test("parallel mode runs each task and uses the top-level model unless a task overrides it", async () => {
   const requestedModels: Array<string | undefined> = [];
 
   const tool = createSubagentTool({
-    discoverAgents: () => ({
-      agents: [
-        { name: "scout", description: "Scout", source: "builtin", systemPrompt: "Scout prompt" },
-        { name: "reviewer", description: "Reviewer", source: "builtin", systemPrompt: "Reviewer prompt" },
-      ],
-      projectAgentsDir: null,
-    }),
     resolveChildModel: ({ taskModel, topLevelModel }: any) => ({
       requestedModel: taskModel ?? topLevelModel,
       resolvedModel: taskModel ?? topLevelModel,
     }),
     runSingleTask: async ({ meta }: any) => {
       requestedModels.push(meta.requestedModel);
       return {
-        runId: `${meta.agent}-${meta.task}`,
-        agent: meta.agent,
-        agentSource: meta.agentSource,
+        runId: `run-${requestedModels.length}`,
         task: meta.task,
         requestedModel: meta.requestedModel,
         resolvedModel: meta.requestedModel,
         exitCode: 0,
-        finalText: `${meta.agent}:${meta.task}`,
+        finalText: `done:${meta.task}`,
       };
     },
   } as any);
@@
     {
       model: "openai/gpt-5",
       tasks: [
-        { agent: "scout", task: "find auth code" },
-        { agent: "reviewer", task: "review auth code", model: "anthropic/claude-opus-4-5" },
+        { task: "find auth code" },
+        { task: "review auth code", model: "anthropic/claude-opus-4-5" },
       ],
     },
@@
   const text = result.content[0]?.type === "text" ? result.content[0].text : "";
   assert.match(text, /2\/2 succeeded/);
+  assert.match(text, /\[task 1\] completed: done:find auth code/);
+  assert.match(text, /\[task 2\] completed: done:review auth code/);
   assert.deepEqual(requestedModels, ["openai/gpt-5", "anthropic/claude-opus-4-5"]);
 });
@@
   const tool = createSubagentTool({
-    discoverAgents: () => ({
-      agents: [{ name: "scout", description: "Scout", source: "builtin", systemPrompt: "Scout prompt" }],
-      projectAgentsDir: null,
-    }),
     runSingleTask: async () => {
       didRun = true;
       throw new Error("should not run");
@@
     {
       model: "anthropic/claude-sonnet-4-5",
-      tasks: [{ agent: "scout", task: "find auth code", model: "openai/gpt-5" }],
+      tasks: [{ task: "find auth code", model: "openai/gpt-5" }],
     },

src/tool-chain.test.ts

@@
 test("chain mode substitutes {previous} into the next task", async () => {
   const seenTasks: string[] = [];
 
   const tool = createSubagentTool({
-    discoverAgents: () => ({
-      agents: [
-        { name: "scout", description: "Scout", source: "builtin", systemPrompt: "Scout prompt" },
-        { name: "planner", description: "Planner", source: "builtin", systemPrompt: "Planner prompt" },
-      ],
-      projectAgentsDir: null,
-    }),
     runSingleTask: async ({ meta }: any) => {
       seenTasks.push(meta.task);
       return {
-        runId: `${meta.agent}-${seenTasks.length}`,
-        agent: meta.agent,
-        agentSource: meta.agentSource,
+        runId: `run-${seenTasks.length}`,
         task: meta.task,
         exitCode: 0,
-        finalText: meta.agent === "scout" ? "Scout output" : "Plan output",
+        finalText: seenTasks.length === 1 ? "Inspection output" : "Plan output",
       };
     },
   } as any);
@@
     {
       model: "anthropic/claude-sonnet-4-5",
       chain: [
-        { agent: "scout", task: "inspect auth" },
-        { agent: "planner", task: "use this context: {previous}" },
+        { task: "inspect auth" },
+        { task: "use this context: {previous}" },
       ],
     },
@@
-  assert.deepEqual(seenTasks, ["inspect auth", "use this context: Scout output"]);
+  assert.deepEqual(seenTasks, ["inspect auth", "use this context: Inspection output"]);
   assert.equal(result.content[0]?.type === "text" ? result.content[0].text : "", "Plan output");
 });
@@
   const tool = createSubagentTool({
-    discoverAgents: () => ({
-      agents: [
-        { name: "scout", description: "Scout", source: "builtin", systemPrompt: "Scout prompt" },
-        { name: "planner", description: "Planner", source: "builtin", systemPrompt: "Planner prompt" },
-      ],
-      projectAgentsDir: null,
-    }),
     runSingleTask: async ({ meta }: any) => {
-      if (meta.agent === "planner") {
+      if (meta.task.includes("Inspection output")) {
         return {
-          runId: "planner-2",
-          agent: meta.agent,
-          agentSource: meta.agentSource,
+          runId: "run-2",
           task: meta.task,
           exitCode: 1,
           finalText: "",
@@
       return {
-        runId: "scout-1",
-        agent: meta.agent,
-        agentSource: meta.agentSource,
+        runId: "run-1",
         task: meta.task,
         exitCode: 0,
-        finalText: "Scout output",
+        finalText: "Inspection output",
       };
     },
   } as any);
@@
     {
       model: "anthropic/claude-sonnet-4-5",
       chain: [
-        { agent: "scout", task: "inspect auth" },
-        { agent: "planner", task: "use this context: {previous}" },
+        { task: "inspect auth" },
+        { task: "use this context: {previous}" },
       ],
     },
@@
   assert.equal(result.isError, true);
   assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /Chain stopped at step 2/);
 });

src/extension.test.ts

@@
     assert.equal(registeredTools[0]?.name, "subagent");
     assert.deepEqual(registeredTools[0]?.parameters.required, ["model"]);
+    assert.equal("agent" in registeredTools[0]?.parameters.properties, false);
+    assert.equal("agentScope" in registeredTools[0]?.parameters.properties, false);
+    assert.equal("confirmProjectAgents" in registeredTools[0]?.parameters.properties, false);
+    assert.equal("task" in registeredTools[0]?.parameters.properties, true);
     assert.deepEqual(registeredTools[0]?.parameters.properties.model.enum, [
       "anthropic/claude-sonnet-4-5",
       "openai/gpt-5",
     ]);
+    assert.equal("agent" in registeredTools[0]?.parameters.properties.tasks.items.properties, false);
+    assert.equal("task" in registeredTools[0]?.parameters.properties.tasks.items.properties, true);
     assert.deepEqual(registeredTools[0]?.parameters.properties.tasks.items.properties.model.enum, [
       "anthropic/claude-sonnet-4-5",
       "openai/gpt-5",
     ]);
  • Step 2: Run the contract/orchestration tests and verify they fail for the intended reason

Run:

npx tsx --test src/tool.test.ts src/tool-parallel.test.ts src/tool-chain.test.ts src/extension.test.ts

Expected:

  • FAIL

  • failures mention removed agent fields, stale discovery stubs, or stale Agent:/summary expectations

  • no unrelated syntax errors

  • Step 3: Implement the generic schema and runtime orchestration

Apply these edits.

src/schema.ts

@@
 export function createTaskItemSchema(availableModels: readonly string[]) {
   return Type.Object({
-    agent: Type.String({ description: "Name of the agent 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 createChainItemSchema(availableModels: readonly string[]) {
   return Type.Object({
-    agent: Type.String({ description: "Name of the agent to invoke" }),
     task: Type.String({ description: "Task with optional {previous} placeholder" }),
     model: createTaskModelSchema(availableModels),
     cwd: Type.Optional(Type.String({ description: "Optional working directory override" })),
   });
 }
@@
-export const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
-  description: "Which markdown agent sources to use",
-  default: "user",
-});
-
 export function createSubagentParamsSchema(availableModels: readonly string[]) {
   return Type.Object({
-    agent: Type.Optional(Type.String({ description: "Single-mode agent name" })),
     task: Type.Optional(Type.String({ description: "Single-mode delegated task" })),
     model: StringEnum(availableModels, {
       description: "Required top-level child model. Must be one of the currently available models.",
     }),
     tasks: Type.Optional(Type.Array(createTaskItemSchema(availableModels), { description: "Parallel tasks" })),
     chain: Type.Optional(Type.Array(createChainItemSchema(availableModels), { description: "Sequential tasks" })),
-    agentScope: Type.Optional(AgentScopeSchema),
-    confirmProjectAgents: Type.Optional(Type.Boolean({ default: true })),
     cwd: Type.Optional(Type.String({ description: "Single-mode working directory override" })),
   });
 }
@@
 export type TaskItem = Static<typeof TaskItemSchema>;
 export type ChainItem = Static<typeof ChainItemSchema>;
 export type SubagentParams = Static<typeof SubagentParamsSchema>;
-export type AgentScope = Static<typeof AgentScopeSchema>;
 
 export interface SubagentRunResult {
   runId: string;
-  agent: string;
-  agentSource: "builtin" | "user" | "project" | "unknown";
   task: string;
   requestedModel?: string;
   resolvedModel?: string;
@@
 export interface SubagentToolDetails {
   mode: "single" | "parallel" | "chain";
-  agentScope: AgentScope;
-  projectAgentsDir: string | null;
   results: SubagentRunResult[];
 }

src/tool.ts

@@
 import { Text } from "@mariozechner/pi-tui";
-import { discoverAgents } from "./agents.ts";
 import {
   listAvailableModelReferences,
   normalizeAvailableModelReference,
   resolveChildModel,
 } from "./models.ts";
 import {
   SubagentParamsSchema,
-  type AgentScope,
   type SubagentRunResult,
   type SubagentToolDetails,
 } from "./schema.ts";
@@
 function makeDetails(
   mode: "single" | "parallel" | "chain",
-  agentScope: AgentScope,
-  projectAgentsDir: string | null,
   results: SubagentRunResult[],
 ): SubagentToolDetails {
-  return { mode, agentScope, projectAgentsDir, results };
+  return { mode, results };
 }
@@
 function makeErrorResult(
   text: string,
   mode: "single" | "parallel" | "chain",
-  agentScope: AgentScope,
-  projectAgentsDir: string | null,
 ) {
   return {
     content: [{ type: "text" as const, text }],
-    details: makeDetails(mode, agentScope, projectAgentsDir, []),
+    details: makeDetails(mode, []),
     isError: true,
   };
 }
@@
 export function createSubagentTool(deps: {
-  discoverAgents?: typeof discoverAgents;
   listAvailableModelReferences?: typeof listAvailableModelReferences;
   normalizeAvailableModelReference?: typeof normalizeAvailableModelReference;
   parameters?: typeof SubagentParamsSchema;
@@
     name: "subagent",
     label: "Subagent",
-    description: "Delegate tasks to specialized agents running in separate child sessions.",
+    description: "Delegate tasks to generic subagents running in separate child sessions.",
     parameters: deps.parameters ?? SubagentParamsSchema,
     async execute(_toolCallId: string, params: any, _signal: AbortSignal | undefined, onUpdate: any, ctx: any) {
-      const hasSingle = Boolean(params.agent && params.task);
+      const hasSingle = Boolean(params.task);
       const hasParallel = Boolean(params.tasks?.length);
       const hasChain = Boolean(params.chain?.length);
       const modeCount = Number(hasSingle) + Number(hasParallel) + Number(hasChain);
       const mode = hasParallel ? "parallel" : hasChain ? "chain" : "single";
-      const agentScope = (params.agentScope ?? "user") as AgentScope;
 
       if (modeCount !== 1) {
-        return makeErrorResult("Provide exactly one mode: single, parallel, or chain.", "single", agentScope, null);
+        return makeErrorResult("Provide exactly one mode: single, parallel, or chain.", "single");
       }
 
-      const discovery = (deps.discoverAgents ?? discoverAgents)(ctx.cwd, { scope: agentScope });
       const availableModelReferences = (deps.listAvailableModelReferences ?? listAvailableModelReferences)(ctx.modelRegistry);
       const availableModelsText = availableModelReferences.join(", ") || "(none)";
       const normalizeModelReference = (requestedModel?: string) =>
         (deps.normalizeAvailableModelReference ?? normalizeAvailableModelReference)(requestedModel, availableModelReferences);
 
       if (availableModelReferences.length === 0) {
         return makeErrorResult(
           "No available models are configured. Configure at least one model before using subagent.",
-          mode,
-          agentScope,
-          discovery.projectAgentsDir,
+          mode,
         );
       }
@@
         const message =
           typeof params.model !== "string" || params.model.trim().length === 0
             ? `Subagent requires a top-level model chosen from the available models: ${availableModelsText}`
             : `Invalid top-level model "${params.model}". Choose one of the available models: ${availableModelsText}`;
-        return makeErrorResult(message, mode, agentScope, discovery.projectAgentsDir);
+        return makeErrorResult(message, mode);
       }
@@
           return makeErrorResult(
-            `Invalid model for parallel task ${index + 1} (${task.agent}): "${task.model}". Choose one of the available models: ${availableModelsText}`,
-            mode,
-            agentScope,
-            discovery.projectAgentsDir,
+            `Invalid model for parallel task ${index + 1}: "${task.model}". Choose one of the available models: ${availableModelsText}`,
+            mode,
           );
         }
@@
           return makeErrorResult(
-            `Invalid model for chain step ${index + 1} (${step.agent}): "${step.model}". Choose one of the available models: ${availableModelsText}`,
-            mode,
-            agentScope,
-            discovery.projectAgentsDir,
+            `Invalid model for chain step ${index + 1}: "${step.model}". Choose one of the available models: ${availableModelsText}`,
+            mode,
           );
         }
         step.model = normalizedStepModel;
       }
-
-      const requestedAgentNames = [
-        ...(hasSingle ? [params.agent] : []),
-        ...((params.tasks ?? []).map((task: any) => task.agent)),
-        ...((params.chain ?? []).map((step: any) => step.agent)),
-      ];
-      const projectAgents = requestedAgentNames
-        .map((name) => discovery.agents.find((candidate) => candidate.name === name))
-        .filter((agent): agent is NonNullable<typeof agent> => Boolean(agent && agent.source === "project"));
-
-      if (projectAgents.length > 0 && (params.confirmProjectAgents ?? true) && ctx.hasUI) {
-        const ok = await ctx.ui.confirm(
-          "Run project-local agents?",
-          `Agents: ${projectAgents.map((agent) => agent.name).join(", ")}\nSource: ${
-            discovery.projectAgentsDir ?? "(unknown)"
-          }`,
-        );
-        if (!ok) {
-          return makeErrorResult(
-            "Canceled: project-local agents not approved.",
-            mode,
-            agentScope,
-            discovery.projectAgentsDir,
-          );
-        }
-      }
-
-      const resolveAgent = (name: string) => {
-        const agent = discovery.agents.find((candidate) => candidate.name === name);
-        if (!agent) throw new Error(`Unknown agent: ${name}`);
-        return agent;
-      };
 
       const runTask = async (input: {
-        agentName: string;
         task: string;
         cwd?: string;
         taskModel?: string;
@@
-        const agent = resolveAgent(input.agentName);
         const model = (deps.resolveChildModel ?? resolveChildModel)({
           taskModel: input.taskModel,
           topLevelModel: params.model,
         });
@@
           cwd: input.cwd ?? ctx.cwd,
           onEvent(event) {
             onUpdate?.({
-              content: [{ type: "text", text: `Running ${input.agentName}: ${event.type}` }],
-              details: makeDetails(input.mode, agentScope, discovery.projectAgentsDir, []),
+              content: [{ type: "text", text: `Running subagent: ${event.type}` }],
+              details: makeDetails(input.mode, []),
             });
           },
           meta: {
             mode: input.mode,
             taskIndex: input.taskIndex,
             step: input.step,
-            agent: agent.name,
-            agentSource: agent.source,
             task: input.task,
             cwd: input.cwd ?? ctx.cwd,
             requestedModel: model.requestedModel,
             resolvedModel: model.resolvedModel,
-            systemPrompt: agent.systemPrompt,
-            tools: agent.tools,
           },
         }) as Promise<SubagentRunResult>;
       };
@@
           const result = await runTask({
-            agentName: params.agent,
             task: params.task,
             cwd: params.cwd,
             mode: "single",
@@
             content: [{ type: "text" as const, text: result.finalText }],
-            details: makeDetails("single", agentScope, discovery.projectAgentsDir, [result]),
+            details: makeDetails("single", [result]),
             isError: isFailure(result),
           };
         } catch (error) {
           return {
             content: [{ type: "text" as const, text: (error as Error).message }],
-            details: makeDetails("single", agentScope, discovery.projectAgentsDir, []),
+            details: makeDetails("single", []),
             isError: true,
           };
         }
@@
             content: [
               {
                 type: "text" as const,
                 text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
               },
             ],
-            details: makeDetails("parallel", agentScope, discovery.projectAgentsDir, []),
+            details: makeDetails("parallel", []),
             isError: true,
           };
         }
@@
         const liveResults: SubagentRunResult[] = [];
         const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (task: any, index) => {
           const result = await runTask({
-            agentName: task.agent,
             task: task.task,
             cwd: task.cwd,
             taskModel: task.model,
@@
           onUpdate?.({
             content: [{ type: "text", text: `Parallel: ${liveResults.filter(Boolean).length}/${params.tasks.length} finished` }],
-            details: makeDetails("parallel", agentScope, discovery.projectAgentsDir, liveResults.filter(Boolean)),
+            details: makeDetails("parallel", liveResults.filter(Boolean)),
           });
           return result;
         });
 
         const successCount = results.filter((result) => !isFailure(result)).length;
         const summary = results
-          .map((result) => `[${result.agent}] ${isFailure(result) ? "failed" : "completed"}: ${result.finalText || "(no output)"}`)
+          .map((result, index) => `[task ${index + 1}] ${isFailure(result) ? "failed" : "completed"}: ${result.finalText || "(no output)"}`)
           .join("\n\n");
 
         return {
           content: [{ type: "text" as const, text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summary}` }],
-          details: makeDetails("parallel", agentScope, discovery.projectAgentsDir, results),
+          details: makeDetails("parallel", results),
           isError: successCount !== results.length,
         };
       }
@@
       for (let index = 0; index < params.chain.length; index += 1) {
         const item = params.chain[index];
         const task = item.task.replaceAll("{previous}", previous);
         const result = await runTask({
-          agentName: item.agent,
           task,
           cwd: item.cwd,
           taskModel: item.model,
@@
         onUpdate?.({
           content: [{ type: "text", text: `Chain: completed step ${index + 1}/${params.chain.length}` }],
-          details: makeDetails("chain", agentScope, discovery.projectAgentsDir, [...results, result]),
+          details: makeDetails("chain", [...results, result]),
         });
         results.push(result);
         if (isFailure(result)) {
           return {
             content: [
               {
                 type: "text" as const,
-                text: `Chain stopped at step ${index + 1} (${item.agent}): ${result.finalText || result.stopReason || "failed"}`,
+                text: `Chain stopped at step ${index + 1}: ${result.finalText || result.stopReason || "failed"}`,
               },
             ],
-            details: makeDetails("chain", agentScope, discovery.projectAgentsDir, results),
+            details: makeDetails("chain", results),
             isError: true,
           };
         }
@@
       return {
         content: [{ type: "text" as const, text: finalResult?.finalText ?? "" }],
-        details: makeDetails("chain", agentScope, discovery.projectAgentsDir, results),
+        details: makeDetails("chain", results),
       };
     },
     renderCall(args: any) {
       if (args.tasks?.length) return new Text(`subagent parallel (${args.tasks.length} tasks)`, 0, 0);
       if (args.chain?.length) return new Text(`subagent chain (${args.chain.length} steps)`, 0, 0);
-      return new Text(`subagent ${args.agent ?? ""}`.trim(), 0, 0);
+      return new Text("subagent", 0, 0);
     },
  • Step 4: Run the same test set again and verify it is green

Run:

npx tsx --test src/tool.test.ts src/tool-parallel.test.ts src/tool-chain.test.ts src/extension.test.ts

Expected:

  • PASS

  • no references to agentScope, confirmProjectAgents, scout, planner, reviewer, or worker

  • Step 5: Commit the contract/orchestration change

Run:

git add src/schema.ts src/tool.ts src/tool.test.ts src/tool-parallel.test.ts src/tool-chain.test.ts src/extension.test.ts
git commit -m "refactor: simplify subagent tool contract"

Task 2: Remove role metadata from artifacts, wrapper, and transcript rendering

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 artifact/wrapper tests that remove agent and prompt assumptions

Apply these edits first.

src/artifacts.test.ts

@@
 test("createRunArtifacts writes metadata and reserves stable artifact paths", async () => {
   const cwd = await mkdtemp(join(tmpdir(), "pi-subagents-run-"));
 
   const artifacts = await createRunArtifacts(cwd, {
     runId: "run-1",
     task: "inspect auth",
-    systemPrompt: "You are scout",
   });
 
   assert.equal(artifacts.runId, "run-1");
   assert.match(artifacts.dir, /\.pi\/subagents\/runs\/run-1$/);
-  assert.equal(JSON.parse(await readFile(artifacts.metaPath, "utf8")).task, "inspect auth");
-  assert.equal(await readFile(artifacts.systemPromptPath, "utf8"), "You are scout");
+  const meta = JSON.parse(await readFile(artifacts.metaPath, "utf8"));
+  assert.equal(meta.task, "inspect auth");
+  assert.equal("systemPromptPath" in meta, false);
+  await assert.rejects(readFile(join(artifacts.dir, "system-prompt.md"), "utf8"));
 });

src/process-runner.test.ts

@@
             {
               runId: meta.runId,
               mode: meta.mode,
-              agent: meta.agent,
-              agentSource: meta.agentSource,
               task: meta.task,
               requestedModel: meta.requestedModel,
               resolvedModel: meta.resolvedModel,
@@
     meta: {
       mode: "single",
-      agent: "scout",
-      agentSource: "builtin",
       task: "inspect auth",
       requestedModel: "openai/gpt-5",
       resolvedModel: "openai/gpt-5",
@@
     meta: {
       mode: "single",
-      agent: "scout",
-      agentSource: "builtin",
       task: "inspect auth",
       requestedModel: "openai/gpt-5",
       resolvedModel: "openai/gpt-5",
@@
   const saved = JSON.parse(await readFile(result.resultPath!, "utf8"));
   assert.equal(saved.exitCode, 1);
+  assert.equal("agent" in saved, false);
   assert.match(saved.errorMessage ?? "", /spawn boom/);
 });

src/wrapper/render.test.ts

@@
 test("renderHeader prints generic subagent metadata", () => {
   const header = renderHeader({
-    agent: "scout",
     task: "Inspect authentication code",
     cwd: "/repo",
     requestedModel: "anthropic/claude-sonnet-4-5",
@@
 
   assert.match(header, /^=== subagent ===/m);
-  assert.match(header, /Agent: scout/);
+  assert.doesNotMatch(header, /Agent:/);
   assert.match(header, /Task: Inspect authentication code/);
   assert.match(header, /Session: \/repo\/\.pi\/subagents\/runs\/run-1\/child-session\.jsonl/);
 });

src/wrapper/cli.test.ts

@@
       {
         runId: "run-1",
         mode: "single",
-        agent: "scout",
-        agentSource: "builtin",
         task: "inspect auth",
         cwd: dir,
         requestedModel,
@@
         stdoutPath: join(dir, "stdout.log"),
         stderrPath: join(dir, "stderr.log"),
         transcriptPath: join(dir, "transcript.log"),
+        tools: ["read", "grep"],
         systemPromptPath: join(dir, "system-prompt.md"),
       },
@@
 test("wrapper marks anthropic child run as a subagent child", async () => {
   const captured = await runWrapperWithFakePi("anthropic/claude-sonnet-4-5");
   assert.equal(captured.flags.PI_SUBAGENTS_CHILD, "1");
 });
+
+test("wrapper ignores stale tool and system prompt metadata", async () => {
+  const captured = await runWrapperWithFakePi("anthropic/claude-sonnet-4-5");
+  assert.equal(captured.flags.argv.includes("--tools"), false);
+  assert.equal(captured.flags.argv.includes("--append-system-prompt"), false);
+});
@@
       {
         runId: "run-1",
         mode: "single",
-        agent: "scout",
-        agentSource: "builtin",
         task: "inspect auth",
         cwd: dir,
@@
   const result = JSON.parse(await readFile(resultPath, "utf8"));
   assert.equal(result.runId, "run-1");
-  assert.equal(result.agent, "scout");
+  assert.equal(result.task, "inspect auth");
+  assert.equal("agent" in result, false);
   assert.equal(result.exitCode, 1);
   assert.match(result.errorMessage ?? "", /ENOENT|not found|spawn pi/i);
 });

In the remaining three inline meta fixtures later in src/wrapper/cli.test.ts (the wrapper does not exit early on non-terminal toolUse assistant messages, wrapper exits and writes result.json after terminal output even if the pi child lingers, and wrapper still writes result.json when transcript/stdout artifact writes fail tests), delete exactly these two lines from each JSON object:

-        agent: "scout",
-        agentSource: "builtin",
  • Step 2: Run the artifact/wrapper tests and confirm they fail for the expected reasons

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

  • failures mention systemPromptPath, Agent: header text, --tools / --append-system-prompt, or stale agent fields

  • no unrelated failures

  • Step 3: Remove prompt/tool injection and role metadata from production code

Apply these edits.

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 },
+  meta: Record<string, unknown> & { runId?: string },
 ): Promise<RunArtifacts> {
@@
     stderrPath: join(dir, "stderr.log"),
     transcriptPath: join(dir, "transcript.log"),
     sessionPath: join(dir, "child-session.jsonl"),
-    systemPromptPath: join(dir, "system-prompt.md"),
   };
@@
         stdoutPath: artifacts.stdoutPath,
         stderrPath: artifacts.stderrPath,
         transcriptPath: artifacts.transcriptPath,
-        systemPromptPath: artifacts.systemPromptPath,
       },
       null,
       2,
     ),
     "utf8",
   );
 
-  await writeFile(artifacts.systemPromptPath, typeof meta.systemPrompt === "string" ? meta.systemPrompt : "", "utf8");
   await writeFile(artifacts.eventsPath, "", "utf8");
   await writeFile(artifacts.stdoutPath, "", "utf8");
   await writeFile(artifacts.stderrPath, "", "utf8");

src/process-runner.ts

@@
   return {
     runId: artifacts.runId,
     mode: meta.mode,
     taskIndex: meta.taskIndex,
     step: meta.step,
-    agent: meta.agent,
-    agentSource: meta.agentSource,
     task: meta.task,
     cwd,
     requestedModel: meta.requestedModel,

src/wrapper/cli.mjs

@@
   return {
     runId: meta.runId,
     mode: meta.mode,
     taskIndex: meta.taskIndex,
     step: meta.step,
-    agent: meta.agent,
-    agentSource: meta.agentSource,
     task: meta.task,
     cwd: meta.cwd,
@@
   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 ===",
-    `Agent: ${meta.agent}`,
     `Task: ${meta.task}`,
     `CWD: ${meta.cwd}`,
     `Requested model: ${meta.requestedModel ?? "(default)"}`,
  • Step 4: Re-run the artifact/wrapper tests and 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

  • every child run still has PI_SUBAGENTS_CHILD=1

  • GitHub Copilot initiator handling still passes

  • no Agent: header line remains

  • result.json still writes even when artifact writes fail

  • Step 5: Commit the artifact/wrapper cleanup

Run:

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 "refactor: remove role metadata from subagent artifacts"

Task 3: Rewrite prompts/docs and remove obsolete discovery files

Files:

  • 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: src/package-manifest.test.ts

  • Delete: src/builtin-agents.ts

  • Delete: src/agents.ts

  • Delete: src/agents.test.ts

  • Test: src/prompts.test.ts

  • Test: src/package-manifest.test.ts

  • Test: full suite npm test

  • Step 1: Add failing prompt/doc tests that enforce generic wording

Apply these edits first.

src/prompts.test.ts

@@
   assert.deepEqual(packageJson.pi.extensions, ["./index.ts"]);
   assert.deepEqual(packageJson.pi.prompts, ["./prompts/*.md"]);
 
   for (const name of ["implement.md", "scout-and-plan.md", "implement-and-review.md"]) {
     const content = readFileSync(join(packageRoot, "prompts", name), "utf8");
     assert.match(content, /^---\ndescription:/m);
     assert.match(content, /subagent/);
+    assert.doesNotMatch(content, /\b(?:scout|planner|reviewer|worker)\b/);
   }
 });

src/package-manifest.test.ts

@@
 test("README documents local install, git install, and tmux PATH requirement", () => {
   const readme = readFileSync(resolve(packageRoot, "README.md"), "utf8");
   assert.match(readme, /pi install \/absolute\/path\/to\/subagents/);
   assert.match(readme, /pi install https:\/\/gitea\.rwiesner\.com\/pi\/pi-subagents/);
+  assert.match(readme, /generic subagent/i);
+  assert.doesNotMatch(readme, /specialized built-in roles/i);
+  assert.doesNotMatch(readme, /markdown agent discovery/i);
   assert.match(readme, /tmux.*PATH/i);
 });
  • Step 2: Run the prompt/doc tests and confirm they fail because the shipped text is still role-based

Run:

npx tsx --test src/prompts.test.ts src/package-manifest.test.ts

Expected:

  • FAIL

  • prompt tests fail on scout, planner, reviewer, or worker

  • README test fails because it still describes specialized behavior

  • Step 3: Rewrite the shipped prompts, refresh README, and delete obsolete discovery files

Replace README.md with:

# pi-subagents

`pi-subagents` is a Pi extension package that runs generic subagent tasks in separate child sessions and ships workflow prompt templates plus the wrapper code needed to execute those runs.

## Install

Local path:

```bash
pi install /absolute/path/to/subagents
```

Git:

```bash
pi install https://gitea.rwiesner.com/pi/pi-subagents
```

## Resources

- Extension: `./index.ts`
- Prompts: `./prompts/*.md`

## Runner modes

- default: background child process runner
- optional tmux runner: set `{"runner":"tmux"}` in `.pi/subagents.json` or `~/.pi/agent/subagents.json`

## What a subagent run gets

- delegated task text
- selected model
- optional working directory

Child runs are normal Pi sessions. This package does not add built-in role behavior, markdown-discovered subagents, per-agent tool restrictions, or appended role prompts.

## Requirements

- default process runner: no tmux requirement
- optional tmux runner: `tmux` must be available on `PATH`

## Development

```bash
npm install
npm test
```

Replace prompts/scout-and-plan.md with:

---
description: Inspect the codebase, then produce a plan using generic subagents
---
Use the `subagent` tool in chain mode:

1. Run a generic subagent to inspect the codebase for: $@
2. Run a generic subagent to turn `{previous}` into a concrete implementation plan for: $@

User request: $@

Replace prompts/implement.md with:

---
description: Inspect, plan, and implement using generic subagents
---
Use the `subagent` tool in chain mode:

1. Run a generic subagent to inspect the codebase for: $@
2. Run a generic subagent to turn `{previous}` into a concrete implementation plan for: $@
3. Run a generic subagent to implement the approved plan using `{previous}`

User request: $@

Replace prompts/implement-and-review.md with:

---
description: Implement, review, then revise using generic subagents
---
Use the `subagent` tool in chain mode:

1. Run a generic subagent to implement: $@
2. Run a generic subagent to review `{previous}` and identify issues
3. Run a generic subagent to revise the implementation using `{previous}`

User request: $@

Delete the obsolete discovery modules and their test:

git rm src/builtin-agents.ts src/agents.ts src/agents.test.ts
  • Step 4: Run the prompt/doc tests, search for stale role names, then run the full suite

Run:

npx tsx --test src/prompts.test.ts src/package-manifest.test.ts

Expected:

  • PASS

Run:

grep -RIn "scout\|planner\|reviewer\|worker" src prompts README.md --exclude-dir=.git

Expected:

  • no matches

Run:

npm test

Expected:

  • PASS
  • registration regressions still pass
  • wrapper child env regressions still pass
  • package-manifest tests still pass

Run:

npm pack --dry-run --json

Expected:

  • PASS

  • JSON output still includes index.ts, prompts/implement.md, prompts/implement-and-review.md, prompts/scout-and-plan.md, and src/wrapper/cli.mjs

  • JSON output still excludes *.test.ts

  • Step 5: Commit the docs/prompt cleanup and final verified state

Run:

git add README.md prompts/scout-and-plan.md prompts/implement.md prompts/implement-and-review.md src/prompts.test.ts src/package-manifest.test.ts
git commit -m "refactor: switch pi-subagents to generic subagents"

After the commit, verify the tree is clean:

git status --short

Expected:

  • no output