Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions extensions/pi-subagents/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ node_modules/
package-lock.json

.spec/
bun.lock
1 change: 1 addition & 0 deletions extensions/pi-subagents/skills/pi-subagents/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Use this tool to launch unrestricted child Pi sessions. The caller must include
- Timeout is only a notification threshold: the parent is informed that the child is still running; the child is not killed.
- The returned subagent id is also the child Pi session id.
- Child output is written to `result.log` under the child subagent directory.
- When `model` is omitted, the child inherits the parent session's active model (e.g., `openai-codex/gpt-5.6-sol`). Pass an explicit `model` to override (e.g., `"anthropic/claude-sonnet-4-5"`).

## Usage

Expand Down
50 changes: 47 additions & 3 deletions extensions/pi-subagents/src/extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1188,7 +1188,38 @@ function startChild(
throw new Error("Subagent process lifeline pipe was not created.");
}
lifelines.set(record.id, lifeline);
child.stdout.pipe(stdoutStream);

// Monitor stdout for terminal provider errors (e.g. HTTP 402).
// When stopReason==="error" is emitted, the child Pi process may
// stay alive indefinitely. Kill it so the close handler finalizes
// the record promptly.
let terminalErrorText: string | undefined;
let errorTimer: NodeJS.Timeout | undefined;
child.stdout.on("data", (chunk: Buffer) => {
// Write to file (replaces pipe).
stdoutStream.write(chunk);
if (terminalErrorText) return;
const text = chunk.toString("utf-8");
if (/"stopReason"\s*:\s*"error"/.test(text)) {
const errMatch = text.match(/"errorMessage"\s*:\s*"([^"]+)"/);
terminalErrorText = errMatch?.[1] ?? "Provider error";
if (!record.error) {
record.error = terminalErrorText;
}
errorTimer = setTimeout(() => {
errorTimer = undefined;
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGTERM");
setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}, 1000).unref();
}
}, 1000);
errorTimer.unref();
}
});
child.stderr.pipe(stderrStream);

const done = new Promise<PersistedSubagentRecord>((resolve) => {
Expand All @@ -1201,6 +1232,12 @@ function startChild(
if (finalized) return;
finalized = true;

// Clear error-detection timer if still pending.
if (errorTimer) {
clearTimeout(errorTimer);
errorTimer = undefined;
}

await Promise.all([
new Promise<void>((r) => stdoutStream.end(r)),
new Promise<void>((r) => stderrStream.end(r)),
Expand All @@ -1227,7 +1264,10 @@ function startChild(
record.completedAt = Date.now();
record.updatedAt = Date.now();

if (code !== 0 && !record.error)
// Only assign a code-based error for actual non-zero exit
// codes (not null signals) and when we don't already have
// a provider error from stdout monitoring.
if (code !== null && code !== 0 && !record.error)
record.error =
stderr.trim() ||
`Subagent exited with code ${code}${signal ? ` (${signal})` : ""}`;
Expand All @@ -1241,7 +1281,11 @@ function startChild(
if (!hasExistingResult) {
// Subagent did not write to result file — auto-save final output
const finalOutput = extractFinalOutput(stdout);
if (finalOutput && record.outputFile) {
// When we have a provider error captured from stdout
// monitoring, use the (error) fallback.
if (record.error && record.outputFile) {
fs.writeFileSync(record.outputFile, "(error)\n", { mode: 0o600 });
} else if (finalOutput && record.outputFile) {
fs.writeFileSync(record.outputFile, `${finalOutput}\n`, {
mode: 0o600,
});
Expand Down
4 changes: 4 additions & 0 deletions extensions/pi-subagents/src/runs/shared/pi-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {

if (input.model) args.push("--model", input.model);

// Use JSON mode so child output is machine-parseable for error
// detection and result extraction.
args.push("--mode", "json");

// Add only the minimal runtime extension. Do not disable normal extensions,
// skills, tools, MCP direct tools, or project context.
args.push("--extension", PROMPT_RUNTIME_EXTENSION_PATH);
Expand Down
15 changes: 12 additions & 3 deletions extensions/pi-subagents/test/support/mock-pi-script.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function claimNextResponse(dir) {
return JSON.parse(fs.readFileSync(defaultPath, "utf-8"));
}

function defaultAssistantMessage(output) {
function defaultAssistantMessage(output, overrides = {}) {
return {
type: "message_end",
message: {
Expand All @@ -54,6 +54,7 @@ function defaultAssistantMessage(output) {
cacheWrite: 0,
cost: { total: 0.001 },
},
...overrides,
},
};
}
Expand Down Expand Up @@ -191,6 +192,14 @@ async function main() {
await new Promise((resolve) => setTimeout(resolve, response.delay));
}

const assistantOverrides = {};
if (typeof response.stopReason === "string") {
assistantOverrides.stopReason = response.stopReason;
}
if (typeof response.errorMessage === "string") {
assistantOverrides.errorMessage = response.errorMessage;
}

if (Array.isArray(response.steps) && response.steps.length > 0) {
for (const step of response.steps) {
if (typeof step?.delay === "number" && step.delay > 0) {
Expand All @@ -210,10 +219,10 @@ async function main() {
response.echoEnv.map((key) => [key, process.env[key] ?? null]),
);
if (jsonMode)
writeJsonlLine(defaultAssistantMessage(JSON.stringify(envSnapshot)));
writeJsonlLine(defaultAssistantMessage(JSON.stringify(envSnapshot), assistantOverrides));
else process.stdout.write(`${JSON.stringify(envSnapshot)}\n`);
} else if (typeof response.output === "string") {
if (jsonMode) writeJsonlLine(defaultAssistantMessage(response.output));
if (jsonMode) writeJsonlLine(defaultAssistantMessage(response.output, assistantOverrides));
else process.stdout.write(`${response.output}\n`);
}

Expand Down
Loading
Loading