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
32 changes: 31 additions & 1 deletion extensions/pi-subagents/src/extension/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "../shared/types.ts";
import { getPiSpawnCommand } from "../runs/shared/pi-spawn.ts";
import { buildPiArgs, cleanupTempDir } from "../runs/shared/pi-args.ts";
import { PI_SUBAGENT_LIFELINE_FD } from "../runs/shared/subagent-prompt-runtime.ts";
import {
SpawnSubagentParams,
type SpawnSubagentParamsLike,
Expand Down Expand Up @@ -91,6 +92,24 @@ interface StartChildHooks {

const DEFAULT_TIMEOUT_SECONDS = 600;
const runningChildren = new Map<string, ChildProcess>();

// Lifeline pipes keyed by record.id. Each entry holds the parent's write end
// (child.stdin) of the anonymous lifeline pipe. The parent never writes to it;
// holding it open keeps the child's read end alive. When the parent process
// dies the kernel closes all FDs, the child sees EOF, and self-terminates.
function closeLifeline(recordId: string): void {
const stream = lifelines.get(recordId);
if (!stream) return;
lifelines.delete(recordId);
try { stream.end(); } catch { /* best-effort */ }
try { stream.destroy(); } catch { /* best-effort */ }
}

function destroyAllLifelines(): void {
for (const id of [...lifelines.keys()]) closeLifeline(id);
}

const lifelines = new Map<string, NodeJS.WritableStream>();
const activeCohorts = new Map<
string,
{ id: string; createdAt: number; turnIndex?: number }
Expand Down Expand Up @@ -1123,9 +1142,13 @@ function startChild(
...built.env,
...getSubagentDepthEnv(resolveCurrentMaxSubagentDepth()),
};
// Use stdin (fd 0) as the anonymous lifeline pipe.
// The parent holds child.stdin open without writing; the child
// runtime watches fd 0 for EOF to detect parent death.
env[PI_SUBAGENT_LIFELINE_FD] = "0";
child = spawn(spawnSpec.command, spawnSpec.args, {
cwd: record.cwd,
stdio: ["ignore", "pipe", "pipe"],
stdio: ["pipe", "pipe", "pipe"],
env,
});
} catch (error) {
Expand Down Expand Up @@ -1154,6 +1177,8 @@ function startChild(
if (!child.stdout || !child.stderr) {
throw new Error("Subagent process stdio pipes were not created.");
}
// Store the lifeline write end (child.stdin) before piping stdout/stderr.
if (child.stdin) lifelines.set(record.id, child.stdin);
child.stdout.pipe(stdoutStream);
child.stderr.pipe(stderrStream);

Expand All @@ -1173,6 +1198,8 @@ function startChild(
]);

runningChildren.delete(record.id);
// Release the lifeline on normal child completion.
closeLifeline(record.id);
cleanupTempDir(built.tempDir);

const stdout = fs.existsSync(record.stdoutFile)
Expand Down Expand Up @@ -1356,6 +1383,9 @@ export default function registerSubagentExtension(pi: ExtensionAPI): void {
});

pi.on("session_shutdown", (_event, _ctx) => {
// Close all lifelines: the owning session is being torn down.
// This terminates all running children for this parent session.
destroyAllLifelines();
// Clear all widget refresh timers to prevent stale UI state across extension instances.
stopAllWidgetRefreshForInstance();
activeCohorts.clear();
Expand Down
38 changes: 38 additions & 0 deletions extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import * as fs from "node:fs";

export const SUBAGENT_INTERCOM_SESSION_NAME_ENV =
"PI_SUBAGENT_INTERCOM_SESSION_NAME";
export const SUBAGENT_RESULT_PATH_ENV = "PI_SUBAGENT_RESULT_PATH";
export const CHILD_SUBAGENT_SYSTEM_LINE =
"You are a Pi subagent controlled by another Pi agent.";

/** Env var set by the parent when spawning a managed subagent.
* Value is the child-side file descriptor number of the anonymous lifeline pipe.
* The child watches this fd for EOF; when the parent process dies the kernel
* closes the write end and the child detects the EOF to self-terminate. */
export const PI_SUBAGENT_LIFELINE_FD = "PI_SUBAGENT_LIFELINE_FD";

const RESULT_PATH_MARKER = "Your result file:";
const RESULT_PATH_ALIASES = new Set([
"$PI_SUBAGENT_RESULT_PATH",
Expand All @@ -18,6 +25,37 @@ export function rewriteSubagentPrompt(prompt: string): string {
return `${CHILD_SUBAGENT_SYSTEM_LINE}\n\n${prompt}`;
}

// ── Lifeline watcher: self-terminate when parent process dies ──

function setupLifelineWatcher(): void {
const lifelineFdRaw = process.env[PI_SUBAGENT_LIFELINE_FD];
if (lifelineFdRaw === undefined) return;

const fd = parseInt(lifelineFdRaw, 10);
if (!Number.isFinite(fd) || fd < 0) return;

// Create a read stream on the lifeline fd. When the parent process
// dies the kernel closes the write end of the pipe; the child sees EOF
// and the stream emits 'end' → self-terminate via SIGTERM.
// We use a dedicated ReadStream (not process.stdin) so we never
// conflict with whatever Pi may do with its own stdin handling.
// We use fd option to read directly from the lifeline fd; the path
// argument is not used when fd is supplied but required by the type.
const lifeline = fs.createReadStream("", { fd, autoClose: false });
lifeline.on("end", () => {
process.kill(process.pid, "SIGTERM");
});
lifeline.on("error", () => {
// If the fd is already closed or invalid, treat as parent death.
process.kill(process.pid, "SIGTERM");
});
// Start flowing so libuv polls the fd for readability/EOF.
lifeline.resume();
}

// Run at module load time so the watcher is active before any Pi handlers fire.
setupLifelineWatcher();

export default function registerSubagentPromptRuntime(pi: ExtensionAPI): void {
pi.on("tool_call", (event) => {
if (!FILE_TOOL_NAMES.has(event.toolName)) return;
Expand Down
Loading
Loading