initial commit

This commit is contained in:
pi
2026-04-10 23:12:17 +01:00
commit d64e050fcc
34 changed files with 6954 additions and 0 deletions

29
README.md Normal file
View File

@@ -0,0 +1,29 @@
# pi-tmux-subagent
`pi-tmux-subagent` is a Pi extension package that runs subagent tasks in tmux panes and ships the prompts and wrapper code needed to execute those runs.
## Install
Use it as a local package root today:
```bash
pi install /absolute/path/to/tmux-subagent
```
After this folder is moved into its own repository, the same package can be installed from git.
## Resources
- Extension: `./index.ts`
- Prompts: `./prompts/*.md`
## Requirements
- `tmux` must be available on `PATH`
## Development
```bash
npm install
npm test
```

99
index.ts Normal file
View File

@@ -0,0 +1,99 @@
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 { monitorRun } from "./src/monitor.ts";
import { listAvailableModelReferences } from "./src/models.ts";
import { createTmuxSingleRunner } from "./src/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 tmuxSubagentExtension(pi: ExtensionAPI) {
if (process.env.PI_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR === "agent") {
pi.registerProvider("github-copilot", {
headers: { "X-Initiator": "agent" },
});
}
// In wrapper/child sessions spawned by the tmux runner we must not register the
// subagent tool (that would cause nested subagent registrations). Skip all
// subagent-tool registration logic when PI_TMUX_SUBAGENT_CHILD is set. Provider
// overrides (above) are still allowed in child runs, so the guard is placed
// after provider registration.
if (process.env.PI_TMUX_SUBAGENT_CHILD === "1") {
return;
}
let lastRegisteredModelsKey: string | undefined;
const runSingleTask = 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 registerSubagentTool = (availableModels: string[]) => {
// Do not register a tool when no models are available. Remember that the
// last-registered key is different from the empty sentinel so that a later
// non-empty list will still trigger registration.
if (!availableModels || availableModels.length === 0) {
const emptyKey = "\u0000";
if (lastRegisteredModelsKey === emptyKey) return;
lastRegisteredModelsKey = emptyKey;
return;
}
// Create a deduplication key that is independent of the order of
// availableModels by sorting a lowercase copy. Do not mutate
// availableModels itself since we want to preserve the original order for
// schema enum values.
const key = [...availableModels].map((s) => s.toLowerCase()).sort().join("\u0000");
if (key === lastRegisteredModelsKey) return;
lastRegisteredModelsKey = key;
pi.registerTool(
createSubagentTool({
parameters: createSubagentParamsSchema(availableModels),
runSingleTask,
}),
);
};
const syncSubagentTool = (ctx: { modelRegistry: { getAvailable(): Array<{ provider: string; id: string }> } }) => {
registerSubagentTool(listAvailableModelReferences(ctx.modelRegistry));
};
pi.on("session_start", (_event, ctx) => {
syncSubagentTool(ctx);
});
pi.on("before_agent_start", (_event, ctx) => {
syncSubagentTool(ctx);
});
}

4371
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "pi-tmux-subagent",
"version": "0.1.0",
"type": "module",
"keywords": ["pi-package"],
"scripts": {
"test": "tsx --test src/*.test.ts src/**/*.test.ts"
},
"files": ["index.ts", "src", "prompts"],
"pi": {
"extensions": ["./index.ts"],
"prompts": ["./prompts/*.md"]
},
"peerDependencies": {
"@mariozechner/pi-ai": "*",
"@mariozechner/pi-coding-agent": "*",
"@mariozechner/pi-tui": "*",
"@sinclair/typebox": "*"
},
"devDependencies": {
"@mariozechner/pi-agent-core": "^0.66.1",
"@mariozechner/pi-ai": "^0.66.1",
"@mariozechner/pi-coding-agent": "^0.66.1",
"@mariozechner/pi-tui": "^0.66.1",
"@sinclair/typebox": "^0.34.49",
"@types/node": "^25.5.2",
"tsx": "^4.21.0",
"typescript": "^6.0.2"
}
}

View File

@@ -0,0 +1,10 @@
---
description: Implement, review, then revise using tmux-backed subagents
---
Use the `subagent` tool in chain mode:
1. `worker` to implement: $@
2. `reviewer` to review `{previous}` and identify issues
3. `worker` to revise the implementation using `{previous}`
User request: $@

10
prompts/implement.md Normal file
View File

@@ -0,0 +1,10 @@
---
description: Scout, plan, and implement using tmux-backed subagents
---
Use the `subagent` tool to handle this request in three stages:
1. Run `scout` to inspect the codebase for: $@
2. Run `planner` in chain mode, using `{previous}` from the scout output to produce a concrete implementation plan
3. Run `worker` in chain mode, using `{previous}` from the planner output to implement the approved plan
User request: $@

View File

@@ -0,0 +1,9 @@
---
description: Scout the codebase, then produce a plan using tmux-backed subagents
---
Use the `subagent` tool in chain mode:
1. `scout` to inspect the codebase for: $@
2. `planner` to turn `{previous}` into an implementation plan
User request: $@

54
src/agents.test.ts Normal file
View File

@@ -0,0 +1,54 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdir, writeFile, mkdtemp } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { BUILTIN_AGENTS } from "./builtin-agents.ts";
import { discoverAgents } from "./agents.ts";
test("discoverAgents returns built-ins and lets user markdown override by name", async () => {
const root = await mkdtemp(join(tmpdir(), "tmux-subagent-agents-"));
const agentDir = join(root, "agent-home");
const userAgentsDir = join(agentDir, "agents");
await mkdir(userAgentsDir, { recursive: true });
await writeFile(
join(userAgentsDir, "scout.md"),
`---\nname: scout\ndescription: User scout\nmodel: openai/gpt-5\n---\nUser override prompt`,
"utf8",
);
const result = discoverAgents(join(root, "repo"), {
scope: "user",
agentDir,
builtins: BUILTIN_AGENTS,
});
const scout = result.agents.find((agent) => agent.name === "scout");
assert.equal(scout?.source, "user");
assert.equal(scout?.description, "User scout");
assert.equal(scout?.model, "openai/gpt-5");
});
test("discoverAgents lets project agents override user agents when scope is both", async () => {
const root = await mkdtemp(join(tmpdir(), "tmux-subagent-agents-"));
const repo = join(root, "repo");
const agentDir = join(root, "agent-home");
const userAgentsDir = join(agentDir, "agents");
const projectAgentsDir = join(repo, ".pi", "agents");
await mkdir(userAgentsDir, { recursive: true });
await mkdir(projectAgentsDir, { recursive: true });
await writeFile(join(userAgentsDir, "worker.md"), `---\nname: worker\ndescription: User worker\n---\nUser worker`, "utf8");
await writeFile(join(projectAgentsDir, "worker.md"), `---\nname: worker\ndescription: Project worker\n---\nProject worker`, "utf8");
const result = discoverAgents(repo, {
scope: "both",
agentDir,
builtins: BUILTIN_AGENTS,
});
const worker = result.agents.find((agent) => agent.name === "worker");
assert.equal(worker?.source, "project");
assert.equal(worker?.description, "Project worker");
assert.equal(result.projectAgentsDir, projectAgentsDir);
});

91
src/agents.ts Normal file
View File

@@ -0,0 +1,91 @@
import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from "node:fs";
import { dirname, join } from "node:path";
import { getAgentDir, parseFrontmatter } from "@mariozechner/pi-coding-agent";
import { BUILTIN_AGENTS, type AgentDefinition } from "./builtin-agents.ts";
import type { AgentScope } from "./schema.ts";
export interface AgentDiscoveryOptions {
scope?: AgentScope;
agentDir?: string;
builtins?: AgentDefinition[];
}
export interface AgentDiscoveryResult {
agents: AgentDefinition[];
projectAgentsDir: string | null;
}
function loadMarkdownAgents(dir: string, source: "user" | "project"): AgentDefinition[] {
if (!existsSync(dir)) return [];
let entries: Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return [];
}
const agents: AgentDefinition[] = [];
for (const entry of entries) {
if (!entry.name.endsWith(".md")) continue;
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
const filePath = join(dir, entry.name);
const content = readFileSync(filePath, "utf8");
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
if (!frontmatter.name || !frontmatter.description) continue;
agents.push({
name: frontmatter.name,
description: frontmatter.description,
tools: frontmatter.tools?.split(",").map((tool) => tool.trim()).filter(Boolean),
model: frontmatter.model,
systemPrompt: body.trim(),
source,
filePath,
});
}
return agents;
}
function findNearestProjectAgentsDir(cwd: string): string | null {
let current = cwd;
while (true) {
const candidate = join(current, ".pi", "agents");
try {
if (statSync(candidate).isDirectory()) return candidate;
} catch {}
const parent = dirname(current);
if (parent === current) return null;
current = parent;
}
}
export function discoverAgents(cwd: string, options: AgentDiscoveryOptions = {}): AgentDiscoveryResult {
const scope = options.scope ?? "user";
const builtins = options.builtins ?? BUILTIN_AGENTS;
const userAgentDir = join(options.agentDir ?? getAgentDir(), "agents");
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
const sources = new Map<string, AgentDefinition>();
for (const agent of builtins) sources.set(agent.name, agent);
if (scope !== "project") {
for (const agent of loadMarkdownAgents(userAgentDir, "user")) {
sources.set(agent.name, agent);
}
}
if (scope !== "user" && projectAgentsDir) {
for (const agent of loadMarkdownAgents(projectAgentsDir, "project")) {
sources.set(agent.name, agent);
}
}
return {
agents: [...sources.values()],
projectAgentsDir,
};
}

