195 lines
7.5 KiB
TypeScript
195 lines
7.5 KiB
TypeScript
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,
|
|
buildKillPaneArgs,
|
|
buildSplitWindowArgs,
|
|
buildWrapperShellCommand,
|
|
isInsideTmux,
|
|
} from "./src/tmux.ts";
|
|
|
|
const packageRoot = dirname(fileURLToPath(import.meta.url));
|
|
const wrapperPath = join(packageRoot, "src", "wrapper", "cli.mjs");
|
|
|
|
export default function subagentsExtension(pi: ExtensionAPI) {
|
|
if (process.env.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR === "agent") {
|
|
pi.registerProvider("github-copilot", {
|
|
headers: { "X-Initiator": "agent" },
|
|
});
|
|
}
|
|
|
|
// 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 tmuxRunner = createTmuxSingleRunner({
|
|
assertInsideTmux() {
|
|
if (!isInsideTmux()) throw new Error('tmux runner requires pi to be running inside tmux.');
|
|
},
|
|
async getCurrentWindowId() {
|
|
const result = await pi.exec("tmux", buildCurrentWindowArgs());
|
|
return result.stdout.trim();
|
|
},
|
|
createArtifacts: createRunArtifacts,
|
|
buildWrapperCommand(metaPath: string) {
|
|
return buildWrapperShellCommand({ nodePath: process.execPath, wrapperPath, metaPath });
|
|
},
|
|
async createPane(input) {
|
|
const result = await pi.exec("tmux", buildSplitWindowArgs(input));
|
|
return result.stdout.trim();
|
|
},
|
|
monitorRun,
|
|
async killPane(paneId: string) {
|
|
await pi.exec("tmux", buildKillPaneArgs(paneId));
|
|
},
|
|
});
|
|
|
|
const processRunner = createProcessSingleRunner({
|
|
createArtifacts: createRunArtifacts,
|
|
buildWrapperSpawn(metaPath: string) {
|
|
return { command: process.execPath, args: [wrapperPath, metaPath] };
|
|
},
|
|
monitorRun,
|
|
});
|
|
|
|
// background registry and helpers
|
|
const registry = (typeof createBackgroundRegistry === 'function') ? createBackgroundRegistry() : undefined;
|
|
let latestUi: { notify(message: string, type?: "info" | "warning" | "error"): void; setStatus(key: string, text: string | undefined): void } | undefined;
|
|
|
|
function renderCounts() {
|
|
if (!registry) return undefined;
|
|
const counts = registry.getCounts();
|
|
return counts.total === 0 ? undefined : `bg: ${counts.running} running / ${counts.total} total`;
|
|
}
|
|
|
|
function updateStatus() {
|
|
latestUi?.setStatus("pi-subagents", renderCounts());
|
|
}
|
|
|
|
async function watchBackgroundRun(runId: string) {
|
|
if (!registry) return;
|
|
const run = registry.getRun(runId);
|
|
if (!run || !run.paths || !run.paths.eventsPath || !run.paths.resultPath) return;
|
|
try {
|
|
const result = await monitorRun({ eventsPath: run.paths.eventsPath, resultPath: run.paths.resultPath });
|
|
const status = result.stopReason === "aborted" ? "aborted" : result.exitCode === 0 ? "completed" : "failed";
|
|
registry.recordUpdate(runId, { status, exitCode: result.exitCode, finalText: result.finalText, stopReason: result.stopReason });
|
|
try {
|
|
pi.appendEntry("pi-subagents:bg-update", { runId, status, finalText: result.finalText, exitCode: result.exitCode });
|
|
} catch (e) {}
|
|
updateStatus();
|
|
latestUi?.notify(`Background agent ${run.preset} finished: ${result.finalText || status}`, status === "completed" ? "info" : "error");
|
|
try {
|
|
pi.sendMessage({
|
|
customType: "pi-subagents:bg-complete",
|
|
content: `Background agent ${run.preset} completed (${runId}): ${result.finalText || status}`,
|
|
display: true,
|
|
details: { runId, status },
|
|
}, { triggerTurn: false });
|
|
} catch (e) {}
|
|
} catch (e) {
|
|
// monitorRun errors are non-fatal here
|
|
}
|
|
}
|
|
|
|
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
|
|
// non-empty list will still trigger registration.
|
|
if (!availableModels || availableModels.length === 0) {
|
|
const emptyKey = "\u0000";
|
|
if (lastRegisteredModelsKey === emptyKey) return;
|
|
lastRegisteredModelsKey = emptyKey;
|
|
return;
|
|
}
|
|
|
|
// Create a deduplication key that is independent of the order of
|
|
// availableModels by sorting a lowercase copy. Do not mutate
|
|
// availableModels itself since we want to preserve the original order for
|
|
// schema enum values.
|
|
const key = [...availableModels].map((s) => s.toLowerCase()).sort().join("\u0000");
|
|
if (key === lastRegisteredModelsKey) return;
|
|
|
|
lastRegisteredModelsKey = key;
|
|
pi.registerTool(
|
|
createSubagentTool({
|
|
parameters: createSubagentParamsSchema(availableModels),
|
|
runSingleTask,
|
|
}),
|
|
);
|
|
|
|
// also register background tools when models available
|
|
try {
|
|
pi.registerTool(createBackgroundStatusTool({ registry }));
|
|
pi.registerTool(createBackgroundAgentTool({
|
|
parameters: createBackgroundAgentSchema(availableModels),
|
|
discoverSubagentPresets,
|
|
launchDetachedTask: processRunner.launchDetachedTask,
|
|
registerBackgroundRun(entry: any) {
|
|
if (!registry) return { running: 0, completed: 0, failed: 0, aborted: 0, total: 0 };
|
|
registry.recordLaunch({ runId: entry.runId, preset: entry.preset, task: entry.task, requestedModel: entry.requestedModel, resolvedModel: entry.resolvedModel, paths: entry.paths, meta: entry.meta });
|
|
return registry.getCounts();
|
|
},
|
|
watchBackgroundRun(runId: string) {
|
|
void watchBackgroundRun(runId);
|
|
},
|
|
}));
|
|
} catch (e) {}
|
|
};
|
|
|
|
const syncSubagentTool = (ctx: { modelRegistry: { getAvailable(): Array<{ provider: string; id: string }> } }) => {
|
|
registerSubagentTool(listAvailableModelReferences(ctx.modelRegistry));
|
|
};
|
|
|
|
pi.on("session_start", (_event, ctx) => {
|
|
latestUi = ctx.ui;
|
|
// replay persisted runs if session manager provides entries
|
|
try {
|
|
const entries = ctx.sessionManager?.getEntries?.() ?? [];
|
|
const bgEntries = entries.filter((e: any) => e.type === "pi-subagents:bg-run" || e.type === "pi-subagents:bg-update");
|
|
// convert to registry runs if possible
|
|
const runs = bgEntries.map((be: any) => be.data?.run).filter(Boolean);
|
|
if (registry && runs.length) registry.replay(runs);
|
|
} catch (e) {}
|
|
|
|
updateStatus();
|
|
|
|
// reattach watchers for running runs
|
|
try {
|
|
if (registry) {
|
|
for (const r of registry.getSnapshot({ includeCompleted: true })) {
|
|
if (r.status === "running") void watchBackgroundRun(r.runId);
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
|
|
syncSubagentTool(ctx);
|
|
});
|
|
|
|
pi.on("before_agent_start", (_event, ctx) => {
|
|
latestUi = ctx.ui;
|
|
syncSubagentTool(ctx);
|
|
});
|
|
}
|