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

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

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

// 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"