40 KiB
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/projectsubagents.json, validaterunner, return effective configsrc/config.test.ts— config precedence/default/validation testssrc/process-runner.ts— spawn wrapper as background child process, write fallback errorresult.jsonif launch fails before wrapper startssrc/process-runner.test.ts— process runner success + launch-failure testssrc/tmux-runner.ts— existing tmux launch logic moved out ofsrc/runner.ts
Modify
package.json— rename package topi-subagentsREADME.md— document process default, optional tmux config, remove hard tmux requirementprompts/scout-and-plan.mdprompts/implement.mdprompts/implement-and-review.md— removetmux-backedwordingindex.ts— rename env vars, create both runners, select runner from config per task, keep model-based re-registration behaviorsrc/runner.ts— sharedRunSingleTasktypes + config-based runner selectorsrc/runner.test.ts— runner selector testssrc/tmux.ts— keep tmux helper functions; only tmux-specific code should mention tmux requirementssrc/tool.ts— generic tool description, still runner-agnosticsrc/schema.ts— align result type with wrapper/process-runner fields (transcriptPath,errorMessage)src/extension.test.ts— env rename + extension registration regressionssrc/package-manifest.test.ts— package rename assertionssrc/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 namesrc/wrapper/cli.mjs— rename env vars, preserve resolved-model behavior, preserveresult.jsonon log/write failuressrc/wrapper/cli.test.ts— child env rename, resolved-model tests, launch-failure test, logging-failure testsrc/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:
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:
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:
{
"name": "pi-subagents"
}
Update top of README.md to generic package + runner wording:
# 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:
# 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:
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:
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
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:
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:
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
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:
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:
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:
import type { SubagentRunResult } from "./schema.ts";
export interface RunSingleTaskInput {
cwd: string;
meta: Record<string, unknown>;
onEvent?: (event: any) => void;
}
export type RunSingleTask = (input: RunSingleTaskInput) => Promise<SubagentRunResult>;
Create src/tmux-runner.ts with existing implementation moved out of src/runner.ts:
export function createTmuxSingleRunner(deps: {
assertInsideTmux(): void;
getCurrentWindowId: () => Promise<string>;
createArtifacts: (cwd: string, meta: Record<string, unknown>) => Promise<any>;
buildWrapperCommand: (metaPath: string) => string;
createPane: (input: { windowId: string; cwd: string; command: string }) => Promise<string>;
monitorRun: (input: { eventsPath: string; resultPath: string; onEvent?: (event: any) => void }) => Promise<any>;
killPane: (paneId: string) => Promise<void>;
}) {
return async function runSingleTask(input: {
cwd: string;
meta: Record<string, unknown>;
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:
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
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:
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:
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:
import { spawn } from "node:child_process";
import { writeFile } from "node:fs/promises";
import type { RunSingleTask } from "./runner.ts";
function makeLaunchFailureResult(artifacts: any, meta: Record<string, unknown>, 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<string, unknown>) => Promise<any>;
buildWrapperSpawn: (metaPath: string) => { command: string; args: string[]; env?: NodeJS.ProcessEnv };
spawnChild?: typeof spawn;
monitorRun: (input: { eventsPath: string; resultPath: string; onEvent?: (event: any) => void }) => Promise<any>;
}): 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
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:
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:
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:
import type { SubagentRunResult } from "./schema.ts";
import type { RunnerMode } from "./config.ts";
export interface RunSingleTaskInput {
cwd: string;
meta: Record<string, unknown>;
onEvent?: (event: any) => void;
}
export type RunSingleTask = (input: RunSingleTaskInput) => Promise<SubagentRunResult>;
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:
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
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:
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:
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:
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:
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:
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
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.tsandsrc/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:
// 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
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"