21
src/artifacts.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, readFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createRunArtifacts } from "./artifacts.ts";
test("createRunArtifacts writes metadata and reserves stable artifact paths", async () => {
const cwd = await mkdtemp(join(tmpdir(), "tmux-subagent-run-"));
const artifacts = await createRunArtifacts(cwd, {
runId: "run-1",
task: "inspect auth",
systemPrompt: "You are scout",
});
assert.equal(artifacts.runId, "run-1");
assert.match(artifacts.dir, /\.pi\/subagents\/runs\/run-1$/);
assert.equal(JSON.parse(await readFile(artifacts.metaPath, "utf8")).task, "inspect auth");
assert.equal(await readFile(artifacts.systemPromptPath, "utf8"), "You are scout");
});

66
src/artifacts.ts Normal file
View File

@@ -0,0 +1,66 @@
import { mkdir, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { join, resolve } from "node:path";
export interface RunArtifacts {
runId: string;
dir: string;
metaPath: string;
eventsPath: string;
resultPath: string;
stdoutPath: string;
stderrPath: string;
transcriptPath: string;
sessionPath: string;
systemPromptPath: string;
}
export async function createRunArtifacts(
cwd: string,
meta: Record<string, unknown> & { runId?: string; systemPrompt?: string },
): Promise<RunArtifacts> {
const runId = meta.runId ?? randomUUID();
const dir = resolve(cwd, ".pi", "subagents", "runs", runId);
await mkdir(dir, { recursive: true });
const artifacts: RunArtifacts = {
runId,
dir,
metaPath: join(dir, "meta.json"),
eventsPath: join(dir, "events.jsonl"),
resultPath: join(dir, "result.json"),
stdoutPath: join(dir, "stdout.log"),
stderrPath: join(dir, "stderr.log"),
transcriptPath: join(dir, "transcript.log"),
sessionPath: join(dir, "child-session.jsonl"),
systemPromptPath: join(dir, "system-prompt.md"),
};
await writeFile(
artifacts.metaPath,
JSON.stringify(
{
...meta,
runId,
sessionPath: artifacts.sessionPath,
eventsPath: artifacts.eventsPath,
resultPath: artifacts.resultPath,
stdoutPath: artifacts.stdoutPath,
stderrPath: artifacts.stderrPath,
transcriptPath: artifacts.transcriptPath,
systemPromptPath: artifacts.systemPromptPath,
},
null,
2,
),
"utf8",
);
await writeFile(artifacts.systemPromptPath, typeof meta.systemPrompt === "string" ? meta.systemPrompt : "", "utf8");
await writeFile(artifacts.eventsPath, "", "utf8");
await writeFile(artifacts.stdoutPath, "", "utf8");
await writeFile(artifacts.stderrPath, "", "utf8");
await writeFile(artifacts.transcriptPath, "", "utf8");
return artifacts;
}

43
src/builtin-agents.ts Normal file
View File

@@ -0,0 +1,43 @@
export interface AgentDefinition {
name: string;
description: string;
tools?: string[];
model?: string;
systemPrompt: string;
source: "builtin" | "user" | "project";
filePath?: string;
}
export const BUILTIN_AGENTS: AgentDefinition[] = [
{
name: "scout",
description: "Fast codebase recon and compressed context gathering",
tools: ["read", "grep", "find", "ls", "bash"],
model: "claude-haiku-4-5",
systemPrompt: "You are a scout. Explore quickly, summarize clearly, and avoid implementation.",
source: "builtin",
},
{
name: "planner",
description: "Turns exploration into implementation plans",
tools: ["read", "grep", "find", "ls"],
model: "claude-sonnet-4-5",
systemPrompt: "You are a planner. Produce implementation plans, file lists, and risks.",
source: "builtin",
},
{
name: "reviewer",
description: "Reviews code and identifies correctness and quality issues",
tools: ["read", "grep", "find", "ls", "bash"],
model: "claude-sonnet-4-5",
systemPrompt: "You are a reviewer. Inspect code critically and report concrete issues.",
source: "builtin",
},
{
name: "worker",
description: "General-purpose implementation agent",
model: "claude-sonnet-4-5",
systemPrompt: "You are a worker. Execute the delegated task completely and report final results clearly.",
source: "builtin",
},
];

397
src/extension.test.ts Normal file
View File

@@ -0,0 +1,397 @@
import test from "node:test";
import assert from "node:assert/strict";
import tmuxSubagentExtension from "../index.ts";
test("the extension entrypoint registers the subagent tool with the currently available models", async () => {
const original = process.env.PI_TMUX_SUBAGENT_CHILD;
if (original !== undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD;
try {
const registeredTools: any[] = [];
const handlers: Record<string, (event: any, ctx: any) => Promise<void> | void> = {};
tmuxSubagentExtension({
on(event: string, handler: (event: any, ctx: any) => Promise<void> | void) {
handlers[event] = handler;
},
registerTool(tool: any) {
registeredTools.push(tool);
},
registerProvider() {},
} as any);
assert.equal(typeof handlers.session_start, "function");
await handlers.session_start?.(
{ reason: "startup" },
{
modelRegistry: {
getAvailable: () => [
{ provider: "anthropic", id: "claude-sonnet-4-5" },
{ provider: "openai", id: "gpt-5" },
],
},
},
);
assert.equal(registeredTools.length, 1);
assert.equal(registeredTools[0]?.name, "subagent");
assert.deepEqual(registeredTools[0]?.parameters.required, ["model"]);
assert.deepEqual(registeredTools[0]?.parameters.properties.model.enum, [
"anthropic/claude-sonnet-4-5",
"openai/gpt-5",
]);
assert.deepEqual(registeredTools[0]?.parameters.properties.tasks.items.properties.model.enum, [
"anthropic/claude-sonnet-4-5",
"openai/gpt-5",
]);
} finally {
if (original === undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD;
else process.env.PI_TMUX_SUBAGENT_CHILD = original;
}
});
test("before_agent_start re-applies subagent registration when available models changed", async () => {
const original = process.env.PI_TMUX_SUBAGENT_CHILD;
if (original !== undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD;
try {
const registeredTools: any[] = [];
const handlers: Record<string, (event: any, ctx: any) => Promise<void> | void> = {};
tmuxSubagentExtension({
on(event: string, handler: (event: any, ctx: any) => Promise<void> | void) {
handlers[event] = handler;
},
registerTool(tool: any) {
registeredTools.push(tool);
},
registerProvider() {},
} as any);
assert.equal(typeof handlers.session_start, "function");
assert.equal(typeof handlers.before_agent_start, "function");
// initial registration with two models
await handlers.session_start?.(
{ reason: "startup" },
{
modelRegistry: {
getAvailable: () => [
{ provider: "anthropic", id: "claude-sonnet-4-5" },
{ provider: "openai", id: "gpt-5" },
],
},
},
);
assert.equal(registeredTools.length, 1);
assert.deepEqual(registeredTools[0]?.parameters.properties.model.enum, [
"anthropic/claude-sonnet-4-5",
"openai/gpt-5",
]);
// then before agent start with a different model set — should re-register
await handlers.before_agent_start?.(
{ reason: "about-to-start" },
{
modelRegistry: {
getAvailable: () => [
{ provider: "openai", id: "gpt-6" },
],
},
},
);
assert.equal(registeredTools.length, 2);
assert.deepEqual(registeredTools[1]?.parameters.properties.model.enum, ["openai/gpt-6"]);
assert.deepEqual(registeredTools[1]?.parameters.properties.tasks.items.properties.model.enum, ["openai/gpt-6"]);
} finally {
if (original === undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD;
else process.env.PI_TMUX_SUBAGENT_CHILD = original;
}
});
test("child subagent sessions skip registering the subagent tool", async () => {
const original = process.env.PI_TMUX_SUBAGENT_CHILD;
process.env.PI_TMUX_SUBAGENT_CHILD = "1";
try {
const registeredTools: any[] = [];
const handlers: Record<string, (event: any, ctx: any) => Promise<void> | void> = {};
tmuxSubagentExtension({
on(event: string, handler: (event: any, ctx: any) => Promise<void> | void) {
handlers[event] = handler;
},
registerTool(tool: any) {
registeredTools.push(tool);
},
registerProvider() {},
} as any);
assert.equal(typeof handlers.session_start, "undefined");
assert.equal(typeof handlers.before_agent_start, "undefined");
assert.equal(registeredTools.length, 0);
} finally {
if (original === undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD;
else process.env.PI_TMUX_SUBAGENT_CHILD = original;
}
});
test("registers github-copilot provider override when PI_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR is set", () => {
const registeredProviders: Array<{ name: string; config: any }> = [];
const originalInitiator = process.env.PI_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR;
const originalChild = process.env.PI_TMUX_SUBAGENT_CHILD;
// Ensure we exercise the non-child code path for this test
if (originalChild !== undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD;
process.env.PI_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR = "agent";
try {
tmuxSubagentExtension({
on() {},
registerTool() {},
registerProvider(name: string, config: any) {
registeredProviders.push({ name, config });
},
} as any);
} finally {
if (originalInitiator === undefined) delete process.env.PI_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR;
else process.env.PI_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR = originalInitiator;
if (originalChild === undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD;
else process.env.PI_TMUX_SUBAGENT_CHILD = originalChild;
}
assert.deepEqual(registeredProviders, [
{
name: "github-copilot",
config: { headers: { "X-Initiator": "agent" } },
},
]);
});
test("combined child+copilot run registers provider but no tools or startup handlers", () => {
const registeredProviders: Array<{ name: string; config: any }> = [];
const registeredTools: any[] = [];
const handlers: Record<string, (event: any, ctx: any) => Promise<void> | void> = {};
const originalInitiator = process.env.PI_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR;
const originalChild = process.env.PI_TMUX_SUBAGENT_CHILD;
process.env.PI_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR = "agent";
process.env.PI_TMUX_SUBAGENT_CHILD = "1";
try {
tmuxSubagentExtension({
on(event: string, handler: (event: any, ctx: any) => Promise<void> | void) {
handlers[event] = handler;
},
registerTool(tool: any) {
registeredTools.push(tool);
},
registerProvider(name: string, config: any) {
registeredProviders.push({ name, config });
},
} as any);
assert.deepEqual(registeredProviders, [
{
name: "github-copilot",
config: { headers: { "X-Initiator": "agent" } },
},
]);
assert.equal(registeredTools.length, 0);
assert.equal(typeof handlers.session_start, "undefined");
assert.equal(typeof handlers.before_agent_start, "undefined");
} finally {
if (originalInitiator === undefined) delete process.env.PI_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR;
else process.env.PI_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR = originalInitiator;
if (originalChild === undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD;
else process.env.PI_TMUX_SUBAGENT_CHILD = originalChild;
}
});
test("does not re-register the subagent tool when models list unchanged, but re-registers when changed", async () => {
const original = process.env.PI_TMUX_SUBAGENT_CHILD;
if (original !== undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD;
try {
let registerToolCalls = 0;
const handlers: Record<string, (event: any, ctx: any) => Promise<void> | void> = {};
tmuxSubagentExtension({
on(event: string, handler: (event: any, ctx: any) => Promise<void> | void) {
handlers[event] = handler;
},
registerTool() {
registerToolCalls++;
},
registerProvider() {},
} as any);
assert.equal(typeof handlers.session_start, "function");
assert.equal(typeof handlers.before_agent_start, "function");
// First registration with two models
await handlers.session_start?.(
{ reason: "startup" },
{
modelRegistry: {
getAvailable: () => [
{ provider: "anthropic", id: "claude-sonnet-4-5" },
{ provider: "openai", id: "gpt-5" },
],
},
},
);
assert.equal(registerToolCalls, 1);
// Second registration with the same models — should not increase count
await handlers.before_agent_start?.(
{ reason: "about-to-start" },
{
modelRegistry: {
getAvailable: () => [
{ provider: "anthropic", id: "claude-sonnet-4-5" },
{ provider: "openai", id: "gpt-5" },
],
},
},
);
assert.equal(registerToolCalls, 1);
// Third call with changed model list — should re-register
await handlers.session_start?.(
{ reason: "startup" },
{
modelRegistry: {
getAvailable: () => [
{ provider: "openai", id: "gpt-6" },
],
},
},
);
assert.equal(registerToolCalls, 2);
} finally {
if (original === undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD;
else process.env.PI_TMUX_SUBAGENT_CHILD = original;
}
});
// New tests for robustness: order-independence and empty model handling
test("same model set in different orders should NOT trigger re-registration", async () => {
const original = process.env.PI_TMUX_SUBAGENT_CHILD;
if (original !== undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD;
try {
let registerToolCalls = 0;
const handlers: Record<string, (event: any, ctx: any) => Promise<void> | void> = {};
tmuxSubagentExtension({
on(event: string, handler: (event: any, ctx: any) => Promise<void> | void) {
handlers[event] = handler;
},
registerTool() {
registerToolCalls++;
},
registerProvider() {},
} as any);
assert.equal(typeof handlers.session_start, "function");
assert.equal(typeof handlers.before_agent_start, "function");
// First registration with two models in one order
await handlers.session_start?.(
{ reason: "startup" },
{
modelRegistry: {
getAvailable: () => [
{ provider: "anthropic", id: "claude-sonnet-4-5" },
{ provider: "openai", id: "gpt-5" },
],
},
},
);
assert.equal(registerToolCalls, 1);
// Same models but reversed order — should NOT re-register
await handlers.before_agent_start?.(
{ reason: "about-to-start" },
{
modelRegistry: {
getAvailable: () => [
{ provider: "openai", id: "gpt-5" },
{ provider: "anthropic", id: "claude-sonnet-4-5" },
],
},
},
);
assert.equal(registerToolCalls, 1);
} finally {
if (original === undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD;
else process.env.PI_TMUX_SUBAGENT_CHILD = original;
}
});
test("empty model list should NOT register the tool, but a later non-empty list should", async () => {
const original = process.env.PI_TMUX_SUBAGENT_CHILD;
if (original !== undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD;
try {
let registerToolCalls = 0;
const handlers: Record<string, (event: any, ctx: any) => Promise<void> | void> = {};
tmuxSubagentExtension({
on(event: string, handler: (event: any, ctx: any) => Promise<void> | void) {
handlers[event] = handler;
},
registerTool() {
registerToolCalls++;
},
registerProvider() {},
} as any);
assert.equal(typeof handlers.session_start, "function");
assert.equal(typeof handlers.before_agent_start, "function");
// empty list should not register
await handlers.session_start?.(
{ reason: "startup" },
{
modelRegistry: {
getAvailable: () => [],
},
},
);
assert.equal(registerToolCalls, 0);
// later non-empty list should register
await handlers.before_agent_start?.(
{ reason: "about-to-start" },
{
modelRegistry: {
getAvailable: () => [
{ provider: "openai", id: "gpt-6" },
],
},
},
);
assert.equal(registerToolCalls, 1);
} finally {
if (original === undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD;
else process.env.PI_TMUX_SUBAGENT_CHILD = original;
}
});

44
src/models.test.ts Normal file
View File

@@ -0,0 +1,44 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
formatModelReference,
listAvailableModelReferences,
normalizeAvailableModelReference,
resolveChildModel,
} from "./models.ts";
test("resolveChildModel prefers the per-task override over the required top-level model", () => {
const selection = resolveChildModel({
taskModel: "openai/gpt-5",
topLevelModel: "anthropic/claude-sonnet-4-5",
});
assert.equal(selection.requestedModel, "openai/gpt-5");
assert.equal(selection.resolvedModel, "openai/gpt-5");
});
test("formatModelReference returns provider/id", () => {
const ref = formatModelReference({ provider: "anthropic", id: "claude-sonnet-4-5" });
assert.equal(ref, "anthropic/claude-sonnet-4-5");
});
test("listAvailableModelReferences formats all configured available models", () => {
const refs = listAvailableModelReferences({
getAvailable: () => [
{ provider: "anthropic", id: "claude-sonnet-4-5" },
{ provider: "openai", id: "gpt-5" },
],
});
assert.deepEqual(refs, ["anthropic/claude-sonnet-4-5", "openai/gpt-5"]);
});
test("normalizeAvailableModelReference matches canonical refs case-insensitively", () => {
const normalized = normalizeAvailableModelReference("OpenAI/GPT-5", [
"anthropic/claude-sonnet-4-5",
"openai/gpt-5",
]);
assert.equal(normalized, "openai/gpt-5");
});

58
src/models.ts Normal file
View File

@@ -0,0 +1,58 @@
export interface ModelLike {
provider: string;
id: string;
}
export interface AvailableModelRegistryLike {
getAvailable(): ModelLike[];
}
export interface ModelSelection {
requestedModel?: string;
resolvedModel?: string;
}
export function formatModelReference(model: ModelLike): string {
return `${model.provider}/${model.id}`;
}
export function listAvailableModelReferences(modelRegistry?: AvailableModelRegistryLike): string[] {
if (!modelRegistry) return [];
const seen = new Set<string>();
const refs: string[] = [];
for (const model of modelRegistry.getAvailable()) {
const ref = formatModelReference(model);
const key = ref.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
refs.push(ref);
}
return refs;
}
export function normalizeAvailableModelReference(
requestedModel: string | undefined,
availableModels: readonly string[],
): string | undefined {
if (typeof requestedModel !== "string") return undefined;
const trimmed = requestedModel.trim();
if (!trimmed) return undefined;
const normalized = trimmed.toLowerCase();
return availableModels.find((candidate) => candidate.toLowerCase() === normalized);
}
export function resolveChildModel(input: {
taskModel?: string;
topLevelModel: string;
}): ModelSelection {
const requestedModel = input.taskModel ?? input.topLevelModel;
return {
requestedModel,
resolvedModel: requestedModel,
};
}

33
src/monitor.test.ts Normal file
View File

@@ -0,0 +1,33 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, writeFile, appendFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { monitorRun } from "./monitor.ts";
test("monitorRun streams normalized events and resolves when result.json appears", async () => {
const dir = await mkdtemp(join(tmpdir(), "tmux-subagent-monitor-"));
const eventsPath = join(dir, "events.jsonl");
const resultPath = join(dir, "result.json");
await writeFile(eventsPath, "", "utf8");
const seen: string[] = [];
const waiting = monitorRun({
eventsPath,
resultPath,
onEvent(event) {
seen.push(event.type);
},
});
await appendFile(eventsPath, `${JSON.stringify({ type: "tool_call", toolName: "read", args: { path: "a.ts" } })}\n`, "utf8");
await writeFile(
resultPath,
JSON.stringify({ runId: "run-1", exitCode: 0, finalText: "done", agent: "scout", task: "inspect auth" }, null, 2),
"utf8",
);
const result = await waiting;
assert.deepEqual(seen, ["tool_call"]);
assert.equal(result.finalText, "done");
});

34
src/monitor.ts Normal file
View File

@@ -0,0 +1,34 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
async function sleep(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
export async function monitorRun(input: {
eventsPath: string;
resultPath: string;
onEvent?: (event: any) => void;
pollMs?: number;
}) {
const pollMs = input.pollMs ?? 50;
let offset = 0;
while (true) {
if (existsSync(input.eventsPath)) {
const text = await readFile(input.eventsPath, "utf8");
const next = text.slice(offset);
offset = text.length;
for (const line of next.split("\n").filter(Boolean)) {
input.onEvent?.(JSON.parse(line));
}
}
if (existsSync(input.resultPath)) {
return JSON.parse(await readFile(input.resultPath, "utf8"));
}
await sleep(pollMs);
}
}

View File

@@ -0,0 +1,33 @@
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-tmux-subagent as a standalone pi package", () => {
assert.equal(pkg.name, "pi-tmux-subagent");
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")));
});

20
src/prompts.test.ts Normal file
View File

@@ -0,0 +1,20 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
test("package.json exposes the extension and workflow prompt templates", () => {
const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
assert.deepEqual(packageJson.pi.extensions, ["./index.ts"]);
assert.deepEqual(packageJson.pi.prompts, ["./prompts/*.md"]);
for (const name of ["implement.md", "scout-and-plan.md", "implement-and-review.md"]) {
const content = readFileSync(join(packageRoot, "prompts", name), "utf8");
assert.match(content, /^---\ndescription:/m);
assert.match(content, /subagent/);
}
});

33
src/runner.test.ts Normal file
View File

@@ -0,0 +1,33 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTmuxSingleRunner } from "./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"]);
});

44
src/runner.ts Normal file
View File

@@ -0,0 +1,44 @@
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);
}
};
}

84
src/schema.ts Normal file
View File

@@ -0,0 +1,84 @@
import { StringEnum } from "@mariozechner/pi-ai";
import { Type, type Static } from "@sinclair/typebox";
function createTaskModelSchema(availableModels: readonly string[]) {
return Type.Optional(
StringEnum(availableModels, {
description: "Optional child model override. Must be one of the currently available models.",
}),
);
}
export function createTaskItemSchema(availableModels: readonly string[]) {
return Type.Object({
agent: Type.String({ description: "Name of the agent to invoke" }),
task: Type.String({ description: "Task to delegate to the child agent" }),
model: createTaskModelSchema(availableModels),
cwd: Type.Optional(Type.String({ description: "Optional working directory override" })),
});
}
export function createChainItemSchema(availableModels: readonly string[]) {
return Type.Object({
agent: Type.String({ description: "Name of the agent to invoke" }),
task: Type.String({ description: "Task with optional {previous} placeholder" }),
model: createTaskModelSchema(availableModels),
cwd: Type.Optional(Type.String({ description: "Optional working directory override" })),
});
}
export const TaskItemSchema = createTaskItemSchema([]);
export const ChainItemSchema = createChainItemSchema([]);
export const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
description: "Which markdown agent sources to use",
default: "user",
});
export function createSubagentParamsSchema(availableModels: readonly string[]) {
return Type.Object({
agent: Type.Optional(Type.String({ description: "Single-mode agent name" })),
task: Type.Optional(Type.String({ description: "Single-mode delegated task" })),
model: StringEnum(availableModels, {
description: "Required top-level child model. Must be one of the currently available models.",
}),
tasks: Type.Optional(Type.Array(createTaskItemSchema(availableModels), { description: "Parallel tasks" })),
chain: Type.Optional(Type.Array(createChainItemSchema(availableModels), { description: "Sequential tasks" })),
agentScope: Type.Optional(AgentScopeSchema),
confirmProjectAgents: Type.Optional(Type.Boolean({ default: true })),
cwd: Type.Optional(Type.String({ description: "Single-mode working directory override" })),
});
}
export const SubagentParamsSchema = createSubagentParamsSchema([]);
export type TaskItem = Static<typeof TaskItemSchema>;
export type ChainItem = Static<typeof ChainItemSchema>;
export type SubagentParams = Static<typeof SubagentParamsSchema>;
export type AgentScope = Static<typeof AgentScopeSchema>;
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;
resultPath?: string;
eventsPath?: string;
}
export interface SubagentToolDetails {
mode: "single" | "parallel" | "chain";
agentScope: AgentScope;
projectAgentsDir: string | null;
results: SubagentRunResult[];
}

43
src/tmux.test.ts Normal file
View File

@@ -0,0 +1,43 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildSplitWindowArgs,
buildWrapperShellCommand,
isInsideTmux,
} from "./tmux.ts";
test("isInsideTmux reads the TMUX environment variable", () => {
assert.equal(isInsideTmux({ TMUX: "/tmp/tmux-1000/default,123,0" } as NodeJS.ProcessEnv), true);
assert.equal(isInsideTmux({} as NodeJS.ProcessEnv), false);
});
test("buildWrapperShellCommand single-quotes paths safely", () => {
const command = buildWrapperShellCommand({
nodePath: "/usr/local/bin/node",
wrapperPath: "/repo/tmux-subagent/src/wrapper/cli.mjs",
metaPath: "/repo/.pi/subagents/runs/run-1/meta.json",
});
assert.equal(
command,
"'/usr/local/bin/node' '/repo/tmux-subagent/src/wrapper/cli.mjs' '/repo/.pi/subagents/runs/run-1/meta.json'",
);
});
test("buildSplitWindowArgs targets the current window and cwd", () => {
assert.deepEqual(buildSplitWindowArgs({
windowId: "@7",
cwd: "/repo",
command: "'node' '/wrapper.mjs' '/meta.json'",
}), [
"split-window",
"-P",
"-F",
"#{pane_id}",
"-t",
"@7",
"-c",
"/repo",
"'node' '/wrapper.mjs' '/meta.json'",
]);
});

