Files
pi-subagents/docs/superpowers/plans/2026-04-10-pi-subagents-process-runner.md
2026-04-11 00:19:52 +01:00

1265 lines
40 KiB
Markdown

# 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<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`:
```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:
```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<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**
```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<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:
```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"
```