# pi-subagents Process Runner Migration 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:** Rename extension to `pi-subagents`, default to background process runner, keep tmux as opt-in via config, and preserve inspectable run artifacts plus child env/model behavior across both runners. **Architecture:** Introduce small config loader for `~/.pi/agent/subagents.json` and `.pi/subagents.json`, plus shared runner selector that chooses between new process runner and tmux runner. Keep artifact creation, file-based monitoring, model resolution, and wrapper logic shared; isolate launch mechanics in `src/process-runner.ts` and `src/tmux-runner.ts`. Do clean-break rename of package/env/docs/comments to `pi-subagents` / `PI_SUBAGENTS_*`. **Tech Stack:** TypeScript, Node.js `child_process` and `fs/promises`, `tsx --test`, Pi extension API, JSON config files --- ## Worktree and file structure This plan assumes work happens in dedicated worktree already created during brainstorming. **Create** - `src/config.ts` — read global/project `subagents.json`, validate `runner`, return effective config - `src/config.test.ts` — config precedence/default/validation tests - `src/process-runner.ts` — spawn wrapper as background child process, write fallback error `result.json` if launch fails before wrapper starts - `src/process-runner.test.ts` — process runner success + launch-failure tests - `src/tmux-runner.ts` — existing tmux launch logic moved out of `src/runner.ts` **Modify** - `package.json` — rename package to `pi-subagents` - `README.md` — document process default, optional tmux config, remove hard tmux requirement - `prompts/scout-and-plan.md` - `prompts/implement.md` - `prompts/implement-and-review.md` — remove `tmux-backed` wording - `index.ts` — rename env vars, create both runners, select runner from config per task, keep model-based re-registration behavior - `src/runner.ts` — shared `RunSingleTask` types + config-based runner selector - `src/runner.test.ts` — runner selector tests - `src/tmux.ts` — keep tmux helper functions; only tmux-specific code should mention tmux requirements - `src/tool.ts` — generic tool description, still runner-agnostic - `src/schema.ts` — align result type with wrapper/process-runner fields (`transcriptPath`, `errorMessage`) - `src/extension.test.ts` — env rename + extension registration regressions - `src/package-manifest.test.ts` — package rename assertions - `src/artifacts.test.ts`, `src/agents.test.ts`, `src/monitor.test.ts`, `src/tmux.test.ts` — clean-break naming updates where strings/prefixes mention old package name - `src/wrapper/cli.mjs` — rename env vars, preserve resolved-model behavior, preserve `result.json` on log/write failures - `src/wrapper/cli.test.ts` — child env rename, resolved-model tests, launch-failure test, logging-failure test - `src/wrapper/render.mjs`, `src/wrapper/render.test.ts` — generic header text ### Task 1: Rename package identity and generic user-facing copy **Files:** - Modify: `package.json` - Modify: `README.md` - Modify: `prompts/scout-and-plan.md` - Modify: `prompts/implement.md` - Modify: `prompts/implement-and-review.md` - Modify: `src/package-manifest.test.ts` - Modify: `src/wrapper/render.mjs` - Modify: `src/wrapper/render.test.ts` - Modify: `src/tool.ts` - [ ] **Step 1: Write the failing rename tests** Update `src/package-manifest.test.ts` to expect new package name: ```ts import test from "node:test"; import assert from "node:assert/strict"; import { existsSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const pkg = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8")); test("package.json exposes pi-subagents as a standalone pi package", () => { assert.equal(pkg.name, "pi-subagents"); assert.equal(pkg.type, "module"); assert.ok(Array.isArray(pkg.keywords)); assert.ok(pkg.keywords.includes("pi-package")); assert.deepEqual(pkg.pi, { extensions: ["./index.ts"], prompts: ["./prompts/*.md"], }); assert.equal(pkg.peerDependencies["@mariozechner/pi-ai"], "*"); assert.equal(pkg.peerDependencies["@mariozechner/pi-coding-agent"], "*"); assert.equal(pkg.peerDependencies["@mariozechner/pi-tui"], "*"); assert.equal(pkg.peerDependencies["@sinclair/typebox"], "*"); assert.deepEqual(pkg.dependencies ?? {}, {}); assert.equal(pkg.bundledDependencies, undefined); assert.deepEqual(pkg.files, ["index.ts", "src", "prompts"]); assert.ok(existsSync(resolve(packageRoot, "index.ts"))); assert.ok(existsSync(resolve(packageRoot, "src/wrapper/cli.mjs"))); assert.ok(existsSync(resolve(packageRoot, "prompts/implement.md"))); assert.ok(existsSync(resolve(packageRoot, "prompts/implement-and-review.md"))); assert.ok(existsSync(resolve(packageRoot, "prompts/scout-and-plan.md"))); }); ``` Update `src/wrapper/render.test.ts` to expect generic header text: ```ts import test from "node:test"; import assert from "node:assert/strict"; import { renderHeader, renderEventLine } from "./render.mjs"; test("renderHeader prints generic subagent metadata", () => { const header = renderHeader({ agent: "scout", task: "Inspect authentication code", cwd: "/repo", requestedModel: "anthropic/claude-sonnet-4-5", resolvedModel: "anthropic/claude-sonnet-4-5", sessionPath: "/repo/.pi/subagents/runs/run-1/child-session.jsonl", }); assert.match(header, /^=== subagent ===/m); assert.match(header, /Agent: scout/); assert.match(header, /Task: Inspect authentication code/); assert.match(header, /Session: \/repo\/\.pi\/subagents\/runs\/run-1\/child-session\.jsonl/); }); test("renderEventLine makes tool calls readable for subagent transcript output", () => { const line = renderEventLine({ type: "tool_call", toolName: "bash", args: { command: "rg -n authentication src" }, }); assert.equal(line, "$ rg -n authentication src"); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx tsx --test src/package-manifest.test.ts src/wrapper/render.test.ts` Expected: FAIL with old values such as `pi-tmux-subagent` and `=== tmux subagent ===`. - [ ] **Step 3: Write minimal rename implementation** Update `package.json` name: ```json { "name": "pi-subagents" } ``` Update top of `README.md` to generic package + runner wording: ```md # pi-subagents `pi-subagents` is a Pi extension package that runs subagent tasks in separate child sessions and ships the prompts and wrapper code needed to execute those runs. ## Install Use it as a local package root today: pi install /absolute/path/to/subagents ## Runner modes - default: `{"runner":"process"}` - optional tmux: `{"runner":"tmux"}` in `.pi/subagents.json` or `~/.pi/agent/subagents.json` ## Requirements - default process runner: no tmux requirement - optional tmux runner: `tmux` must be available on `PATH` ``` Update prompt descriptions: ```md # prompts/scout-and-plan.md description: Scout the codebase, then produce a plan using subagents # prompts/implement.md description: Scout, plan, and implement using subagents # prompts/implement-and-review.md description: Implement, review, then revise using subagents ``` Update `src/wrapper/render.mjs` header: ```js export function renderHeader(meta) { return [ "=== subagent ===", `Agent: ${meta.agent}`, `Task: ${meta.task}`, `CWD: ${meta.cwd}`, `Requested model: ${meta.requestedModel ?? "(default)"}`, `Resolved model: ${meta.resolvedModel ?? "(pending)"}`, `Session: ${meta.sessionPath}`, "---------------------", ].join("\n"); } ``` Update `src/tool.ts` description: ```ts description: "Delegate tasks to specialized agents running in separate child sessions.", ``` - [ ] **Step 4: Run tests and grep old user-facing strings** Run: `npx tsx --test src/package-manifest.test.ts src/wrapper/render.test.ts` Expected: PASS. Run: `rg -n "pi-tmux-subagent|tmux-backed subagents|=== tmux subagent ===" README.md package.json prompts src/tool.ts src/wrapper` Expected: no matches. - [ ] **Step 5: Commit** ```bash git add package.json README.md prompts/scout-and-plan.md prompts/implement.md prompts/implement-and-review.md src/package-manifest.test.ts src/wrapper/render.mjs src/wrapper/render.test.ts src/tool.ts git commit -m "chore: rename package to pi-subagents" ``` ### Task 2: Add config loader for default process runner and project-overrides-global precedence **Files:** - Create: `src/config.ts` - Create: `src/config.test.ts` - [ ] **Step 1: Write the failing config tests** Create `src/config.test.ts`: ```ts import test from "node:test"; import assert from "node:assert/strict"; import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { loadSubagentsConfig } from "./config.ts"; async function makeFixture() { const root = await mkdtemp(join(tmpdir(), "pi-subagents-config-")); const homeDir = join(root, "home"); const cwd = join(root, "repo"); await mkdir(join(homeDir, ".pi", "agent"), { recursive: true }); await mkdir(join(cwd, ".pi"), { recursive: true }); return { root, homeDir, cwd }; } test("loadSubagentsConfig defaults to process when no config files exist", async () => { const { homeDir, cwd } = await makeFixture(); const config = loadSubagentsConfig(cwd, { homeDir }); assert.equal(config.runner, "process"); assert.equal(config.globalPath, join(homeDir, ".pi", "agent", "subagents.json")); assert.equal(config.projectPath, join(cwd, ".pi", "subagents.json")); }); test("loadSubagentsConfig uses global config when project config is absent", async () => { const { homeDir, cwd } = await makeFixture(); await writeFile( join(homeDir, ".pi", "agent", "subagents.json"), JSON.stringify({ runner: "tmux" }, null, 2), "utf8", ); const config = loadSubagentsConfig(cwd, { homeDir }); assert.equal(config.runner, "tmux"); }); test("loadSubagentsConfig lets project config override global config", async () => { const { homeDir, cwd } = await makeFixture(); await writeFile( join(homeDir, ".pi", "agent", "subagents.json"), JSON.stringify({ runner: "tmux" }, null, 2), "utf8", ); await writeFile( join(cwd, ".pi", "subagents.json"), JSON.stringify({ runner: "process" }, null, 2), "utf8", ); const config = loadSubagentsConfig(cwd, { homeDir }); assert.equal(config.runner, "process"); }); test("loadSubagentsConfig throws clear error for invalid runner values", async () => { const { homeDir, cwd } = await makeFixture(); const projectPath = join(cwd, ".pi", "subagents.json"); await writeFile(projectPath, JSON.stringify({ runner: "fork" }, null, 2), "utf8"); assert.throws( () => loadSubagentsConfig(cwd, { homeDir }), new RegExp(`Invalid runner .*fork.*${projectPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`), ); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx tsx --test src/config.test.ts` Expected: FAIL with `Cannot find module './config.ts'`. - [ ] **Step 3: Write minimal config loader** Create `src/config.ts`: ```ts import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; export type RunnerMode = "process" | "tmux"; export interface SubagentsConfig { runner: RunnerMode; globalPath: string; projectPath: string; } export function getSubagentsConfigPaths(cwd: string, homeDir = homedir()) { return { globalPath: join(homeDir, ".pi", "agent", "subagents.json"), projectPath: resolve(cwd, ".pi", "subagents.json"), }; } function readConfigFile(path: string): { runner?: RunnerMode } | undefined { if (!existsSync(path)) return undefined; let parsed: any; try { parsed = JSON.parse(readFileSync(path, "utf8")); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to parse ${path}: ${message}`); } if (parsed.runner !== undefined && parsed.runner !== "process" && parsed.runner !== "tmux") { throw new Error(`Invalid runner ${JSON.stringify(parsed.runner)} in ${path}. Expected "process" or "tmux".`); } return parsed; } export function loadSubagentsConfig(cwd: string, options: { homeDir?: string } = {}): SubagentsConfig { const { globalPath, projectPath } = getSubagentsConfigPaths(cwd, options.homeDir); const globalConfig = readConfigFile(globalPath) ?? {}; const projectConfig = readConfigFile(projectPath) ?? {}; return { runner: projectConfig.runner ?? globalConfig.runner ?? "process", globalPath, projectPath, }; } ``` - [ ] **Step 4: Run config tests** Run: `npx tsx --test src/config.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/config.ts src/config.test.ts git commit -m "feat: add subagents runner config loader" ``` ### Task 3: Split tmux-specific runner into its own file without behavior change **Files:** - Create: `src/tmux-runner.ts` - Create: `src/tmux-runner.test.ts` - Modify: `src/runner.ts` - Modify: `index.ts` - [ ] **Step 1: Write the failing tmux runner tests and move the old runner test** Move the old runner test file: ```bash git mv src/runner.test.ts src/tmux-runner.test.ts ``` Then make `src/tmux-runner.test.ts` import the new module and add explicit tmux-precondition coverage: ```ts import test from "node:test"; import assert from "node:assert/strict"; import { createTmuxSingleRunner } from "./tmux-runner.ts"; test("createTmuxSingleRunner always kills the pane after monitor completion", async () => { const killed: string[] = []; const runSingleTask = createTmuxSingleRunner({ assertInsideTmux() {}, getCurrentWindowId: async () => "@1", createArtifacts: async () => ({ metaPath: "/tmp/meta.json", runId: "run-1", eventsPath: "/tmp/events.jsonl", resultPath: "/tmp/result.json", sessionPath: "/tmp/child-session.jsonl", stdoutPath: "/tmp/stdout.log", stderrPath: "/tmp/stderr.log", }), buildWrapperCommand: () => "'node' '/wrapper.mjs' '/tmp/meta.json'", createPane: async () => "%9", monitorRun: async () => ({ finalText: "done", exitCode: 0 }), killPane: async (paneId: string) => { killed.push(paneId); }, }); const result = await runSingleTask({ cwd: "/repo", meta: { task: "inspect auth" } as any }); assert.equal(result.paneId, "%9"); assert.equal(result.finalText, "done"); assert.deepEqual(killed, ["%9"]); }); test("createTmuxSingleRunner surfaces explicit tmux precondition errors", async () => { const runSingleTask = createTmuxSingleRunner({ assertInsideTmux() { throw new Error("tmux-backed subagents require pi to be running inside tmux."); }, getCurrentWindowId: async () => "@1", createArtifacts: async () => ({ metaPath: "/tmp/meta.json", runId: "run-1", eventsPath: "/tmp/events.jsonl", resultPath: "/tmp/result.json", sessionPath: "/tmp/child-session.jsonl", stdoutPath: "/tmp/stdout.log", stderrPath: "/tmp/stderr.log", }), buildWrapperCommand: () => "'node' '/wrapper.mjs' '/tmp/meta.json'", createPane: async () => "%9", monitorRun: async () => ({ finalText: "done", exitCode: 0 }), killPane: async () => {}, }); await assert.rejects( () => runSingleTask({ cwd: "/repo", meta: { task: "inspect auth" } as any }), /tmux-backed subagents require pi to be running inside tmux/, ); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx tsx --test src/tmux-runner.test.ts` Expected: FAIL with `Cannot find module './tmux-runner.ts'`. - [ ] **Step 3: Write minimal runner split** Replace `src/runner.ts` with shared runner types only: ```ts import type { SubagentRunResult } from "./schema.ts"; export interface RunSingleTaskInput { cwd: string; meta: Record; onEvent?: (event: any) => void; } export type RunSingleTask = (input: RunSingleTaskInput) => Promise; ``` Create `src/tmux-runner.ts` with existing implementation moved out of `src/runner.ts`: ```ts export function createTmuxSingleRunner(deps: { assertInsideTmux(): void; getCurrentWindowId: () => Promise; createArtifacts: (cwd: string, meta: Record) => Promise; buildWrapperCommand: (metaPath: string) => string; createPane: (input: { windowId: string; cwd: string; command: string }) => Promise; monitorRun: (input: { eventsPath: string; resultPath: string; onEvent?: (event: any) => void }) => Promise; killPane: (paneId: string) => Promise; }) { return async function runSingleTask(input: { cwd: string; meta: Record; onEvent?: (event: any) => void; }) { deps.assertInsideTmux(); const artifacts = await deps.createArtifacts(input.cwd, input.meta); const windowId = await deps.getCurrentWindowId(); const command = deps.buildWrapperCommand(artifacts.metaPath); const paneId = await deps.createPane({ windowId, cwd: input.cwd, command }); try { const result = await deps.monitorRun({ eventsPath: artifacts.eventsPath, resultPath: artifacts.resultPath, onEvent: input.onEvent, }); return { ...result, runId: result.runId ?? artifacts.runId, paneId, windowId, sessionPath: result.sessionPath ?? artifacts.sessionPath, stdoutPath: result.stdoutPath ?? artifacts.stdoutPath, stderrPath: result.stderrPath ?? artifacts.stderrPath, resultPath: artifacts.resultPath, eventsPath: artifacts.eventsPath, }; } finally { await deps.killPane(paneId); } }; } ``` Update `index.ts` import only: ```ts import { createTmuxSingleRunner } from "./src/tmux-runner.ts"; ``` - [ ] **Step 4: Run tmux runner tests** Run: `npx tsx --test src/tmux-runner.test.ts src/tmux.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/runner.ts src/tmux-runner.ts src/tmux-runner.test.ts index.ts git commit -m "refactor: isolate tmux runner implementation" ``` ### Task 4: Add process runner with inspectable launch-failure results **Files:** - Create: `src/process-runner.ts` - Create: `src/process-runner.test.ts` - Modify: `src/schema.ts` - [ ] **Step 1: Write the failing process runner tests** Create `src/process-runner.test.ts`: ```ts import test from "node:test"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import { mkdtemp, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createRunArtifacts } from "./artifacts.ts"; import { monitorRun } from "./monitor.ts"; import { createProcessSingleRunner } from "./process-runner.ts"; class FakeChild extends EventEmitter {} test("createProcessSingleRunner launches wrapper without tmux and returns monitored result", async () => { const cwd = await mkdtemp(join(tmpdir(), "pi-subagents-process-")); let metaPathSeen = ""; const runSingleTask = createProcessSingleRunner({ createArtifacts: createRunArtifacts, buildWrapperSpawn(metaPath: string) { metaPathSeen = metaPath; return { command: process.execPath, args: ["-e", "process.exit(0)"] }; }, spawnChild() { const child = new FakeChild() as any; process.nextTick(async () => { const meta = JSON.parse(await readFile(metaPathSeen, "utf8")); await writeFile( meta.resultPath, JSON.stringify( { runId: meta.runId, mode: meta.mode, agent: meta.agent, agentSource: meta.agentSource, task: meta.task, requestedModel: meta.requestedModel, resolvedModel: meta.resolvedModel, sessionPath: meta.sessionPath, exitCode: 0, finalText: "done", stdoutPath: meta.stdoutPath, stderrPath: meta.stderrPath, transcriptPath: meta.transcriptPath, resultPath: meta.resultPath, eventsPath: meta.eventsPath, }, null, 2, ), "utf8", ); child.emit("close", 0); }); return child; }, monitorRun: (input) => monitorRun({ ...input, pollMs: 1 }), }); const result = await runSingleTask({ cwd, meta: { mode: "single", agent: "scout", agentSource: "builtin", task: "inspect auth", requestedModel: "openai/gpt-5", resolvedModel: "openai/gpt-5", }, }); assert.equal(result.finalText, "done"); assert.equal(result.exitCode, 0); assert.match(result.resultPath ?? "", /\.pi\/subagents\/runs\//); }); test("createProcessSingleRunner writes error result.json when wrapper launch fails", async () => { const cwd = await mkdtemp(join(tmpdir(), "pi-subagents-process-")); const runSingleTask = createProcessSingleRunner({ createArtifacts: createRunArtifacts, buildWrapperSpawn() { return { command: process.execPath, args: ["-e", "process.exit(0)"] }; }, spawnChild() { const child = new FakeChild() as any; process.nextTick(() => { child.emit("error", new Error("spawn boom")); }); return child; }, monitorRun: (input) => monitorRun({ ...input, pollMs: 1 }), }); const result = await runSingleTask({ cwd, meta: { mode: "single", agent: "scout", agentSource: "builtin", task: "inspect auth", requestedModel: "openai/gpt-5", resolvedModel: "openai/gpt-5", }, }); assert.equal(result.exitCode, 1); assert.equal(result.stopReason, "error"); assert.match(result.errorMessage ?? "", /spawn boom/); const saved = JSON.parse(await readFile(result.resultPath!, "utf8")); assert.equal(saved.exitCode, 1); assert.match(saved.errorMessage ?? "", /spawn boom/); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx tsx --test src/process-runner.test.ts` Expected: FAIL with `Cannot find module './process-runner.ts'`. - [ ] **Step 3: Write minimal process runner and type alignment** Update `src/schema.ts` result type to match what wrapper/process runner already write: ```ts export interface SubagentRunResult { runId: string; agent: string; agentSource: "builtin" | "user" | "project" | "unknown"; task: string; requestedModel?: string; resolvedModel?: string; paneId?: string; windowId?: string; sessionPath?: string; exitCode: number; stopReason?: string; finalText: string; stdoutPath?: string; stderrPath?: string; transcriptPath?: string; resultPath?: string; eventsPath?: string; errorMessage?: string; } ``` Create `src/process-runner.ts`: ```ts import { spawn } from "node:child_process"; import { writeFile } from "node:fs/promises"; import type { RunSingleTask } from "./runner.ts"; function makeLaunchFailureResult(artifacts: any, meta: Record, cwd: string, error: unknown) { const message = error instanceof Error ? error.stack ?? error.message : String(error); const startedAt = new Date().toISOString(); 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, resolvedModel: meta.resolvedModel, sessionPath: artifacts.sessionPath, startedAt, finishedAt: new Date().toISOString(), exitCode: 1, stopReason: "error", finalText: "", stdoutPath: artifacts.stdoutPath, stderrPath: artifacts.stderrPath, transcriptPath: artifacts.transcriptPath, resultPath: artifacts.resultPath, eventsPath: artifacts.eventsPath, errorMessage: message, }; } export function createProcessSingleRunner(deps: { createArtifacts: (cwd: string, meta: Record) => Promise; buildWrapperSpawn: (metaPath: string) => { command: string; args: string[]; env?: NodeJS.ProcessEnv }; spawnChild?: typeof spawn; monitorRun: (input: { eventsPath: string; resultPath: string; onEvent?: (event: any) => void }) => Promise; }): RunSingleTask { const spawnChild = deps.spawnChild ?? spawn; return async function runSingleTask(input) { const artifacts = await deps.createArtifacts(input.cwd, input.meta); const spawnSpec = deps.buildWrapperSpawn(artifacts.metaPath); const writeLaunchFailure = async (error: unknown) => { const result = makeLaunchFailureResult(artifacts, input.meta, input.cwd, error); await writeFile(artifacts.resultPath, JSON.stringify(result, null, 2), "utf8"); return result; }; let child; try { child = spawnChild(spawnSpec.command, spawnSpec.args, { cwd: input.cwd, env: { ...process.env, ...(spawnSpec.env ?? {}) }, stdio: ["ignore", "ignore", "ignore"], }); } catch (error) { return writeLaunchFailure(error); } child.once("error", (error) => { void writeLaunchFailure(error); }); const result = await deps.monitorRun({ eventsPath: artifacts.eventsPath, resultPath: artifacts.resultPath, onEvent: input.onEvent, }); return { ...result, runId: result.runId ?? artifacts.runId, sessionPath: result.sessionPath ?? artifacts.sessionPath, stdoutPath: result.stdoutPath ?? artifacts.stdoutPath, stderrPath: result.stderrPath ?? artifacts.stderrPath, transcriptPath: result.transcriptPath ?? artifacts.transcriptPath, resultPath: artifacts.resultPath, eventsPath: artifacts.eventsPath, }; }; } ``` - [ ] **Step 4: Run process runner tests** Run: `npx tsx --test src/process-runner.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/process-runner.ts src/process-runner.test.ts src/schema.ts git commit -m "feat: add process runner for subagents" ``` ### Task 5: Add shared runner selection and wire config into extension entrypoint **Files:** - Modify: `src/runner.ts` - Create: `src/runner.test.ts` - Modify: `index.ts` - Modify: `src/extension.test.ts` - [ ] **Step 1: Write the failing runner-selection and env-rename tests** Create `src/runner.test.ts`: ```ts import test from "node:test"; import assert from "node:assert/strict"; import { createConfiguredRunSingleTask } from "./runner.ts"; function makeResult(finalText: string) { return { runId: "run-1", agent: "scout", agentSource: "builtin" as const, task: "inspect auth", exitCode: 0, finalText, }; } test("createConfiguredRunSingleTask uses process runner when config says process", async () => { const calls: string[] = []; const runSingleTask = createConfiguredRunSingleTask({ loadConfig: () => ({ runner: "process" }), processRunner: async () => { calls.push("process"); return makeResult("process"); }, tmuxRunner: async () => { calls.push("tmux"); return makeResult("tmux"); }, }); const result = await runSingleTask({ cwd: "/repo", meta: { task: "inspect auth" } as any }); assert.equal(result.finalText, "process"); assert.deepEqual(calls, ["process"]); }); test("createConfiguredRunSingleTask uses tmux runner when config says tmux", async () => { const calls: string[] = []; const runSingleTask = createConfiguredRunSingleTask({ loadConfig: () => ({ runner: "tmux" }), processRunner: async () => { calls.push("process"); return makeResult("process"); }, tmuxRunner: async () => { calls.push("tmux"); return makeResult("tmux"); }, }); const result = await runSingleTask({ cwd: "/repo", meta: { task: "inspect auth" } as any }); assert.equal(result.finalText, "tmux"); assert.deepEqual(calls, ["tmux"]); }); test("createConfiguredRunSingleTask passes task cwd into config loader", async () => { let cwdSeen = ""; const runSingleTask = createConfiguredRunSingleTask({ loadConfig: (cwd) => { cwdSeen = cwd; return { runner: "process" }; }, processRunner: async () => makeResult("process"), tmuxRunner: async () => makeResult("tmux"), }); await runSingleTask({ cwd: "/repo/worktree", meta: { task: "inspect auth" } as any }); assert.equal(cwdSeen, "/repo/worktree"); }); ``` Update `src/extension.test.ts` env names and import: ```ts import subagentsExtension from "../index.ts"; const original = process.env.PI_SUBAGENTS_CHILD; if (original !== undefined) delete process.env.PI_SUBAGENTS_CHILD; process.env.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR = "agent"; process.env.PI_SUBAGENTS_CHILD = "1"; test("registers github-copilot provider override when PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR is set", () => { // keep existing assertion body; only env names and imported symbol change }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `npx tsx --test src/runner.test.ts src/extension.test.ts` Expected: FAIL because `createConfiguredRunSingleTask` does not exist yet and `index.ts` still reads `PI_TMUX_SUBAGENT_*`. - [ ] **Step 3: Write minimal runner selector and extension wiring** Expand `src/runner.ts`: ```ts import type { SubagentRunResult } from "./schema.ts"; import type { RunnerMode } from "./config.ts"; export interface RunSingleTaskInput { cwd: string; meta: Record; onEvent?: (event: any) => void; } export type RunSingleTask = (input: RunSingleTaskInput) => Promise; export function createConfiguredRunSingleTask(deps: { loadConfig: (cwd: string) => { runner: RunnerMode }; processRunner: RunSingleTask; tmuxRunner: RunSingleTask; }): RunSingleTask { return (input) => { const config = deps.loadConfig(input.cwd); return (config.runner === "tmux" ? deps.tmuxRunner : deps.processRunner)(input); }; } ``` Update `index.ts` to use new env names, create both runners, and select per task cwd: ```ts import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { createRunArtifacts } from "./src/artifacts.ts"; import { loadSubagentsConfig } from "./src/config.ts"; import { monitorRun } from "./src/monitor.ts"; import { listAvailableModelReferences } from "./src/models.ts"; import { createProcessSingleRunner } from "./src/process-runner.ts"; import { createConfiguredRunSingleTask } from "./src/runner.ts"; import { createTmuxSingleRunner } from "./src/tmux-runner.ts"; import { buildCurrentWindowArgs, buildKillPaneArgs, buildSplitWindowArgs, buildWrapperShellCommand, isInsideTmux, } from "./src/tmux.ts"; import { createSubagentParamsSchema } from "./src/schema.ts"; import { createSubagentTool } from "./src/tool.ts"; const packageRoot = dirname(fileURLToPath(import.meta.url)); const wrapperPath = join(packageRoot, "src", "wrapper", "cli.mjs"); export default function subagentsExtension(pi: ExtensionAPI) { if (process.env.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR === "agent") { pi.registerProvider("github-copilot", { headers: { "X-Initiator": "agent" }, }); } if (process.env.PI_SUBAGENTS_CHILD === "1") { return; } const tmuxRunner = createTmuxSingleRunner({ assertInsideTmux() { if (!isInsideTmux()) throw new Error("tmux-backed subagents require pi to be running inside tmux."); }, async getCurrentWindowId() { const result = await pi.exec("tmux", buildCurrentWindowArgs()); return result.stdout.trim(); }, createArtifacts: createRunArtifacts, buildWrapperCommand(metaPath: string) { return buildWrapperShellCommand({ nodePath: process.execPath, wrapperPath, metaPath }); }, async createPane(input) { const result = await pi.exec("tmux", buildSplitWindowArgs(input)); return result.stdout.trim(); }, monitorRun, async killPane(paneId: string) { await pi.exec("tmux", buildKillPaneArgs(paneId)); }, }); const processRunner = createProcessSingleRunner({ createArtifacts: createRunArtifacts, buildWrapperSpawn(metaPath: string) { return { command: process.execPath, args: [wrapperPath, metaPath] }; }, monitorRun, }); const runSingleTask = createConfiguredRunSingleTask({ loadConfig: (cwd) => loadSubagentsConfig(cwd), processRunner, tmuxRunner, }); // keep existing model-registration logic unchanged below this point } ``` Also change all `PI_TMUX_SUBAGENT_CHILD` and `PI_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR` references in `src/extension.test.ts` to `PI_SUBAGENTS_CHILD` and `PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR`, and update the imported symbol name to `subagentsExtension`. - [ ] **Step 4: Run runner and extension tests** Run: `npx tsx --test src/runner.test.ts src/extension.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/runner.ts src/runner.test.ts index.ts src/extension.test.ts git commit -m "feat: select subagent runner from config" ``` ### Task 6: Update wrapper child env behavior and result-writing guarantees **Files:** - Modify: `src/wrapper/cli.mjs` - Modify: `src/wrapper/cli.test.ts` - [ ] **Step 1: Write the failing wrapper tests** Update `src/wrapper/cli.test.ts` helper to capture new env names: ```ts await writeFile( piPath, [ `#!${process.execPath}`, "const fs = require('fs');", `const capturePath = ${JSON.stringify(capturePath)};`, "const obj = {", " PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR: process.env.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR || '',", " PI_SUBAGENTS_CHILD: process.env.PI_SUBAGENTS_CHILD || '',", " argv: process.argv.slice(2)", "};", "fs.writeFileSync(capturePath, JSON.stringify(obj), 'utf8');", "console.log(JSON.stringify({type:'message_end',message:{role:'assistant',content:[{type:'text',text:'done'}],model:'github-copilot/gpt-4o',stopReason:'stop'}}));", ].join("\n"), "utf8", ); ``` Update child-env assertions: ```ts test("wrapper marks github-copilot child run as a subagent child", async () => { const captured = await runWrapperWithFakePi("github-copilot/gpt-4o"); assert.equal(captured.flags.PI_SUBAGENTS_CHILD, "1"); }); 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 marks github-copilot child runs as agent-initiated", async () => { const captured = await runWrapperWithFakePi("github-copilot/gpt-4o"); assert.equal(captured.flags.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR, "agent"); assert.equal(captured.flags.PI_SUBAGENTS_CHILD, "1"); }); test("wrapper leaves non-copilot child runs unchanged", async () => { const captured = await runWrapperWithFakePi("anthropic/claude-sonnet-4-5"); assert.equal(captured.flags.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR, ""); assert.equal(captured.flags.PI_SUBAGENTS_CHILD, "1"); }); ``` Keep the existing requested/resolved model regression test, but update the env assertions to the new names. Add a new logging-failure regression test: ```ts test("wrapper still writes result.json when transcript/stdout artifact writes fail", async () => { const dir = await mkdtemp(join(tmpdir(), "pi-subagents-wrapper-")); const metaPath = join(dir, "meta.json"); const resultPath = join(dir, "result.json"); const piPath = join(dir, "pi"); const brokenArtifactPath = join(dir, "broken-artifact"); await mkdir(brokenArtifactPath); await writeFile( piPath, [ `#!${process.execPath}`, "console.log(JSON.stringify({type:'message_end',message:{role:'assistant',content:[{type:'text',text:'done'}],model:'openai/gpt-5',stopReason:'stop'}}));", ].join("\n"), "utf8", ); await chmod(piPath, 0o755); await writeFile( metaPath, JSON.stringify( { runId: "run-1", mode: "single", agent: "scout", agentSource: "builtin", task: "inspect auth", cwd: dir, requestedModel: "openai/gpt-5", resolvedModel: "openai/gpt-5", sessionPath: join(dir, "child-session.jsonl"), eventsPath: join(dir, "events.jsonl"), resultPath, stdoutPath: brokenArtifactPath, stderrPath: join(dir, "stderr.log"), transcriptPath: brokenArtifactPath, systemPromptPath: join(dir, "system-prompt.md"), }, null, 2, ), "utf8", ); const wrapperPath = join(dirname(fileURLToPath(import.meta.url)), "cli.mjs"); const child = spawn(process.execPath, [wrapperPath, metaPath], { env: { ...process.env, PATH: dir }, stdio: ["ignore", "pipe", "pipe"], }); const exitCode = await waitForExit(child); assert.equal(exitCode, 0); const result = JSON.parse(await readFile(resultPath, "utf8")); assert.equal(result.exitCode, 0); assert.equal(result.finalText, "done"); }); ``` Add `mkdir` to the `node:fs/promises` import at top of the file. - [ ] **Step 2: Run test to verify it fails** Run: `npx tsx --test src/wrapper/cli.test.ts` Expected: FAIL because wrapper still exports `PI_TMUX_SUBAGENT_*` env names and the new logging-failure test is not implemented yet. - [ ] **Step 3: Write minimal wrapper implementation changes** Update env handling in `src/wrapper/cli.mjs`: ```js const childEnv = { ...process.env }; delete childEnv.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR; childEnv.PI_SUBAGENTS_CHILD = "1"; if (typeof effectiveModel === "string" && effectiveModel.startsWith("github-copilot/")) { childEnv.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR = "agent"; } ``` Keep resolved-model behavior exactly as-is: ```js const effectiveModel = typeof meta.resolvedModel === "string" && meta.resolvedModel.length > 0 ? meta.resolvedModel : meta.requestedModel; const args = ["--mode", "json", "--session", meta.sessionPath]; if (effectiveModel) args.push("--model", effectiveModel); ``` Keep best-effort artifact writes wrapped in `appendBestEffort()` and do not gate final `writeFile(meta.resultPath, ...)` on those writes. - [ ] **Step 4: Run wrapper tests** Run: `npx tsx --test src/wrapper/cli.test.ts` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/wrapper/cli.mjs src/wrapper/cli.test.ts git commit -m "fix: rename wrapper env vars and preserve result writing" ``` ### Task 7: Final cleanup for old names, tmux-only docs, and full regression suite **Files:** - Modify: `README.md` - Modify: `prompts/scout-and-plan.md` - Modify: `prompts/implement.md` - Modify: `prompts/implement-and-review.md` - Modify: `src/agents.test.ts` - Modify: `src/artifacts.test.ts` - Modify: `src/monitor.test.ts` - Modify: `src/tmux.test.ts` - Modify: `src/package-manifest.test.ts` - Modify: `src/extension.test.ts` - Modify: `src/wrapper/render.test.ts` - Modify: comments in `index.ts` and `src/wrapper/cli.mjs` - [ ] **Step 1: Replace remaining clean-break old-name strings** Update test tempdir prefixes and example paths where old package name is only fixture text: ```ts // src/artifacts.test.ts const cwd = await mkdtemp(join(tmpdir(), "pi-subagents-run-")); // src/agents.test.ts const root = await mkdtemp(join(tmpdir(), "pi-subagents-agents-")); // src/monitor.test.ts const dir = await mkdtemp(join(tmpdir(), "pi-subagents-monitor-")); // src/tmux.test.ts wrapperPath: "/repo/subagents/src/wrapper/cli.mjs", assert.equal( command, "'/usr/local/bin/node' '/repo/subagents/src/wrapper/cli.mjs' '/repo/.pi/subagents/runs/run-1/meta.json'", ); ``` Update comments that still say `tmux subagent` or `nested tmux subagent` to generic `subagent child run`, except in tmux-specific files where the comment is truly about tmux behavior. - [ ] **Step 2: Verify no old canonical names remain** Run: `rg -n "pi-tmux-subagent|PI_TMUX_SUBAGENT|tmux-backed subagents|nested tmux subagent|=== tmux subagent ===" .` Expected: no matches. - [ ] **Step 3: Run full regression suite** Run: `npm test` Expected: all tests pass. - [ ] **Step 4: Commit** ```bash git add README.md prompts/scout-and-plan.md prompts/implement.md prompts/implement-and-review.md src/agents.test.ts src/artifacts.test.ts src/monitor.test.ts src/tmux.test.ts src/package-manifest.test.ts src/extension.test.ts src/wrapper/render.test.ts index.ts src/wrapper/cli.mjs git commit -m "test: finish pi-subagents rename and regression cleanup" ```