41
src/tmux.ts Normal file
View File

@@ -0,0 +1,41 @@
export function isInsideTmux(env: NodeJS.ProcessEnv = process.env): boolean {
return typeof env.TMUX === "string" && env.TMUX.length > 0;
}
function shellEscape(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
export function buildWrapperShellCommand(input: {
nodePath: string;
wrapperPath: string;
metaPath: string;
}): string {
return [input.nodePath, input.wrapperPath, input.metaPath].map(shellEscape).join(" ");
}
export function buildSplitWindowArgs(input: {
windowId: string;
cwd: string;
command: string;
}): string[] {
return [
"split-window",
"-P",
"-F",
"#{pane_id}",
"-t",
input.windowId,
"-c",
input.cwd,
input.command,
];
}
export function buildKillPaneArgs(paneId: string): string[] {
return ["kill-pane", "-t", paneId];
}
export function buildCurrentWindowArgs(): string[] {
return ["display-message", "-p", "#{window_id}"];
}

107
src/tool-chain.test.ts Normal file
View File

@@ -0,0 +1,107 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createSubagentTool } from "./tool.ts";
test("chain mode substitutes {previous} into the next task", async () => {
const seenTasks: string[] = [];
const tool = createSubagentTool({
discoverAgents: () => ({
agents: [
{ name: "scout", description: "Scout", source: "builtin", systemPrompt: "Scout prompt" },
{ name: "planner", description: "Planner", source: "builtin", systemPrompt: "Planner prompt" },
],
projectAgentsDir: null,
}),
runSingleTask: async ({ meta }: any) => {
seenTasks.push(meta.task);
return {
runId: `${meta.agent}-${seenTasks.length}`,
agent: meta.agent,
agentSource: meta.agentSource,
task: meta.task,
exitCode: 0,
finalText: meta.agent === "scout" ? "Scout output" : "Plan output",
};
},
} as any);
const result = await tool.execute(
"tool-1",
{
model: "anthropic/claude-sonnet-4-5",
chain: [
{ agent: "scout", task: "inspect auth" },
{ agent: "planner", task: "use this context: {previous}" },
],
},
undefined,
undefined,
{
cwd: "/repo",
modelRegistry: {
getAvailable: () => [{ provider: "anthropic", id: "claude-sonnet-4-5" }],
},
hasUI: false,
} as any,
);
assert.deepEqual(seenTasks, ["inspect auth", "use this context: Scout output"]);
assert.equal(result.content[0]?.type === "text" ? result.content[0].text : "", "Plan output");
});
test("chain mode stops on the first failed step", async () => {
const tool = createSubagentTool({
discoverAgents: () => ({
agents: [
{ name: "scout", description: "Scout", source: "builtin", systemPrompt: "Scout prompt" },
{ name: "planner", description: "Planner", source: "builtin", systemPrompt: "Planner prompt" },
],
projectAgentsDir: null,
}),
runSingleTask: async ({ meta }: any) => {
if (meta.agent === "planner") {
return {
runId: "planner-2",
agent: meta.agent,
agentSource: meta.agentSource,
task: meta.task,
exitCode: 1,
finalText: "",
stopReason: "error",
};
}
return {
runId: "scout-1",
agent: meta.agent,
agentSource: meta.agentSource,
task: meta.task,
exitCode: 0,
finalText: "Scout output",
};
},
} as any);
const result = await tool.execute(
"tool-1",
{
model: "anthropic/claude-sonnet-4-5",
chain: [
{ agent: "scout", task: "inspect auth" },
{ agent: "planner", task: "use this context: {previous}" },
],
},
undefined,
undefined,
{
cwd: "/repo",
modelRegistry: {
getAvailable: () => [{ provider: "anthropic", id: "claude-sonnet-4-5" }],
},
hasUI: false,
} as any,
);
assert.equal(result.isError, true);
assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /Chain stopped at step 2/);
});

