Files
pi-subagents/src/wrapper/cli.mjs
2026-04-10 23:12:17 +01:00

215 lines
6.1 KiB
JavaScript

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;
});