From cf9312c8d71e93470766a30942e7b11809abab02 Mon Sep 17 00:00:00 2001 From: pi Date: Fri, 10 Apr 2026 23:57:53 +0100 Subject: [PATCH] feat: select subagent runner from config --- index.ts | 37 +++++++++++----- src/extension.test.ts | 100 +++++++++++++++++++++--------------------- src/runner.test.ts | 70 +++++++++++++++++++++++++++++ src/runner.ts | 12 +++++ 4 files changed, 158 insertions(+), 61 deletions(-) create mode 100644 src/runner.test.ts diff --git a/index.ts b/index.ts index 56b0a02..5f0f368 100644 --- a/index.ts +++ b/index.ts @@ -2,8 +2,13 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { createRunArtifacts } from "./src/artifacts.ts"; +import { loadSubagentsConfig } from "./src/config.ts"; import { monitorRun } from "./src/monitor.ts"; import { listAvailableModelReferences } from "./src/models.ts"; +import { createProcessSingleRunner } from "./src/process-runner.ts"; +import { createConfiguredRunSingleTask } from "./src/runner.ts"; +import { createSubagentParamsSchema } from "./src/schema.ts"; +import { createSubagentTool } from "./src/tool.ts"; import { createTmuxSingleRunner } from "./src/tmux-runner.ts"; import { buildCurrentWindowArgs, @@ -12,31 +17,27 @@ import { 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") { +export default function subagentsExtension(pi: ExtensionAPI) { + if (process.env.PI_SUBAGENTS_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") { + // In wrapper/child sessions spawned by subagent runners we must not register + // the subagent tool again. Provider overrides above are still allowed in child + // runs, so the guard stays after provider registration. + if (process.env.PI_SUBAGENTS_CHILD === "1") { return; } let lastRegisteredModelsKey: string | undefined; - const runSingleTask = createTmuxSingleRunner({ + const tmuxRunner = createTmuxSingleRunner({ assertInsideTmux() { if (!isInsideTmux()) throw new Error("tmux-backed subagents require pi to be running inside tmux."); }, @@ -58,6 +59,20 @@ export default function tmuxSubagentExtension(pi: ExtensionAPI) { }, }); + const processRunner = createProcessSingleRunner({ + createArtifacts: createRunArtifacts, + buildWrapperSpawn(metaPath: string) { + return { command: process.execPath, args: [wrapperPath, metaPath] }; + }, + monitorRun, + }); + + const runSingleTask = createConfiguredRunSingleTask({ + loadConfig: (cwd) => loadSubagentsConfig(cwd), + processRunner, + tmuxRunner, + }); + 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 diff --git a/src/extension.test.ts b/src/extension.test.ts index 54e74ff..4cb3c78 100644 --- a/src/extension.test.ts +++ b/src/extension.test.ts @@ -1,16 +1,16 @@ import test from "node:test"; import assert from "node:assert/strict"; -import tmuxSubagentExtension from "../index.ts"; +import subagentsExtension 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; + const original = process.env.PI_SUBAGENTS_CHILD; + if (original !== undefined) delete process.env.PI_SUBAGENTS_CHILD; try { const registeredTools: any[] = []; const handlers: Record Promise | void> = {}; - tmuxSubagentExtension({ + subagentsExtension({ on(event: string, handler: (event: any, ctx: any) => Promise | void) { handlers[event] = handler; }, @@ -46,20 +46,20 @@ test("the extension entrypoint registers the subagent tool with the currently av "openai/gpt-5", ]); } finally { - if (original === undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD; - else process.env.PI_TMUX_SUBAGENT_CHILD = original; + if (original === undefined) delete process.env.PI_SUBAGENTS_CHILD; + else process.env.PI_SUBAGENTS_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; + const original = process.env.PI_SUBAGENTS_CHILD; + if (original !== undefined) delete process.env.PI_SUBAGENTS_CHILD; try { const registeredTools: any[] = []; const handlers: Record Promise | void> = {}; - tmuxSubagentExtension({ + subagentsExtension({ on(event: string, handler: (event: any, ctx: any) => Promise | void) { handlers[event] = handler; }, @@ -107,20 +107,20 @@ test("before_agent_start re-applies subagent registration when available models 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; + if (original === undefined) delete process.env.PI_SUBAGENTS_CHILD; + else process.env.PI_SUBAGENTS_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"; + const original = process.env.PI_SUBAGENTS_CHILD; + process.env.PI_SUBAGENTS_CHILD = "1"; try { const registeredTools: any[] = []; const handlers: Record Promise | void> = {}; - tmuxSubagentExtension({ + subagentsExtension({ on(event: string, handler: (event: any, ctx: any) => Promise | void) { handlers[event] = handler; }, @@ -134,21 +134,21 @@ test("child subagent sessions skip registering the subagent tool", async () => { 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; + if (original === undefined) delete process.env.PI_SUBAGENTS_CHILD; + else process.env.PI_SUBAGENTS_CHILD = original; } }); -test("registers github-copilot provider override when PI_TMUX_SUBAGENT_GITHUB_COPILOT_INITIATOR is set", () => { +test("registers github-copilot provider override when PI_SUBAGENTS_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; + const originalInitiator = process.env.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR; + const originalChild = process.env.PI_SUBAGENTS_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"; + if (originalChild !== undefined) delete process.env.PI_SUBAGENTS_CHILD; + process.env.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR = "agent"; try { - tmuxSubagentExtension({ + subagentsExtension({ on() {}, registerTool() {}, registerProvider(name: string, config: any) { @@ -156,11 +156,11 @@ test("registers github-copilot provider override when PI_TMUX_SUBAGENT_GITHUB_CO }, } 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 (originalInitiator === undefined) delete process.env.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR; + else process.env.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR = originalInitiator; - if (originalChild === undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD; - else process.env.PI_TMUX_SUBAGENT_CHILD = originalChild; + if (originalChild === undefined) delete process.env.PI_SUBAGENTS_CHILD; + else process.env.PI_SUBAGENTS_CHILD = originalChild; } assert.deepEqual(registeredProviders, [ @@ -176,13 +176,13 @@ test("combined child+copilot run registers provider but no tools or startup hand const registeredTools: any[] = []; const handlers: Record Promise | 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"; + const originalInitiator = process.env.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR; + const originalChild = process.env.PI_SUBAGENTS_CHILD; + process.env.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR = "agent"; + process.env.PI_SUBAGENTS_CHILD = "1"; try { - tmuxSubagentExtension({ + subagentsExtension({ on(event: string, handler: (event: any, ctx: any) => Promise | void) { handlers[event] = handler; }, @@ -205,23 +205,23 @@ test("combined child+copilot run registers provider but no tools or startup hand 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 (originalInitiator === undefined) delete process.env.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR; + else process.env.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR = originalInitiator; - if (originalChild === undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD; - else process.env.PI_TMUX_SUBAGENT_CHILD = originalChild; + if (originalChild === undefined) delete process.env.PI_SUBAGENTS_CHILD; + else process.env.PI_SUBAGENTS_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; + const original = process.env.PI_SUBAGENTS_CHILD; + if (original !== undefined) delete process.env.PI_SUBAGENTS_CHILD; try { let registerToolCalls = 0; const handlers: Record Promise | void> = {}; - tmuxSubagentExtension({ + subagentsExtension({ on(event: string, handler: (event: any, ctx: any) => Promise | void) { handlers[event] = handler; }, @@ -278,8 +278,8 @@ test("does not re-register the subagent tool when models list unchanged, but re- assert.equal(registerToolCalls, 2); } finally { - if (original === undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD; - else process.env.PI_TMUX_SUBAGENT_CHILD = original; + if (original === undefined) delete process.env.PI_SUBAGENTS_CHILD; + else process.env.PI_SUBAGENTS_CHILD = original; } }); @@ -287,14 +287,14 @@ test("does not re-register the subagent tool when models list unchanged, but re- // 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; + const original = process.env.PI_SUBAGENTS_CHILD; + if (original !== undefined) delete process.env.PI_SUBAGENTS_CHILD; try { let registerToolCalls = 0; const handlers: Record Promise | void> = {}; - tmuxSubagentExtension({ + subagentsExtension({ on(event: string, handler: (event: any, ctx: any) => Promise | void) { handlers[event] = handler; }, @@ -337,21 +337,21 @@ test("same model set in different orders should NOT trigger re-registration", as assert.equal(registerToolCalls, 1); } finally { - if (original === undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD; - else process.env.PI_TMUX_SUBAGENT_CHILD = original; + if (original === undefined) delete process.env.PI_SUBAGENTS_CHILD; + else process.env.PI_SUBAGENTS_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; + const original = process.env.PI_SUBAGENTS_CHILD; + if (original !== undefined) delete process.env.PI_SUBAGENTS_CHILD; try { let registerToolCalls = 0; const handlers: Record Promise | void> = {}; - tmuxSubagentExtension({ + subagentsExtension({ on(event: string, handler: (event: any, ctx: any) => Promise | void) { handlers[event] = handler; }, @@ -390,8 +390,8 @@ test("empty model list should NOT register the tool, but a later non-empty list assert.equal(registerToolCalls, 1); } finally { - if (original === undefined) delete process.env.PI_TMUX_SUBAGENT_CHILD; - else process.env.PI_TMUX_SUBAGENT_CHILD = original; + if (original === undefined) delete process.env.PI_SUBAGENTS_CHILD; + else process.env.PI_SUBAGENTS_CHILD = original; } }); diff --git a/src/runner.test.ts b/src/runner.test.ts new file mode 100644 index 0000000..d23780f --- /dev/null +++ b/src/runner.test.ts @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createConfiguredRunSingleTask } from "./runner.ts"; + +function makeResult(finalText: string) { + return { + runId: "run-1", + agent: "scout", + agentSource: "builtin" as const, + task: "inspect auth", + exitCode: 0, + finalText, + }; +} + +test("createConfiguredRunSingleTask uses process runner when config says process", async () => { + const calls: string[] = []; + const runSingleTask = createConfiguredRunSingleTask({ + loadConfig: () => ({ runner: "process" }), + processRunner: async () => { + calls.push("process"); + return makeResult("process"); + }, + tmuxRunner: async () => { + calls.push("tmux"); + return makeResult("tmux"); + }, + }); + + const result = await runSingleTask({ cwd: "/repo", meta: { task: "inspect auth" } as any }); + + assert.equal(result.finalText, "process"); + assert.deepEqual(calls, ["process"]); +}); + +test("createConfiguredRunSingleTask uses tmux runner when config says tmux", async () => { + const calls: string[] = []; + const runSingleTask = createConfiguredRunSingleTask({ + loadConfig: () => ({ runner: "tmux" }), + processRunner: async () => { + calls.push("process"); + return makeResult("process"); + }, + tmuxRunner: async () => { + calls.push("tmux"); + return makeResult("tmux"); + }, + }); + + const result = await runSingleTask({ cwd: "/repo", meta: { task: "inspect auth" } as any }); + + assert.equal(result.finalText, "tmux"); + assert.deepEqual(calls, ["tmux"]); +}); + +test("createConfiguredRunSingleTask passes task cwd into config loader", async () => { + let cwdSeen = ""; + const runSingleTask = createConfiguredRunSingleTask({ + loadConfig: (cwd) => { + cwdSeen = cwd; + return { runner: "process" }; + }, + processRunner: async () => makeResult("process"), + tmuxRunner: async () => makeResult("tmux"), + }); + + await runSingleTask({ cwd: "/repo/worktree", meta: { task: "inspect auth" } as any }); + + assert.equal(cwdSeen, "/repo/worktree"); +}); diff --git a/src/runner.ts b/src/runner.ts index f33741f..d8e7e97 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -1,3 +1,4 @@ +import type { RunnerMode } from "./config.ts"; import type { SubagentRunResult } from "./schema.ts"; export interface RunSingleTaskInput { @@ -7,3 +8,14 @@ export interface RunSingleTaskInput { } export type RunSingleTask = (input: RunSingleTaskInput) => Promise; + +export function createConfiguredRunSingleTask(deps: { + loadConfig: (cwd: string) => { runner: RunnerMode }; + processRunner: RunSingleTask; + tmuxRunner: RunSingleTask; +}): RunSingleTask { + return (input) => { + const config = deps.loadConfig(input.cwd); + return (config.runner === "tmux" ? deps.tmuxRunner : deps.processRunner)(input); + }; +}