1265 lines
40 KiB
Markdown
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"
|
|
```
|