97
src/tool-parallel.test.ts Normal file
View File

@@ -0,0 +1,97 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createSubagentTool } from "./tool.ts";
test("parallel mode runs each task and uses the top-level model unless a task overrides it", async () => {
const requestedModels: Array<string | undefined> = [];
const tool = createSubagentTool({
discoverAgents: () => ({
agents: [
{ name: "scout", description: "Scout", source: "builtin", systemPrompt: "Scout prompt" },
{ name: "reviewer", description: "Reviewer", source: "builtin", systemPrompt: "Reviewer prompt" },
],
projectAgentsDir: null,
}),
resolveChildModel: ({ taskModel, topLevelModel }: any) => ({
requestedModel: taskModel ?? topLevelModel,
resolvedModel: taskModel ?? topLevelModel,
}),
runSingleTask: async ({ meta }: any) => {
requestedModels.push(meta.requestedModel);
return {
runId: `${meta.agent}-${meta.task}`,
agent: meta.agent,
agentSource: meta.agentSource,
task: meta.task,
requestedModel: meta.requestedModel,
resolvedModel: meta.requestedModel,
exitCode: 0,
finalText: `${meta.agent}:${meta.task}`,
};
},
} as any);
const result = await tool.execute(
"tool-1",
{
model: "openai/gpt-5",
tasks: [
{ agent: "scout", task: "find auth code" },
{ agent: "reviewer", task: "review auth code", model: "anthropic/claude-opus-4-5" },
],
},
undefined,
undefined,
{
cwd: "/repo",
modelRegistry: {
getAvailable: () => [
{ provider: "openai", id: "gpt-5" },
{ provider: "anthropic", id: "claude-opus-4-5" },
],
},
hasUI: false,
} as any,
);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
assert.match(text, /2\/2 succeeded/);
assert.deepEqual(requestedModels, ["openai/gpt-5", "anthropic/claude-opus-4-5"]);
});
test("parallel mode rejects per-task model overrides that are not currently available", async () => {
let didRun = false;
const tool = createSubagentTool({
discoverAgents: () => ({
agents: [{ name: "scout", description: "Scout", source: "builtin", systemPrompt: "Scout prompt" }],
projectAgentsDir: null,
}),
runSingleTask: async () => {
didRun = true;
throw new Error("should not run");
},
} as any);
const result = await tool.execute(
"tool-1",
{
model: "anthropic/claude-sonnet-4-5",
tasks: [{ agent: "scout", task: "find auth code", model: "openai/gpt-5" }],
},
undefined,
undefined,
{
cwd: "/repo",
modelRegistry: {
getAvailable: () => [{ provider: "anthropic", id: "claude-sonnet-4-5" }],
},
hasUI: false,
} as any,
);
assert.equal(didRun, false);
assert.equal(result.isError, true);
assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /parallel task 1/i);
});

