initial commit
This commit is contained in:
54
src/agents.test.ts
Normal file
54
src/agents.test.ts
Normal 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
91
src/agents.ts
Normal 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
21
src/artifacts.test.ts
Normal 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
66
src/artifacts.ts
Normal 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
43
src/builtin-agents.ts
Normal 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
397
src/extension.test.ts
Normal 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
44
src/models.test.ts
Normal 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
58
src/models.ts
Normal 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
33
src/monitor.test.ts
Normal 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
34
src/monitor.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
33
src/package-manifest.test.ts
Normal file
33
src/package-manifest.test.ts
Normal 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
20
src/prompts.test.ts
Normal 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
33
src/runner.test.ts
Normal 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
44
src/runner.ts
Normal 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
84
src/schema.ts
Normal 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
43
src/tmux.test.ts
Normal 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
41
src/tmux.ts
Normal 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
107
src/tool-chain.test.ts
Normal 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
97
src/tool-parallel.test.ts
Normal 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
177
src/tool.test.ts
Normal 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
336
src/tool.ts
Normal 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
214
src/wrapper/cli.mjs
Normal 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
192
src/wrapper/cli.test.ts
Normal 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
35
src/wrapper/normalize.mjs
Normal 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;
|
||||
}
|
||||
38
src/wrapper/normalize.test.ts
Normal file
38
src/wrapper/normalize.test.ts
Normal 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
33
src/wrapper/render.mjs
Normal 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);
|
||||
}
|
||||
28
src/wrapper/render.test.ts
Normal file
28
src/wrapper/render.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user