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