177
src/tool.test.ts Normal file
View File

@@ -0,0 +1,177 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createSubagentTool } from "./tool.ts";
test("single-mode subagent uses the required top-level model, emits progress, and returns final text plus metadata", async () => {
const updates: string[] = [];
const tool = createSubagentTool({
discoverAgents: () => ({
agents: [
{
name: "scout",
description: "Scout",
model: "claude-haiku-4-5",
systemPrompt: "Scout prompt",
source: "builtin",
},
],
projectAgentsDir: null,
}),
runSingleTask: async ({ onEvent, meta }: any) => {
onEvent?.({ type: "tool_call", toolName: "read", args: { path: "src/auth.ts" } });
return {
runId: "run-1",
agent: "scout",
agentSource: "builtin",
task: "inspect auth",
requestedModel: meta.requestedModel,
resolvedModel: meta.resolvedModel,
paneId: "%3",
windowId: "@1",
sessionPath: "/repo/.pi/subagents/runs/run-1/child-session.jsonl",
exitCode: 0,
finalText: "Auth code is in src/auth.ts",
};
},
} as any);
const result = await tool.execute(
"tool-1",
{
agent: "scout",
task: "inspect auth",
model: "anthropic/claude-sonnet-4-5",
},
undefined,
(partial: any) => {
const first = partial.content?.[0];
if (first?.type === "text") updates.push(first.text);
},
{
cwd: "/repo",
modelRegistry: {
getAvailable: () => [{ provider: "anthropic", id: "claude-sonnet-4-5" }],
},
hasUI: false,
} as any,
);
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
assert.equal(text, "Auth code is in src/auth.ts");
assert.equal(result.details.results[0]?.paneId, "%3");
assert.equal(result.details.results[0]?.requestedModel, "anthropic/claude-sonnet-4-5");
assert.match(updates.join("\n"), /Running scout/);
});
test("single-mode subagent requires a top-level model even when execute is called directly", async () => {
let didRun = false;
const tool = createSubagentTool({
discoverAgents: () => ({
agents: [{ name: "scout", description: "Scout", systemPrompt: "Scout prompt", source: "builtin" }],
projectAgentsDir: null,
}),
runSingleTask: async () => {
didRun = true;
throw new Error("should not run");
},
} as any);
const result = await tool.execute(
"tool-1",
{ agent: "scout", task: "inspect auth" },
undefined,
undefined,
{
cwd: "/repo",
modelRegistry: {
getAvailable: () => [{ provider: "anthropic", id: "claude-sonnet-4-5" }],
},
hasUI: false,
} as any,
);
assert.equal(didRun, false);
assert.equal(result.isError, true);
assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /top-level model/i);
});
test("single-mode subagent rejects models that are not currently available", async () => {
let didRun = false;
const tool = createSubagentTool({
discoverAgents: () => ({
agents: [{ name: "scout", description: "Scout", systemPrompt: "Scout prompt", source: "builtin" }],
projectAgentsDir: null,
}),
runSingleTask: async () => {
didRun = true;
throw new Error("should not run");
},
} as any);
const result = await tool.execute(
"tool-1",
{
agent: "scout",
task: "inspect auth",
model: "openai/gpt-5",
},
undefined,
undefined,
{
cwd: "/repo",
modelRegistry: {
getAvailable: () => [{ provider: "anthropic", id: "claude-sonnet-4-5" }],
},
hasUI: false,
} as any,
);
assert.equal(didRun, false);
assert.equal(result.isError, true);
assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /available models/i);
});
test("single-mode subagent asks before running a project-local agent", async () => {
const tool = createSubagentTool({
discoverAgents: () => ({
agents: [
{
name: "reviewer",
description: "Reviewer",
systemPrompt: "Review prompt",
source: "project",
},
],
projectAgentsDir: "/repo/.pi/agents",
}),
runSingleTask: async () => {
throw new Error("should not run");
},
} as any);
const result = await tool.execute(
"tool-1",
{
agent: "reviewer",
task: "review auth",
model: "anthropic/claude-sonnet-4-5",
agentScope: "both",
},
undefined,
undefined,
{
cwd: "/repo",
modelRegistry: {
getAvailable: () => [{ provider: "anthropic", id: "claude-sonnet-4-5" }],
},
hasUI: true,
ui: { confirm: async () => false },
} as any,
);
assert.equal(result.isError, true);
assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /not approved/);
});

336
src/tool.ts Normal file
View File

@@ -0,0 +1,336 @@
import { Text } from "@mariozechner/pi-tui";
import { discoverAgents } from "./agents.ts";
import {
listAvailableModelReferences,
normalizeAvailableModelReference,
resolveChildModel,
} from "./models.ts";
import {
SubagentParamsSchema,
type AgentScope,
type SubagentRunResult,
type SubagentToolDetails,
} from "./schema.ts";
const MAX_PARALLEL_TASKS = 8;
const MAX_CONCURRENCY = 4;
async function mapWithConcurrencyLimit<TIn, TOut>(
items: TIn[],
concurrency: number,
fn: (item: TIn, index: number) => Promise<TOut>,
): Promise<TOut[]> {
const limit = Math.max(1, Math.min(concurrency, items.length || 1));
const results = new Array<TOut>(items.length);
let nextIndex = 0;
await Promise.all(
Array.from({ length: limit }, async () => {
while (nextIndex < items.length) {
const index = nextIndex++;
results[index] = await fn(items[index], index);
}
}),
);
return results;
}
function isFailure(result: Pick<SubagentRunResult, "exitCode" | "stopReason">) {
return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
}
function makeDetails(
mode: "single" | "parallel" | "chain",
agentScope: AgentScope,
projectAgentsDir: string | null,
results: SubagentRunResult[],
): SubagentToolDetails {
return { mode, agentScope, projectAgentsDir, results };
}
function makeErrorResult(
text: string,
mode: "single" | "parallel" | "chain",
agentScope: AgentScope,
projectAgentsDir: string | null,
) {
return {
content: [{ type: "text" as const, text }],
details: makeDetails(mode, agentScope, projectAgentsDir, []),
isError: true,
};
}
export function createSubagentTool(deps: {
discoverAgents?: typeof discoverAgents;
listAvailableModelReferences?: typeof listAvailableModelReferences;
normalizeAvailableModelReference?: typeof normalizeAvailableModelReference;
parameters?: typeof SubagentParamsSchema;
resolveChildModel?: typeof resolveChildModel;
runSingleTask?: (input: {
cwd: string;
meta: Record<string, unknown>;
onEvent?: (event: any) => void;
}) => Promise<SubagentRunResult>;
} = {}) {
return {
name: "subagent",
label: "Subagent",
description: "Delegate tasks to specialized agents running in tmux panes.",
parameters: deps.parameters ?? SubagentParamsSchema,
async execute(_toolCallId: string, params: any, _signal: AbortSignal | undefined, onUpdate: any, ctx: any) {
const hasSingle = Boolean(params.agent && params.task);
const hasParallel = Boolean(params.tasks?.length);
const hasChain = Boolean(params.chain?.length);
const modeCount = Number(hasSingle) + Number(hasParallel) + Number(hasChain);
const mode = hasParallel ? "parallel" : hasChain ? "chain" : "single";
const agentScope = (params.agentScope ?? "user") as AgentScope;
if (modeCount !== 1) {
return makeErrorResult("Provide exactly one mode: single, parallel, or chain.", "single", agentScope, null);
}
const discovery = (deps.discoverAgents ?? discoverAgents)(ctx.cwd, { scope: agentScope });
const availableModelReferences = (deps.listAvailableModelReferences ?? listAvailableModelReferences)(ctx.modelRegistry);
const availableModelsText = availableModelReferences.join(", ") || "(none)";
const normalizeModelReference = (requestedModel?: string) =>
(deps.normalizeAvailableModelReference ?? normalizeAvailableModelReference)(requestedModel, availableModelReferences);
if (availableModelReferences.length === 0) {
return makeErrorResult(
"No available models are configured. Configure at least one model before using subagent.",
mode,
agentScope,
discovery.projectAgentsDir,
);
}
const topLevelModel = normalizeModelReference(params.model);
if (!topLevelModel) {
const message =
typeof params.model !== "string" || params.model.trim().length === 0
? `Subagent requires a top-level model chosen from the available models: ${availableModelsText}`
: `Invalid top-level model "${params.model}". Choose one of the available models: ${availableModelsText}`;
return makeErrorResult(message, mode, agentScope, discovery.projectAgentsDir);
}
params.model = topLevelModel;
for (const [index, task] of (params.tasks ?? []).entries()) {
if (task.model === undefined) continue;
const normalizedTaskModel = normalizeModelReference(task.model);
if (!normalizedTaskModel) {
return makeErrorResult(
`Invalid model for parallel task ${index + 1} (${task.agent}): "${task.model}". Choose one of the available models: ${availableModelsText}`,
mode,
agentScope,
discovery.projectAgentsDir,
);
}
task.model = normalizedTaskModel;
}
for (const [index, step] of (params.chain ?? []).entries()) {
if (step.model === undefined) continue;
const normalizedStepModel = normalizeModelReference(step.model);
if (!normalizedStepModel) {
return makeErrorResult(
`Invalid model for chain step ${index + 1} (${step.agent}): "${step.model}". Choose one of the available models: ${availableModelsText}`,
mode,
agentScope,
discovery.projectAgentsDir,
);
}
step.model = normalizedStepModel;
}
const requestedAgentNames = [
...(hasSingle ? [params.agent] : []),
...((params.tasks ?? []).map((task: any) => task.agent)),
...((params.chain ?? []).map((step: any) => step.agent)),
];
const projectAgents = requestedAgentNames
.map((name) => discovery.agents.find((candidate) => candidate.name === name))
.filter((agent): agent is NonNullable<typeof agent> => Boolean(agent && agent.source === "project"));
if (projectAgents.length > 0 && (params.confirmProjectAgents ?? true) && ctx.hasUI) {
const ok = await ctx.ui.confirm(
"Run project-local agents?",
`Agents: ${projectAgents.map((agent) => agent.name).join(", ")}\nSource: ${
discovery.projectAgentsDir ?? "(unknown)"
}`,
);
if (!ok) {
return makeErrorResult(
"Canceled: project-local agents not approved.",
mode,
agentScope,
discovery.projectAgentsDir,
);
}
}
const resolveAgent = (name: string) => {
const agent = discovery.agents.find((candidate) => candidate.name === name);
if (!agent) throw new Error(`Unknown agent: ${name}`);
return agent;
};
const runTask = async (input: {
agentName: string;
task: string;
cwd?: string;
taskModel?: string;
taskIndex?: number;
step?: number;
mode: "single" | "parallel" | "chain";
}) => {
const agent = resolveAgent(input.agentName);
const model = (deps.resolveChildModel ?? resolveChildModel)({
taskModel: input.taskModel,
topLevelModel: params.model,
});
return deps.runSingleTask?.({
cwd: input.cwd ?? ctx.cwd,
onEvent(event) {
onUpdate?.({
content: [{ type: "text", text: `Running ${input.agentName}: ${event.type}` }],
details: makeDetails(input.mode, agentScope, discovery.projectAgentsDir, []),
});
},
meta: {
mode: input.mode,
taskIndex: input.taskIndex,
step: input.step,
agent: agent.name,
agentSource: agent.source,
task: input.task,
cwd: input.cwd ?? ctx.cwd,
requestedModel: model.requestedModel,
resolvedModel: model.resolvedModel,
systemPrompt: agent.systemPrompt,
tools: agent.tools,
},
}) as Promise<SubagentRunResult>;
};
if (hasSingle) {
try {
const result = await runTask({
agentName: params.agent,
task: params.task,
cwd: params.cwd,
mode: "single",
});
return {
content: [{ type: "text" as const, text: result.finalText }],
details: makeDetails("single", agentScope, discovery.projectAgentsDir, [result]),
isError: isFailure(result),
};
} catch (error) {
return {
content: [{ type: "text" as const, text: (error as Error).message }],
details: makeDetails("single", agentScope, discovery.projectAgentsDir, []),
isError: true,
};
}
}
if (hasParallel) {
if (params.tasks.length > MAX_PARALLEL_TASKS) {
return {
content: [
{
type: "text" as const,
text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
},
],
details: makeDetails("parallel", agentScope, discovery.projectAgentsDir, []),
isError: true,
};
}
const liveResults: SubagentRunResult[] = [];
const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (task: any, index) => {
const result = await runTask({
agentName: task.agent,
task: task.task,
cwd: task.cwd,
taskModel: task.model,
taskIndex: index,
mode: "parallel",
});
liveResults[index] = result;
onUpdate?.({
content: [{ type: "text", text: `Parallel: ${liveResults.filter(Boolean).length}/${params.tasks.length} finished` }],
details: makeDetails("parallel", agentScope, discovery.projectAgentsDir, liveResults.filter(Boolean)),
});
return result;
});
const successCount = results.filter((result) => !isFailure(result)).length;
const summary = results
.map((result) => `[${result.agent}] ${isFailure(result) ? "failed" : "completed"}: ${result.finalText || "(no output)"}`)
.join("\n\n");
return {
content: [{ type: "text" as const, text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summary}` }],
details: makeDetails("parallel", agentScope, discovery.projectAgentsDir, results),
isError: successCount !== results.length,
};
}
const results: SubagentRunResult[] = [];
let previous = "";
for (let index = 0; index < params.chain.length; index += 1) {
const item = params.chain[index];
const task = item.task.replaceAll("{previous}", previous);
const result = await runTask({
agentName: item.agent,
task,
cwd: item.cwd,
taskModel: item.model,
step: index + 1,
mode: "chain",
});
onUpdate?.({
content: [{ type: "text", text: `Chain: completed step ${index + 1}/${params.chain.length}` }],
details: makeDetails("chain", agentScope, discovery.projectAgentsDir, [...results, result]),
});
results.push(result);
if (isFailure(result)) {
return {
content: [
{
type: "text" as const,
text: `Chain stopped at step ${index + 1} (${item.agent}): ${result.finalText || result.stopReason || "failed"}`,
},
],
details: makeDetails("chain", agentScope, discovery.projectAgentsDir, results),
isError: true,
};
}
previous = result.finalText;
}
const finalResult = results[results.length - 1];
return {
content: [{ type: "text" as const, text: finalResult?.finalText ?? "" }],
details: makeDetails("chain", agentScope, discovery.projectAgentsDir, results),
};
},
renderCall(args: any) {
if (args.tasks?.length) return new Text(`subagent parallel (${args.tasks.length} tasks)`, 0, 0);
if (args.chain?.length) return new Text(`subagent chain (${args.chain.length} steps)`, 0, 0);
return new Text(`subagent ${args.agent ?? ""}`.trim(), 0, 0);
},
renderResult(result: { content: Array<{ type: string; text?: string }> }) {
const first = result.content[0];
return new Text(first?.type === "text" ? first.text ?? "" : "", 0, 0);
},
};
}

214
src/wrapper/cli.mjs Normal file
View File

@@ -0,0 +1,214 @@
import { appendFile, readFile, writeFile } from "node:fs/promises";
import { spawn } from "node:child_process";
import { normalizePiEvent } from "./normalize.mjs";
import { renderHeader, renderEventLine } from "./render.mjs";
async function appendJsonLine(path, value) {
await appendBestEffort(path, `${JSON.stringify(value)}\n`);
}
async function appendBestEffort(path, text) {
try {
await appendFile(path, text, "utf8");
} catch {
// Best-effort artifact logging should never prevent result.json from being written.
}
}
function makeResult(meta, startedAt, input = {}) {
const errorText = typeof input.errorMessage === "string" ? input.errorMessage.trim() : "";
const exitCode = typeof input.exitCode === "number" ? input.exitCode : 1;
return {
runId: meta.runId,
mode: meta.mode,
taskIndex: meta.taskIndex,
step: meta.step,
agent: meta.agent,
agentSource: meta.agentSource,
task: meta.task,
cwd: meta.cwd,
requestedModel: meta.requestedModel,
resolvedModel: input.resolvedModel ?? meta.resolvedModel,
sessionPath: meta.sessionPath,
startedAt,
finishedAt: new Date().toISOString(),
exitCode,
stopReason: input.stopReason ?? (exitCode === 0 ? undefined : "error"),
finalText: input.finalText ?? "",
usage: input.usage,
stdoutPath: meta.stdoutPath,
stderrPath: meta.stderrPath,
resultPath: meta.resultPath,
eventsPath: meta.eventsPath,
transcriptPath: meta.transcriptPath,
errorMessage: errorText || undefined,
};
}
async function runWrapper(meta, startedAt) {
const header = renderHeader(meta);
await appendBestEffort(meta.transcriptPath, `${header}\n`);
console.log(header);
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);
if (Array.isArray(meta.tools) && meta.tools.length > 0) args.push("--tools", meta.tools.join(","));
if (meta.systemPromptPath) args.push("--append-system-prompt", meta.systemPromptPath);
args.push(meta.task);
let finalText = "";
let resolvedModel = meta.resolvedModel;
let stopReason;
let usage = undefined;
let stdoutBuffer = "";
let stderrText = "";
let spawnError;
let queue = Promise.resolve();
const enqueue = (work) => {
queue = queue.then(work, work);
return queue;
};
const handleStdoutLine = async (line) => {
if (!line.trim()) return;
let parsed;
try {
parsed = JSON.parse(line);
} catch {
return;
}
const normalized = normalizePiEvent(parsed);
if (!normalized) return;
await appendJsonLine(meta.eventsPath, normalized);
const rendered = renderEventLine(normalized);
await appendBestEffort(meta.transcriptPath, `${rendered}\n`);
console.log(rendered);
if (normalized.type === "assistant_text") {
finalText = normalized.text;
resolvedModel = normalized.model ?? resolvedModel;
stopReason = normalized.stopReason ?? stopReason;
usage = normalized.usage ?? usage;
}
};
const childEnv = { ...process.env };
// Ensure the copilot initiator flag is not accidentally inherited from the parent
// environment; set it only for github-copilot models.
delete childEnv.PI_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR;
// Mark every child run as a nested tmux subagent so it cannot spawn further subagents.
childEnv.PI_TMUX_SUBAGENT_CHILD = "1";
if (typeof effectiveModel === "string" && effectiveModel.startsWith("github-copilot/")) {
childEnv.PI_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR = "agent";
}
const child = spawn("pi", args, {
cwd: meta.cwd,
env: childEnv,
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (chunk) => {
const text = chunk.toString();
enqueue(async () => {
stdoutBuffer += text;
await appendBestEffort(meta.stdoutPath, text);
const lines = stdoutBuffer.split("\n");
stdoutBuffer = lines.pop() ?? "";
for (const line of lines) {
await handleStdoutLine(line);
}
});
});
child.stderr.on("data", (chunk) => {
const text = chunk.toString();
enqueue(async () => {
stderrText += text;
await appendBestEffort(meta.stderrPath, text);
});
});
const exitCode = await new Promise((resolve) => {
let done = false;
const finish = (code) => {
if (done) return;
done = true;
resolve(code);
};
child.on("error", (error) => {
spawnError = error;
finish(1);
});
child.on("close", (code) => {
finish(code ?? (spawnError ? 1 : 0));
});
});
await queue;
if (stdoutBuffer.trim()) {
await handleStdoutLine(stdoutBuffer);
stdoutBuffer = "";
}
if (spawnError) {
const message = spawnError instanceof Error ? spawnError.stack ?? spawnError.message : String(spawnError);
if (!stderrText.trim()) {
stderrText = message;
await appendBestEffort(meta.stderrPath, `${message}\n`);
}
}
return makeResult(meta, startedAt, {
exitCode,
stopReason,
finalText,
usage,
resolvedModel,
errorMessage: stderrText,
});
}
async function main() {
const metaPath = process.argv[2];
if (!metaPath) throw new Error("Expected meta.json path as argv[2]");
const meta = JSON.parse(await readFile(metaPath, "utf8"));
const startedAt = meta.startedAt ?? new Date().toISOString();
let result;
try {
result = await runWrapper(meta, startedAt);
} catch (error) {
const message = error instanceof Error ? error.stack ?? error.message : String(error);
await appendBestEffort(meta.stderrPath, `${message}\n`);
result = makeResult(meta, startedAt, {
exitCode: 1,
stopReason: "error",
errorMessage: message,
});
}
await writeFile(meta.resultPath, JSON.stringify(result, null, 2), "utf8");
if (result.exitCode !== 0) process.exitCode = result.exitCode;
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack : String(error));
process.exitCode = 1;
});

192
src/wrapper/cli.test.ts Normal file
View File

@@ -0,0 +1,192 @@
import test from "node:test";
import assert from "node:assert/strict";
import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { spawn } from "node:child_process";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
function waitForExit(child: ReturnType<typeof spawn>, timeoutMs = 1500): Promise<number> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error(`wrapper did not exit within ${timeoutMs}ms`));
}, timeoutMs);
child.on("error", (error) => {
clearTimeout(timeout);
reject(error);
});
child.on("close", (code) => {
clearTimeout(timeout);
resolve(code ?? 0);
});
});
}
async function runWrapperWithFakePi(requestedModel: string, resolvedModel?: string) {
const dir = await mkdtemp(join(tmpdir(), "tmux-subagent-wrapper-"));
const metaPath = join(dir, "meta.json");
const resultPath = join(dir, "result.json");
const capturePath = join(dir, "capture.json");
const piPath = join(dir, "pi");
// The fake `pi` is a small Node script that writes a JSON capture file
// including relevant PI_* environment variables and the argv it received.
const resolved = typeof resolvedModel === "string" ? resolvedModel : requestedModel;
await writeFile(
piPath,
[
`#!${process.execPath}`,
"const fs = require('fs');",
`const capturePath = ${JSON.stringify(capturePath)};`,
"const obj = {",
" PI_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR: process.env.PI_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR || '',",
" PI_TMUX_SUBAGENT_CHILD: process.env.PI_TMUX_SUBAGENT_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",
);
await chmod(piPath, 0o755);
await writeFile(
metaPath,
JSON.stringify(
{
runId: "run-1",
mode: "single",
agent: "scout",
agentSource: "builtin",
task: "inspect auth",
cwd: dir,
requestedModel,
resolvedModel: resolved,
startedAt: "2026-04-09T00:00:00.000Z",
sessionPath: join(dir, "child-session.jsonl"),
eventsPath: join(dir, "events.jsonl"),
resultPath,
stdoutPath: join(dir, "stdout.log"),
stderrPath: join(dir, "stderr.log"),
transcriptPath: join(dir, "transcript.log"),
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 captureJson = JSON.parse(await readFile(capturePath, "utf8"));
return { flags: captureJson };
}
// Dedicated tests: every child run must have PI_TMUX_SUBAGENT_CHILD=1
test("wrapper marks github-copilot child run as a tmux subagent child", async () => {
const captured = await runWrapperWithFakePi("github-copilot/gpt-4o");
assert.equal(captured.flags.PI_TMUX_SUBAGENT_CHILD, "1");
});
test("wrapper marks anthropic child run as a tmux subagent child", async () => {
const captured = await runWrapperWithFakePi("anthropic/claude-sonnet-4-5");
assert.equal(captured.flags.PI_TMUX_SUBAGENT_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_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR, "agent");
assert.equal(captured.flags.PI_TMUX_SUBAGENT_CHILD, "1");
});
test("wrapper leaves non-copilot child runs unchanged", async () => {
const captured = await runWrapperWithFakePi("anthropic/claude-sonnet-4-5");
// The wrapper should not inject the copilot initiator for non-copilot models.
assert.equal(captured.flags.PI_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR, "");
assert.equal(captured.flags.PI_TMUX_SUBAGENT_CHILD, "1");
});
// Regression test: ensure when requestedModel and resolvedModel differ, the
// wrapper uses the same effective model for the child --model arg and the
// copilot initiator env flag.
test("wrapper uses effective model for both argv and env when requested/resolved differ", async () => {
const requested = "anthropic/claude-sonnet-4-5";
const resolved = "github-copilot/gpt-4o";
const captured = await runWrapperWithFakePi(requested, resolved);
// The effective model should be the resolved model in this case.
assert.equal(captured.flags.PI_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR, "agent");
assert.equal(captured.flags.PI_TMUX_SUBAGENT_CHILD, "1");
// Verify the child argv contains the effective model after a --model flag.
const argv = captured.flags.argv;
const modelIndex = argv.indexOf("--model");
assert.ok(modelIndex >= 0, "expected --model in argv");
assert.equal(argv[modelIndex + 1], resolved);
});
test("wrapper exits and writes result.json when the pi child cannot be spawned", async () => {
const dir = await mkdtemp(join(tmpdir(), "tmux-subagent-wrapper-"));
const metaPath = join(dir, "meta.json");
const resultPath = join(dir, "result.json");
await writeFile(
metaPath,
JSON.stringify(
{
runId: "run-1",
mode: "single",
agent: "scout",
agentSource: "builtin",
task: "inspect auth",
cwd: dir,
requestedModel: "anthropic/claude-sonnet-4-5",
resolvedModel: "anthropic/claude-sonnet-4-5",
startedAt: "2026-04-09T00:00:00.000Z",
sessionPath: join(dir, "child-session.jsonl"),
eventsPath: join(dir, "events.jsonl"),
resultPath,
stdoutPath: join(dir, "stdout.log"),
stderrPath: join(dir, "stderr.log"),
transcriptPath: join(dir, "transcript.log"),
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, 1);
const result = JSON.parse(await readFile(resultPath, "utf8"));
assert.equal(result.runId, "run-1");
assert.equal(result.agent, "scout");
assert.equal(result.exitCode, 1);
assert.match(result.errorMessage ?? "", /ENOENT|not found|spawn pi/i);
});

35
src/wrapper/normalize.mjs Normal file
View File

@@ -0,0 +1,35 @@
export function normalizePiEvent(event) {
if (event?.type === "tool_execution_start") {
return {
type: "tool_call",
toolName: event.toolName,
args: event.args ?? {},
};
}
if (event?.type === "message_end" && event.message?.role === "assistant") {
const text = (event.message.content ?? [])
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
.trim();
return {
type: "assistant_text",
text,
model: event.message.model,
stopReason: event.message.stopReason,
usage: event.message.usage,
};
}
if (event?.type === "tool_execution_end") {
return {
type: "tool_result",
toolName: event.toolName,
isError: Boolean(event.isError),
};
}
return null;
}

View File

@@ -0,0 +1,38 @@
import test from "node:test";
import assert from "node:assert/strict";
import { normalizePiEvent } from "./normalize.mjs";
test("normalizePiEvent converts tool start events into protocol tool-call records", () => {
const normalized = normalizePiEvent({
type: "tool_execution_start",
toolName: "read",
args: { path: "src/app.ts", offset: 1, limit: 20 },
});
assert.deepEqual(normalized, {
type: "tool_call",
toolName: "read",
args: { path: "src/app.ts", offset: 1, limit: 20 },
});
});
test("normalizePiEvent converts assistant message_end into a final-text record", () => {
const normalized = normalizePiEvent({
type: "message_end",
message: {
role: "assistant",
model: "anthropic/claude-sonnet-4-5",
stopReason: "end_turn",
content: [{ type: "text", text: "Final answer" }],
usage: { input: 10, output: 5, totalTokens: 15, cost: { total: 0.001 } },
},
});
assert.deepEqual(normalized, {
type: "assistant_text",
text: "Final answer",
model: "anthropic/claude-sonnet-4-5",
stopReason: "end_turn",
usage: { input: 10, output: 5, totalTokens: 15, cost: { total: 0.001 } },
});
});

33
src/wrapper/render.mjs Normal file
View File

@@ -0,0 +1,33 @@
function shortenCommand(command) {
return command.length > 100 ? `${command.slice(0, 100)}` : command;
}
export function renderHeader(meta) {
return [
"=== tmux 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");
}
export function renderEventLine(event) {
if (event.type === "tool_call") {
if (event.toolName === "bash") return `$ ${shortenCommand(event.args.command ?? "")}`;
return `${event.toolName} ${JSON.stringify(event.args)}`;
}
if (event.type === "tool_result") {
return event.isError ? `${event.toolName} failed` : `${event.toolName} done`;
}
if (event.type === "assistant_text") {
return event.text || "(no assistant text)";
}
return JSON.stringify(event);
}

View File

@@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";
import { renderHeader, renderEventLine } from "./render.mjs";
test("renderHeader prints the key wrapper 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, /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 a tmux pane", () => {
const line = renderEventLine({
type: "tool_call",
toolName: "bash",
args: { command: "rg -n authentication src" },
});
assert.equal(line, "$ rg -n authentication src");
});