/**
* pi-babysit: run ANY shell command under babysit — one context-safe supervision
* substrate for quick commands, background processes, AND pi subagents. Retires both `pi-processes`
* (the `process` tool) and `pi-subagent`.
*
* Every session is a babysit-supervised PTY (state in $PI_BABYSIT_DIR,
* default ~/.pi-babysit, namespaced per pi session). Two KINDS of session:
*
* kind=process `babysit_run { command }` — builds, tests, dev servers,
* watchers, interactive TUIs. Completion = process exit.
* On exit a notification message is injected (triggerTurn),
* so the agent can END ITS TURN after starting and be resumed
* automatically — same contract as the old `process` tool.
* Being a PTY, the agent can also TYPE into it (babysit_send
* text/keys) and read the rendered screen (babysit_check
* { screen: true }) — full interactive-program driving.
*
* kind=subagent `babysit_run { profile: "subagent", task }` — a long-lived
* `pi --mode rpc` worker. Tasks are injected as RPC `prompt`
* commands over stdin, completion is detected from the JSONL
* event stream (`agent_settled`), NOT process exit; the session
* remains reusable during its configured idle grace. Same design as the
* old pi-subagent extension.
*
* The "profile" is a tool-parameter, not a separate tool set: one small tool
* surface (babysit_run/check/send/wait/kill) covers both, and domain knowledge
* (RPC bookkeeping, byte offsets, parked-turn detection) stays in code.
*
* Tools (LLM): babysit_run, babysit_check, babysit_send, babysit_wait, babysit_kill
* Commands: /babysit (arrow-key picker: attach/tail/inspect)
* Widget: live counts (processes running · subagents working · idle)
*/
import { spawn } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import type {
ExtensionAPI,
ExtensionContext,
Theme,
ToolDefinition,
} from "@earendil-works/pi-coding-agent";
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
import { Box, Markdown, Text } from "@earendil-works/pi-tui";
import { Type, type TSchema } from "typebox";
import { StringEnum } from "@earendil-works/pi-ai";
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents";
// Dedicated babysit state root so pi-managed sessions never collide with the
// user's own manual `babysit` sessions. The base is namespaced per pi session
// on session_start (BABYSIT_DIR=/), so each pi session only
// sees its own sessions in list/widget/kill.
const ROOT_BASE = process.env.PI_BABYSIT_DIR ?? path.join(os.homedir(), ".pi-babysit");
let ROOT = ROOT_BASE;
const PI_BIN = process.env.PI_BABYSIT_BIN ?? "pi";
const BABYSIT_BIN = process.env.PI_BABYSIT_CLI ?? "babysit";
const SHELL = process.env.SHELL ?? "sh";
const SUBAGENT_DEPTH_ENV = "PI_BABYSIT_INTERNAL_SUBAGENT_DEPTH";
const SUBAGENT_MAX_DEPTH_ENV = "PI_BABYSIT_INTERNAL_SUBAGENT_MAX_DEPTH";
const DEFAULT_SUBAGENT_MAX_DEPTH = 1;
export type SubagentSpawnPlan =
| { allowed: true; childDepth: number; maxDepth: number }
| { allowed: false; error: string };
/**
* Plan a subagent spawn without letting an already-spawned worker raise its
* inherited recursion allowance. Depth 0 is the user-facing pi process; the
* first worker is depth 1 and is allowed by default, but that worker cannot
* create depth 2 unless its top-level parent explicitly opted in.
*/
export function planSubagentSpawn(
requestedMaxDepth?: number,
env: Record = process.env,
): SubagentSpawnPlan {
if (
requestedMaxDepth !== undefined &&
(!Number.isInteger(requestedMaxDepth) || requestedMaxDepth < 1)
) {
return { allowed: false, error: "`maxDepth` must be a positive integer." };
}
const rawDepth = env[SUBAGENT_DEPTH_ENV];
let currentDepth = 0;
if (rawDepth !== undefined) {
currentDepth = Number(rawDepth);
if (!Number.isInteger(currentDepth) || currentDepth < 0) {
return {
allowed: false,
error: `Invalid inherited subagent depth ${JSON.stringify(rawDepth)}; refusing to spawn recursively.`,
};
}
}
const nested = currentDepth > 0;
if (nested && requestedMaxDepth !== undefined) {
return {
allowed: false,
error:
`Nested subagents cannot override \`maxDepth\` (current depth ${currentDepth}). ` +
"Only the top-level parent may opt in when it creates the first subagent.",
};
}
let maxDepth = requestedMaxDepth ?? DEFAULT_SUBAGENT_MAX_DEPTH;
if (nested) {
const rawMaxDepth = env[SUBAGENT_MAX_DEPTH_ENV];
// Missing/corrupt inherited state fails closed at the current depth.
maxDepth = rawMaxDepth === undefined ? currentDepth : Number(rawMaxDepth);
if (!Number.isInteger(maxDepth) || maxDepth < currentDepth) {
return {
allowed: false,
error: `Invalid inherited max subagent depth ${JSON.stringify(rawMaxDepth)}; refusing to spawn recursively.`,
};
}
}
const childDepth = currentDepth + 1;
if (childDepth > maxDepth) {
return {
allowed: false,
error:
`Nested subagent creation is disabled at depth ${currentDepth}: spawning would reach depth ${childDepth}, ` +
`but the inherited maxDepth is ${maxDepth}. Have the top-level parent explicitly opt in with ` +
`babysit_run { profile: "subagent", task, maxDepth: ${childDepth} } when creating the first worker.`,
};
}
return { allowed: true, childDepth, maxDepth };
}
// Marker embedded in babysit_run's tool RESULT text for kind=process runs.
// It is how "the turn parked awaiting a process-exit notification" is told
// apart from any other turn end (see isParkedMessages / self-reap.ts).
export const NOTIFY_MARKER = "[notify-on-exit]";
// Human-readable view for the compact subagent JSONL stream when a human
// attaches. The RPC proxy removes cumulative streaming snapshots before both
// recording and display; authoritative events remain intact for parsers. Set
// PI_BABYSIT_VIEW_CMD="" to disable formatting, or provide a custom command.
const EXT_DIR = path.dirname(fileURLToPath(import.meta.url));
const shq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
const VIEW_CMD =
process.env.PI_BABYSIT_VIEW_CMD ??
`${shq(process.execPath)} ${shq(path.join(EXT_DIR, "format-stream.mjs"))}`;
// Appended to every subagent's system prompt. The subagent is a long-lived
// headless `pi --mode rpc` worker: turns can end and resume, so babysit_run
// (process kind) works normally inside it. It just cannot talk to a human.
export function subagentGuidance(
depth: number,
maxDepth: number,
directBashAvailable: boolean,
babysitRunAvailable = true,
): string {
const shellGuidance = directBashAvailable
? "Direct bash is available, and babysit_run can supervise longer commands."
: babysitRunAvailable
? "Direct bash is unavailable; run shell commands with babysit_run { command }."
: "No shell execution tool is available in this task's tool allowlist; do not attempt shell commands.";
const nestingGuidance = depth >= maxDepth
? `You are at the inherited subagent depth limit (${depth}/${maxDepth}); do not attempt to spawn another subagent.`
: `Your inherited subagent depth is ${depth}/${maxDepth}; child subagents may not exceed maxDepth ${maxDepth}.`;
return [
"You are a headless background worker driven over pi's RPC protocol.",
"Work autonomously: you cannot ask the user questions, so state assumptions",
"in your final answer instead.",
shellGuidance,
nestingGuidance,
"When your task is complete, produce a final answer message summarizing the outcome —",
"your controller reads it from the event stream.",
].join(" ");
}
const POLL_MS = 2500;
const QUICK_COMMAND_GRACE = process.env.PI_BABYSIT_QUICK_GRACE ?? "2s";
const KILL_CONFIRM_TIMEOUT = "4s";
interface BsSession {
id: string;
state: string; // "running" | "exited" | "dead" ...
alive?: boolean; // whether the PTY worker process is still live
exit_code?: number | null;
note?: string | null;
output_bytes?: number;
screen_seq?: number | null;
}
// A worker whose PTY process is gone (alive:false) can still report state
// "running" if it crashed BEFORE recording its exit transition (e.g. the child
// died in the first few ms). Treat that as finished so it never shows or counts
// as running. This normalizes at the source so every downstream
// `state === "running"` check is correct.
function normalizeSession(s: BsSession): BsSession {
if (s.alive === false && s.state === "running") {
return { ...s, state: s.exit_code != null ? "exited" : "dead" };
}
return s;
}
// ---------------------------------------------------------------------------
// babysit CLI helpers
// ---------------------------------------------------------------------------
// Async, NON-BLOCKING spawn. Using spawnSync here would block Node's event
// loop (and thus freeze the whole TUI) for the entire duration of the child —
// which for `babysit wait` can be minutes or forever. A streamed async spawn
// keeps the UI responsive while the child runs. An optional AbortSignal lets a
// long wait be interrupted (Ctrl-C) by killing the child.
function bs(
args: string[],
opts: {
cwd?: string;
signal?: AbortSignal;
env?: Record;
} = {},
): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve) => {
if (opts.signal?.aborted) {
resolve({ stdout: "", stderr: "aborted", code: 130 });
return;
}
const env: NodeJS.ProcessEnv = { ...process.env };
for (const [name, value] of Object.entries(opts.env ?? {})) {
if (value === undefined) delete env[name];
else env[name] = value;
}
env.BABYSIT_DIR = ROOT;
const child = spawn(BABYSIT_BIN, args, {
cwd: opts.cwd,
env,
});
let stdout = "";
let stderr = "";
const onAbort = () => child.kill("SIGTERM");
opts.signal?.addEventListener("abort", onAbort, { once: true });
child.stdout?.on("data", (d) => {
stdout += d.toString();
});
child.stderr?.on("data", (d) => {
stderr += d.toString();
});
child.on("error", (e) => {
opts.signal?.removeEventListener("abort", onAbort);
const installHint = babysitSpawnInstallHint(e);
if (installHint) {
babysitPreflightError = installHint;
babysitPreflightCheckedAt = Date.now();
}
resolve({ stdout, stderr: installHint ?? stderr + String(e), code: 1 });
});
child.on("close", (code) => {
opts.signal?.removeEventListener("abort", onAbort);
resolve({ stdout, stderr, code: code ?? 1 });
});
});
}
// ---------------------------------------------------------------------------
// preflight: the `babysit` binary must be on PATH
// ---------------------------------------------------------------------------
// Every session shells out to `babysit`; without it the extension can do
// nothing. We don't auto-install (that's the user's job) — we fail loudly with
// install instructions the moment a tool or command is used.
const INSTALL_STEPS =
`Install babysit 0.13.0 or newer, then retry:\n` +
` cargo install --git https://github.com/yusukeshib/babysit\n` +
`or download a prebuilt binary from https://github.com/yusukeshib/babysit/releases and put it on your PATH.\n` +
`(Override the binary path with $PI_BABYSIT_CLI.)`;
const INSTALL_HINT =
`The \`babysit\` binary was not found (tried "${BABYSIT_BIN}").\n` + INSTALL_STEPS;
const MIN_BABYSIT_VERSION = [0, 13, 0] as const;
export function babysitSpawnInstallHint(error: unknown): string | null {
return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT" ? INSTALL_HINT : null;
}
export function isSupportedBabysitVersion(output: string): boolean {
const match = /\b(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?\b/.exec(output);
if (!match) return false;
const actual = [Number(match[1]), Number(match[2]), Number(match[3])] as const;
for (let i = 0; i < MIN_BABYSIT_VERSION.length; i++) {
if (actual[i] !== MIN_BABYSIT_VERSION[i]) return actual[i] > MIN_BABYSIT_VERSION[i];
}
return match[4] === undefined;
}
// Cached preflight. A supported binary stays cached until a later spawn reports
// ENOENT; failures are retried after a short delay so installation can recover.
// undefined = not probed, null = supported, string = actionable error.
let babysitPreflightError: string | null | undefined;
let babysitPreflightCheckedAt = 0;
async function babysitAvailable(): Promise {
if (babysitPreflightError === null) return true;
// Briefly negative-cache failures so repeated mistaken calls do not fork a
// version probe each time, while still recovering quickly after installation.
if (babysitPreflightError && Date.now() - babysitPreflightCheckedAt < 2_000) return false;
const r = await bs(["--version"]);
babysitPreflightCheckedAt = Date.now();
if (r.code !== 0) {
babysitPreflightError = INSTALL_HINT;
} else if (!isSupportedBabysitVersion(r.stdout)) {
babysitPreflightError =
`pi-babysit requires babysit 0.13.0 or newer; found ${r.stdout.trim() || "an unknown version"}.\n` +
INSTALL_STEPS;
} else {
babysitPreflightError = null;
}
return babysitPreflightError === null;
}
// Throwing form for tool `execute` handlers: a thrown error marks the tool
// result isError and reports the preflight error to the model.
async function requireBabysit(): Promise {
if (!(await babysitAvailable())) throw new Error(babysitPreflightError ?? INSTALL_HINT);
}
// Error-aware: `babysit list` failing is NOT the same as "no sessions" —
// callers that show state to the agent must surface the error instead of
// silently reporting an empty registry (which reads like lost sessions).
async function listSessions(): Promise<{ sessions: BsSession[]; error?: string }> {
const r = await bs(["list", "--json"]);
if (r.code !== 0) {
return {
sessions: [],
error: r.stderr || r.stdout || `babysit list failed (exit ${r.code}, no output)`,
};
}
try {
const parsed = JSON.parse(r.stdout);
const raw: BsSession[] = Array.isArray(parsed) ? parsed : (parsed.sessions ?? []);
return { sessions: raw.map(normalizeSession) };
} catch {
return { sessions: [], error: `could not parse babysit list output: ${r.stdout.slice(0, 200)}` };
}
}
async function lookupStatus(
id: string,
): Promise<{ session: BsSession | null; error?: string }> {
// `list --json` already carries every lifecycle field used by the extension,
// including `alive` and `note`. Using it directly avoids the old
// status-then-list pair (two CLI subprocesses for every status lookup).
try {
const listed = await listSessions();
if (listed.error) return { session: null, error: listed.error };
return { session: listed.sessions.find((session) => session.id === id) ?? null };
} catch (error) {
return { session: null, error: error instanceof Error ? error.message : String(error) };
}
}
async function statusOf(id: string): Promise {
return (await lookupStatus(id)).session;
}
export function isConfirmedTerminalState(state: string): boolean {
return state === "killed" || state === "exited";
}
export function validateKillResponse(stdout: string): string | null {
try {
const response = JSON.parse(stdout);
if (response.killed !== true || response.confirmed === false) {
return `Kill was not confirmed by babysit: ${stdout.trim()}`;
}
return null;
} catch {
return `Invalid kill response from babysit: ${stdout.trim() || "(empty)"}`;
}
}
export function resolveKillConfirmation(
id: string,
backendError: string | null,
state: string | undefined,
): { confirmed: true; warning?: string } | { confirmed: false; error: string } {
if (state && isConfirmedTerminalState(state)) {
return backendError ? { confirmed: true, warning: backendError } : { confirmed: true };
}
if (backendError) return { confirmed: false, error: backendError };
if (!state) return { confirmed: false, error: `Kill could not be verified: session ${id} disappeared.` };
return {
confirmed: false,
error: `Kill was acknowledged but ${id} is still ${state}; completion notifications were restored.`,
};
}
async function awaitConfirmedTermination(id: string): Promise {
const initial = await statusOf(id);
if (!initial || isConfirmedTerminalState(initial.state) || initial.state === "dead") {
return initial;
}
// New babysit versions return only after persistence, so this is normally
// skipped. It is a bounded compatibility guard for older binaries that
// acknowledged signal delivery before the process actually exited.
await bs(["wait", "-s", id, "--timeout", KILL_CONFIRM_TIMEOUT]);
return statusOf(id);
}
export function shouldTerminateSessionsOnShutdown(reason: string | undefined): boolean {
return reason === "quit";
}
async function terminateRunningSessions(): Promise {
let listed: Awaited>;
try {
listed = await listSessions();
} catch {
return;
}
if (listed.error) return;
// A namespace may contain many independent commands and agents. Terminate
// them concurrently so quitting Pi does not wait for serial kill timeouts.
// allSettled ensures one unreachable worker cannot skip cleanup for others.
await Promise.allSettled(
listed.sessions
.filter((session) => session.state === "running")
.map(async (session) => {
const result = await bs(["kill", "-s", session.id, "--json"]);
if (result.code !== 0 || validateKillResponse(result.stdout)) return;
// A later resume of this Pi session must not turn intentional shutdown
// cleanup into a process-completion notification.
suppressNotify(session.id, "kill");
}),
);
}
// ---------------------------------------------------------------------------
// per-session metadata
// ---------------------------------------------------------------------------
// kind=process: name/command + `notified` (exit notification dedup).
// kind=subagent: task + the raw-log byte offset of the last prompt, which lets
// check/wait analyze only the CURRENT task's events (important for follow-ups).
export interface SubagentBudget {
maxCost?: number;
maxTurns?: number;
maxToolCalls?: number;
maxUsageTokens?: number;
}
interface Meta {
kind: "process" | "subagent";
// process
name?: string;
command?: string;
notificationGroup?: string;
notified?: boolean;
// A confirmed kill permanently owns completion delivery. An interrupted
// concurrent wait must not re-enable the automatic notification afterward.
killNotificationSuppressed?: boolean;
// Temporary reservation while kill is in flight. Unlike `notified`, this
// must be cleared on failure so a real completion remains deliverable.
notificationPaused?: boolean;
// Concurrent explicit waits share a reference-counted notification claim.
// A timeout must not re-enable the poller while another wait still owns it.
waitReservations?: number;
waitCompletionClaimed?: boolean;
completionObservedAt?: number;
startedAt?: number;
// subagent
task?: string;
promptOffset?: number;
model?: string;
tools?: string[];
messageTempDirs?: Array<{ dir: string; afterAgentEnd: number }>;
depth?: number;
maxDepth?: number;
budget?: SubagentBudget;
/** Soft-limit warning (80% by default) was accepted for this task. */
budgetWarnedAt?: number;
budgetWarningReason?: string;
/** Hard limit was first observed; grace is measured from observation, not RPC acceptance. */
budgetExceededAt?: number;
budgetReason?: string;
budgetKilled?: boolean;
/** Prompt offset whose nested usage has already been charged to the parent session. */
usageReportedOffset?: number;
/** Prompt offset explicitly collected by foreground mode or babysit_wait. */
subagentCollectedOffset?: number;
/** Prompt offset whose ready-to-collect reminder was sent to the parent. */
subagentNotifiedOffset?: number;
/** Current task completion first observed by the reminder poller. */
subagentCompletionObservedOffset?: number;
subagentCompletionObservedAt?: number;
}
const metaDir = () => path.join(ROOT, "meta");
const logPath = (id: string) => path.join(ROOT, "sessions", id, "output.log");
function writeMeta(id: string, m: Meta): boolean {
const target = path.join(metaDir(), `${id}.json`);
const temp = `${target}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
try {
fs.mkdirSync(metaDir(), { recursive: true });
fs.writeFileSync(temp, JSON.stringify(m));
fs.renameSync(temp, target);
return true;
} catch {
try {
fs.rmSync(temp, { force: true });
} catch {
/* best-effort */
}
return false;
}
}
export function claimFileOnce(file: string, payload: string): boolean {
let fd: number;
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
fd = fs.openSync(file, "wx");
} catch {
return false;
}
try {
fs.writeFileSync(fd, payload);
} finally {
fs.closeSync(fd);
}
return true;
}
function readMeta(id: string): Meta | null {
try {
return JSON.parse(fs.readFileSync(path.join(metaDir(), `${id}.json`), "utf-8"));
} catch {
return null;
}
}
export interface BabysitGcResult {
candidates: string[];
deleted: string[];
bytes: number;
skippedLive: string[];
}
function processIsAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}
const GC_LOCK_FILE = ".pi-babysit-gc.lock";
const GC_STAMP_FILE = ".pi-babysit-gc.last";
const AUTOMATIC_GC_INTERVAL_MS = 24 * 60 * 60 * 1_000;
const ACTIVE_LEASE_PREFIX = ".pi-babysit-active-";
function automaticGcDue(now = Date.now()): boolean {
try {
return now - fs.statSync(path.join(ROOT_BASE, GC_STAMP_FILE)).mtimeMs >= AUTOMATIC_GC_INTERVAL_MS;
} catch {
return true;
}
}
function markAutomaticGc(now = new Date()): void {
try {
fs.mkdirSync(ROOT_BASE, { recursive: true });
fs.writeFileSync(path.join(ROOT_BASE, GC_STAMP_FILE), now.toISOString());
} catch {
/* best-effort; GC safety does not depend on this throttle stamp */
}
}
function scanTreeStats(root: string): { bytes: number; newestMtimeMs: number } {
let bytes = 0;
let newestMtimeMs = 0;
const pending = [root];
while (pending.length > 0) {
const current = pending.pop() as string;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (entry.name === GC_LOCK_FILE) continue;
const fullPath = path.join(current, entry.name);
try {
const stat = fs.lstatSync(fullPath);
newestMtimeMs = Math.max(newestMtimeMs, stat.mtimeMs);
if (entry.isDirectory()) pending.push(fullPath);
else if (entry.isFile()) bytes += stat.size;
} catch {
/* raced with cleanup */
}
}
}
return { bytes, newestMtimeMs };
}
function gcRootIsSafe(root: string): boolean {
let rootEntries: fs.Dirent[];
try {
rootEntries = fs.readdirSync(root, { withFileTypes: true });
} catch {
return false;
}
for (const entry of rootEntries) {
if (!entry.isFile() || !entry.name.startsWith(ACTIVE_LEASE_PREFIX)) continue;
try {
const lease = JSON.parse(fs.readFileSync(path.join(root, entry.name), "utf8")) as {
pid?: number;
};
if (!Number.isSafeInteger(lease.pid) || (lease.pid as number) <= 0) return false;
if (processIsAlive(lease.pid as number)) return false;
} catch {
return false;
}
}
const sessionsDir = path.join(root, "sessions");
let sessionDirs: fs.Dirent[];
try {
sessionDirs = fs.readdirSync(sessionsDir, { withFileTypes: true });
} catch (error) {
// session_start acquires a lease before the first worker exists. Once that
// lease is gone, an old root with no sessions is safe to collect.
return (error as NodeJS.ErrnoException).code === "ENOENT";
}
if (!sessionDirs.some((entry) => entry.isDirectory())) return true;
let sawStatus = false;
for (const sessionEntry of sessionDirs) {
if (!sessionEntry.isDirectory()) continue;
const sessionDir = path.join(sessionsDir, sessionEntry.name);
try {
const status = JSON.parse(
fs.readFileSync(path.join(sessionDir, "status.json"), "utf8"),
) as { state?: string; child_pid?: number | null };
sawStatus = true;
if (status.state !== "running") {
if (!status.state || (!isConfirmedTerminalState(status.state) && status.state !== "dead")) {
return false;
}
continue;
}
const supervisorPid = Number(
(JSON.parse(fs.readFileSync(path.join(sessionDir, "meta.json"), "utf8")) as {
babysit_pid?: number;
}).babysit_pid,
);
const childPid = Number(status.child_pid);
if (
!Number.isSafeInteger(supervisorPid) ||
supervisorPid <= 0 ||
processIsAlive(supervisorPid) ||
(Number.isSafeInteger(childPid) && childPid > 0 && processIsAlive(childPid))
) {
return false;
}
} catch {
return false;
}
}
return sawStatus;
}
function acquireRootLease(root: string): string | null {
fs.mkdirSync(root, { recursive: true });
const leasePath = path.join(
root,
`${ACTIVE_LEASE_PREFIX}${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
);
const sleeper = new Int32Array(new SharedArrayBuffer(4));
for (let attempt = 0; attempt < 100; attempt++) {
if (fs.existsSync(path.join(root, GC_LOCK_FILE))) {
Atomics.wait(sleeper, 0, 0, 50);
continue;
}
try {
fs.writeFileSync(leasePath, JSON.stringify({ pid: process.pid, startedAt: Date.now() }), {
flag: "wx",
});
if (!fs.existsSync(path.join(root, GC_LOCK_FILE))) return leasePath;
fs.rmSync(leasePath, { force: true });
} catch {
/* retry while a collector owns the namespace */
}
Atomics.wait(sleeper, 0, 0, 50);
}
return null;
}
function releaseRootLease(leasePath: string | undefined): void {
if (!leasePath) return;
try {
fs.rmSync(leasePath, { force: true });
} catch {
/* best-effort */
}
}
export function gcBabysitRoots(options: {
rootBase: string;
currentRoot: string;
olderThanMs: number;
dryRun?: boolean;
now?: number;
}): BabysitGcResult {
const now = options.now ?? Date.now();
const current = path.resolve(options.currentRoot);
const result: BabysitGcResult = { candidates: [], deleted: [], bytes: 0, skippedLive: [] };
let roots: fs.Dirent[];
try {
roots = fs.readdirSync(options.rootBase, { withFileTypes: true });
} catch {
return result;
}
for (const entry of roots) {
if (!entry.isDirectory() || entry.name.startsWith(".pi-babysit-gc-")) continue;
const root = path.join(options.rootBase, entry.name);
if (path.resolve(root) === current) continue;
if (!gcRootIsSafe(root)) {
result.skippedLive.push(entry.name);
continue;
}
const stats = scanTreeStats(root);
if (now - stats.newestMtimeMs < options.olderThanMs) continue;
result.candidates.push(entry.name);
if (options.dryRun !== false) {
result.bytes += stats.bytes;
continue;
}
const lockPath = path.join(root, GC_LOCK_FILE);
let lockFd: number | undefined;
let tombstone: string | undefined;
try {
lockFd = fs.openSync(lockPath, "wx");
// Compatible Pi processes acquire an active lease around this same lock.
// Revalidate after locking, then atomically rename the root so a resume
// racing deletion creates a fresh namespace instead of writing into rmSync.
if (!gcRootIsSafe(root)) {
result.skippedLive.push(entry.name);
continue;
}
const refreshedStats = scanTreeStats(root);
if (now - refreshedStats.newestMtimeMs < options.olderThanMs) continue;
tombstone = path.join(
options.rootBase,
`.pi-babysit-gc-${entry.name}-${process.pid}-${Date.now()}`,
);
fs.closeSync(lockFd);
lockFd = undefined;
fs.renameSync(root, tombstone);
fs.rmSync(tombstone, { recursive: true, force: true });
result.deleted.push(entry.name);
result.bytes += refreshedStats.bytes;
} catch {
/* report only roots whose atomic removal completed */
} finally {
if (lockFd != null) {
try {
fs.closeSync(lockFd);
} catch {
/* best-effort */
}
}
try {
fs.rmSync(lockPath, { force: true });
} catch {
/* root may already have been atomically renamed */
}
}
}
return result;
}
function cleanupMessageTempDirs(id: string, meta: Meta, agentEnds: number): void {
if (!meta.messageTempDirs?.length) return;
const keep: NonNullable = [];
for (const entry of meta.messageTempDirs) {
if (entry.afterAgentEnd > agentEnds) {
keep.push(entry);
continue;
}
try {
fs.rmSync(entry.dir, { recursive: true, force: true });
} catch {
/* best-effort */
}
}
if (keep.length > 0) meta.messageTempDirs = keep;
else delete meta.messageTempDirs;
writeMeta(id, meta);
}
export function shouldDeliverProcessCompletion(
meta: Meta | null,
): meta is Meta & { kind: "process" } {
return meta?.kind === "process" && !meta.notified && !meta.notificationPaused;
}
export function shouldDeliverSubagentCompletion(meta: Meta | null): meta is Meta & { kind: "subagent" } {
if (meta?.kind !== "subagent") return false;
const offset = meta.promptOffset ?? 0;
return (
meta.usageReportedOffset !== offset &&
meta.subagentCollectedOffset !== offset &&
meta.subagentNotifiedOffset !== offset
);
}
export function isNotificationGroupReady(
meta: Meta,
sessions: BsSession[],
metaFor: (id: string) => Meta | null,
): boolean {
if (!meta.notificationGroup) return true;
return !sessions.some(
(session) =>
session.state === "running" &&
metaFor(session.id)?.notificationGroup === meta.notificationGroup,
);
}
export function shouldDeferCompletionNotification(agentIsIdle: boolean): boolean {
return !agentIsIdle;
}
export function shouldKeepPolling(
sessions: Array<{ id: string; state: string }>,
metaFor: (id: string) => Meta | null,
): boolean {
return sessions.some((session) => {
const meta = metaFor(session.id);
return (
session.state === "running" ||
shouldDeliverProcessCompletion(meta) ||
(session.state !== "running" && shouldDeliverSubagentCompletion(meta))
);
});
}
export function shouldKeepPollingAfterList(
listed: { sessions: Array<{ id: string; state: string }>; error?: string },
metaFor: (id: string) => Meta | null,
): boolean {
return Boolean(listed.error) || shouldKeepPolling(listed.sessions, metaFor);
}
const kindOf = (id: string): "process" | "subagent" => readMeta(id)?.kind ?? "process";
// Compact elapsed formatting: "42s", "3m12s", "1h04m".
function fmtDuration(ms: number): string {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m${String(s % 60).padStart(2, "0")}s`;
const h = Math.floor(m / 60);
return `${h}h${String(m % 60).padStart(2, "0")}m`;
}
// Elapsed since a session started, from its recorded startedAt (null if unknown).
function elapsedOf(id: string): string | null {
const started = readMeta(id)?.startedAt;
return started ? fmtDuration(Date.now() - started) : null;
}
// ---------------------------------------------------------------------------
// RPC plumbing over babysit (subagent kind: send a command, await its response)
// ---------------------------------------------------------------------------
// `babysit send --json` returns the raw-log byte offset just BEFORE the input
// was injected — pass it to `expect --since` to wait for the reply race-free.
async function sendRpc(
id: string,
cmd: Record,
): Promise<{ offset: number } | { error: string }> {
const r = await bs(["send", "-s", id, "--json", JSON.stringify(cmd)]);
if (r.code !== 0) return { error: r.stderr || r.stdout || "send failed" };
try {
return { offset: JSON.parse(r.stdout).offset as number };
} catch {
return { error: `could not parse send output: ${r.stdout}` };
}
}
export function parseRpcResponseBytes(
bytes: Buffer,
since: number,
command: string,
):
| { ok: true; data?: Record; offset: number }
| { ok: false; error: string } {
let start = 0;
while (start < bytes.length) {
const newline = bytes.indexOf(0x0a, start);
const end = newline < 0 ? bytes.length : newline + 1;
const line = bytes
.subarray(start, newline < 0 ? end : newline)
.toString("utf8")
.replace(/\r$/, "")
.trim();
if (line.startsWith("{")) {
try {
const event = JSON.parse(line);
if (event.type === "response" && event.command === command) {
if (event.success === false) {
return { ok: false, error: clip(String(event.error ?? `${command} failed`)) };
}
return {
ok: true,
data: event.data as Record | undefined,
offset: since + end,
};
}
} catch {
/* partial line */
}
}
start = end;
}
return { ok: false, error: `no ${command} response found in log` };
}
// Wait for `{"type":"response","command":}` after `since`, then parse
// it. Distinguishes: success, explicit failure (success:false → the error
// message, e.g. "No API key found for …"), subagent death, and timeout — this
// is what makes bad-model/config failures LOUD instead of silent.
export function rpcResponsePattern(command: string): string {
const escaped = command.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Match only a complete response record. A loose `"command":"…"` can occur
// in assistant/tool text, and matching before the terminating newline can
// race the writer while the JSON record is still partial.
return `(?m)^\\{(?:"id":"[^"\\n]*",)?"type":"response","command":"${escaped}"[^\\n]*\\}\\r?\\n`;
}
export function readLogBytesFrom(file: string, since: number): Buffer {
const size = fs.statSync(file).size;
const offset = Math.min(Math.max(0, since), size);
const length = size - offset;
const bytes = Buffer.allocUnsafe(length);
const fd = fs.openSync(file, "r");
let read = 0;
try {
while (read < length) {
const count = fs.readSync(fd, bytes, read, length - read, offset + read);
if (count === 0) break;
read += count;
}
} finally {
fs.closeSync(fd);
}
return bytes.subarray(0, read);
}
const RPC_RESPONSE_WINDOW_MAX_BYTES = 1_000_000;
function readLogWindowFrom(
file: string,
since: number,
maxBytes = RPC_RESPONSE_WINDOW_MAX_BYTES,
): { bytes: Buffer; offset: number } {
const size = fs.statSync(file).size;
const requested = Math.min(Math.max(0, since), size);
const offset = Math.max(requested, size - maxBytes);
return { bytes: readLogBytesFrom(file, offset), offset };
}
async function rpcResponse(
id: string,
since: number,
command: string,
timeout = "30s",
signal?: AbortSignal,
): Promise<
| { ok: true; data?: Record; offset: number }
| { ok: false; error: string }
> {
const e = await bs(
[
"expect",
"-s",
id,
"--since",
String(since),
"--timeout",
timeout,
rpcResponsePattern(command),
],
{ signal },
);
if (e.code !== 0) {
const st = await statusOf(id);
if (st && st.state !== "running") {
let structuredError = "";
try {
// Long-lived follow-up workers can have very large historical logs. Read
// only the response window rather than synchronously loading all history.
const window = readLogWindowFrom(logPath(id), since);
structuredError = parseEvents(window.bytes.toString("utf8")).errorMsg ?? "";
} catch {
/* full log path below remains the diagnostic source */
}
return {
ok: false,
error:
`subagent exited (exit_code=${st.exit_code ?? "?"}) before responding to ${command}.` +
(structuredError ? `\n${structuredError}` : "") +
`\nFull log: ${logPath(id)}`,
};
}
return {
ok: false,
error:
e.code === 124
? `timed out waiting for ${command} response (${timeout})`
: e.stderr || `expect failed (code ${e.code})`,
};
}
try {
const window = readLogWindowFrom(logPath(id), since);
return parseRpcResponseBytes(window.bytes, window.offset, command);
} catch (error) {
return { ok: false, error: `could not read ${command} response: ${String(error)}` };
}
}
// "5m" / "30s" / "2h" → milliseconds (null = no limit).
function parseDurMs(s?: string): number | null {
if (!s || s === "none" || s === "0") return null;
const m = /^(\d+)(ms|s|m|h)?$/.exec(s.trim());
if (!m) return null;
const n = Number(m[1]);
const u = m[2] ?? "s";
return n * (u === "ms" ? 1 : u === "s" ? 1000 : u === "m" ? 60_000 : 3_600_000);
}
// ---------------------------------------------------------------------------
// Context-size guard — every string that flows back into the agent's context
// passes through clip(). Log tails are line-capped upstream (`--tail N`), but
// a single pathological line (minified JS, a giant JSON blob) can still be
// megabytes, so we also cap bytes, eliding the middle so both the head and
// the tail of the output stay visible.
function byteLimitFromEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (raw == null || raw.trim() === "") return fallback;
const value = Number(raw);
return Number.isSafeInteger(value) && value >= 0 ? value : fallback;
}
const TAIL_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_TAIL_MAX_BYTES", 4_000);
// Direct run/wait results can carry more context because the caller explicitly
// requested them. Unsolicited completion notifications default much smaller.
const INLINE_OUTPUT_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_INLINE_OUTPUT_MAX_BYTES", 8_000);
const NOTIFY_OUTPUT_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_OUTPUT_MAX_BYTES", 2_000);
const NOTIFY_COMMAND_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_COMMAND_MAX_BYTES", 240);
const NOTIFY_BATCH_MAX_BYTES = byteLimitFromEnv("PI_BABYSIT_NOTIFY_BATCH_MAX_BYTES", 8_000);
const ANSWER_MAX_BYTES = 24_000; // single subagent answers / structured error messages
const MAX_MULTI_WAIT_SESSIONS = 32;
const SUBAGENT_BUDGET_GRACE_MS =
parseDurMs(process.env.PI_BABYSIT_BUDGET_GRACE ?? "90s") ?? 90_000;
const SUBAGENT_REAP_AFTER =
process.env.PI_BABYSIT_REAP_AFTER ?? process.env.PI_SUBAGENT_REAP_AFTER ?? "120s";
const SUBAGENT_REUSE_HINT = ["0", "off", "none"].includes(SUBAGENT_REAP_AFTER)
? "Session remains available until its absolute timeout."
: `Session remains available for follow-ups during the ${SUBAGENT_REAP_AFTER} idle grace.`;
export function clip(s: string, maxBytes = TAIL_MAX_BYTES): string {
if (maxBytes <= 0) return "";
const buf = Buffer.from(s, "utf8");
if (buf.length <= maxBytes) return s;
// The marker counts toward the limit. Recompute a few times because the
// omitted-byte count can change the marker's digit width.
let available = maxBytes;
let marker = "";
for (let i = 0; i < 3; i++) {
marker = `\n… [${buf.length - available} bytes elided] …\n`;
available = Math.max(0, maxBytes - Buffer.byteLength(marker, "utf8"));
}
if (Buffer.byteLength(marker, "utf8") > maxBytes) return truncateUtf8End(marker, maxBytes);
const headBytes = Math.floor(available / 2);
const tailBytes = available - headBytes;
// Strip replacement chars from a mid-codepoint cut at either boundary.
const head = buf.subarray(0, headBytes).toString("utf8").replace(/\uFFFD+$/, "");
const tail = buf.subarray(buf.length - tailBytes).toString("utf8").replace(/^\uFFFD+/, "");
return `${head}${marker}${tail}`;
}
export function clipMultiWaitResult(
value: string,
maxBytes = INLINE_OUTPUT_MAX_BYTES,
): string {
return clip(value, Math.min(maxBytes, ANSWER_MAX_BYTES));
}
interface SearchLogCacheEntry {
size: number;
mtimeMs: number;
text: string;
}
const searchLogCache = new Map();
async function searchLog(
id: string,
pattern: string,
maxLines: number,
signal?: AbortSignal,
maxBytes = TAIL_MAX_BYTES,
): Promise<{ text: string; error?: string }> {
const file = logPath(id);
if (!fs.existsSync(file)) return { text: "", error: `Log file is missing: ${file}` };
if (signal?.aborted) return { text: "", error: "Log search was interrupted." };
const stat = fs.statSync(file);
const cacheKey = `${file}\u0000${pattern}\u0000${maxLines}\u0000${maxBytes}`;
const cached = searchLogCache.get(cacheKey);
if (cached?.size === stat.size && cached.mtimeMs === stat.mtimeMs) return { text: cached.text };
// Run regex evaluation out of process so catastrophic backtracking or a huge
// no-newline log cannot freeze or exhaust pi's main Node process. The helper
// clips each retained line; this parent also enforces a hard wall-clock limit.
return new Promise((resolve) => {
const helper = path.join(EXT_DIR, "search-log.mjs");
const nodeOptions = [process.env.NODE_OPTIONS, "--max-old-space-size=32"]
.filter(Boolean)
.join(" ");
const child = spawn(process.execPath, [helper, file, pattern, String(maxLines)], {
env: { ...process.env, NODE_OPTIONS: nodeOptions },
});
let stdout = "";
let stderr = "";
let finished = false;
let timedOut = false;
const finish = (result: { text: string; error?: string }) => {
if (finished) return;
finished = true;
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
resolve(result);
};
const onAbort = () => {
child.kill("SIGTERM");
finish({ text: "", error: "Log search was interrupted." });
};
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGTERM");
}, 3_000);
signal?.addEventListener("abort", onAbort, { once: true });
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.stderr?.on("data", (data) => {
stderr = clip(stderr + data.toString());
});
child.on("error", (error) => {
finish({ text: "", error: `Could not start log search: ${String(error)}` });
});
child.on("close", (code) => {
if (timedOut) {
finish({ text: "", error: "Log search timed out after 3s; narrow the pattern or log." });
} else if (code !== 0) {
finish({ text: "", error: stderr.trim() || `Log search failed (exit ${code ?? "?"}).` });
} else {
const text = clip(stdout.trimEnd(), maxBytes);
searchLogCache.set(cacheKey, { size: stat.size, mtimeMs: stat.mtimeMs, text });
while (searchLogCache.size > 64) searchLogCache.delete(searchLogCache.keys().next().value as string);
finish({ text });
}
});
});
}
export function shouldInlineCompleteOutput(outputBytes: number, maxBytes: number): boolean {
return maxBytes > 0 && outputBytes <= maxBytes;
}
async function inlineOutput(
id: string,
status: BsSession,
maxBytes = INLINE_OUTPUT_MAX_BYTES,
): Promise {
let bytes = status.output_bytes;
if (bytes == null) {
try {
bytes = fs.statSync(logPath(id)).size;
} catch {
bytes = Number.POSITIVE_INFINITY;
}
}
if (!shouldInlineCompleteOutput(bytes, maxBytes)) {
const size = Number.isFinite(bytes) ? `${bytes} bytes` : "size unavailable";
return `\nOutput omitted (${size}; inline limit ${maxBytes}).`;
}
const output = (await bs(["log", "-s", id])).stdout.trimEnd();
if (Buffer.byteLength(output) > maxBytes) {
return `\nOutput omitted (exceeds inline limit ${maxBytes} bytes).`;
}
return output ? `\n\nOutput:\n${output}` : "";
}
interface ProcessOutputSelection {
pattern?: string;
lines?: number;
maxBytes?: number;
}
async function selectedProcessOutput(
id: string,
status: BsSession,
selection?: ProcessOutputSelection,
signal?: AbortSignal,
): Promise {
if (!selection || (!selection.pattern && selection.lines == null && selection.maxBytes == null)) {
return inlineOutput(id, status);
}
const maxBytes = selection.maxBytes ?? INLINE_OUTPUT_MAX_BYTES;
const lines = Math.min(Math.max(1, Math.floor(selection.lines ?? 30)), 200);
if (selection.pattern) {
const result = await searchLog(id, selection.pattern, lines, signal, maxBytes);
if (result.error) return `\nOutput filter failed: ${result.error}`;
const body = result.text || `(no output matching /${selection.pattern}/)`;
return `\n\nSelected output /${selection.pattern}/:\n${clip(body, maxBytes)}`;
}
const tail = (await bs(["log", "-s", id, "--tail", String(lines)])).stdout.trimEnd();
return tail ? `\n\nSelected tail (${lines} lines max):\n${clip(tail, maxBytes)}` : "";
}
export function summarizeNotificationCommand(command: string | undefined): string {
const preview =
(command ?? "?")
.trim()
.replace(/\r/g, "\\r")
.replace(/\n/g, "\\n")
.replace(/\t/g, "\\t")
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, (char) =>
`\\x${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`,
) || "?";
const bytes = Buffer.from(preview, "utf8");
if (bytes.length <= NOTIFY_COMMAND_MAX_BYTES) return preview;
if (NOTIFY_COMMAND_MAX_BYTES === 0) return "";
const ellipsis = Buffer.from("…", "utf8");
if (NOTIFY_COMMAND_MAX_BYTES <= ellipsis.length) {
return ".".repeat(NOTIFY_COMMAND_MAX_BYTES);
}
const prefix = bytes
.subarray(0, NOTIFY_COMMAND_MAX_BYTES - ellipsis.length)
.toString("utf8")
.replace(/\uFFFD+$/, "");
return `${prefix}…`;
}
type CompletionStatus = "success" | "failed" | "terminated";
export interface ProcessCompletionNotice {
id: string;
exitCode: number | null | undefined;
success: boolean;
status: CompletionStatus;
runtime: string;
summary: string;
command: string | undefined;
logPath: string;
output: string;
}
interface ProcessCompletionMessage {
customType: "pi-babysit-process-end";
content: string;
display: true;
details: {
id?: string;
exitCode?: number | null;
success: boolean;
status: CompletionStatus;
runtime?: string;
logPath?: string;
command?: string;
count: number;
totalCount: number;
remainingCount: number;
processes: Array<{
id: string;
exitCode: number | null | undefined;
success: boolean;
status: CompletionStatus;
runtime: string;
command: string | undefined;
logPath: string;
}>;
};
}
const COMPLETION_FOOTER =
"Automatic completion notification. Inspect the bounded log with babysit_check only if needed.";
const AGGREGATE_OUTPUT_OMISSION = "\nOutput omitted from aggregate notification; inspect log.";
function truncateUtf8End(value: string, maxBytes: number): string {
if (maxBytes <= 0) return "";
const bytes = Buffer.from(value, "utf8");
if (bytes.length <= maxBytes) return value;
const suffix = Buffer.from("…", "utf8");
if (maxBytes <= suffix.length) return ".".repeat(maxBytes);
const prefix = bytes
.subarray(0, maxBytes - suffix.length)
.toString("utf8")
.replace(/\uFFFD+$/, "");
return `${prefix}…`;
}
/** Build one bounded message for as many deliverable completions as fit. */
export function buildProcessCompletionMessage(
notices: ProcessCompletionNotice[],
maxBytes = NOTIFY_BATCH_MAX_BYTES,
): ProcessCompletionMessage {
if (notices.length === 0) throw new Error("At least one completion notice is required.");
const totalCount = notices.length;
const footer = `\n\n${COMPLETION_FOOTER}`;
const header = (count: number) =>
totalCount === 1
? ""
: count === totalCount
? `${count} processes completed:\n\n`
: `${count} of ${totalCount} processes completed:\n\n`;
const deferred = (count: number) =>
count < totalCount
? `\n\n${totalCount - count} completion${totalCount - count === 1 ? "" : "s"} deferred to the next poll.`
: "";
const renderCompact = (batch: ProcessCompletionNotice[]) =>
header(batch.length) +
batch.map((notice) => `${notice.summary}\nLog: ${notice.logPath}`).join("\n\n") +
deferred(batch.length) +
footer;
// If every summary and log path cannot fit, notify the largest fitting prefix
// and leave the rest unacknowledged for a later poll. Always make progress for
// pathological single ids/paths by delivering one UTF-8-truncated entry.
let batch = notices;
if (Buffer.byteLength(renderCompact(batch), "utf8") > maxBytes) {
let low = 1;
let high = notices.length - 1;
let fittedCount = 0;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
if (Buffer.byteLength(renderCompact(notices.slice(0, mid)), "utf8") <= maxBytes) {
fittedCount = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
batch = fittedCount > 0 ? notices.slice(0, fittedCount) : [notices[0]];
}
const blockBase = (notice: ProcessCompletionNotice) =>
`${notice.summary}\nCommand: ${summarizeNotificationCommand(notice.command)}\nLog: ${notice.logPath}`;
const outputs = batch.map((notice) =>
notice.output.startsWith("\n\nOutput:") ? AGGREGATE_OUTPUT_OMISSION : notice.output,
);
const renderDetailed = () =>
header(batch.length) +
batch.map((notice, i) => blockBase(notice) + outputs[i]).join("\n\n") +
deferred(batch.length) +
footer;
let content = renderDetailed();
let contentBytes = Buffer.byteLength(content, "utf8");
if (contentBytes <= maxBytes) {
// Spend the remaining aggregate budget on complete inline outputs without
// repeatedly rebuilding the entire batch for every candidate.
for (let i = 0; i < batch.length; i++) {
if (!batch[i].output.startsWith("\n\nOutput:")) continue;
const delta =
Buffer.byteLength(batch[i].output, "utf8") - Buffer.byteLength(outputs[i], "utf8");
if (contentBytes + delta > maxBytes) continue;
outputs[i] = batch[i].output;
contentBytes += delta;
}
content = renderDetailed();
} else {
content = truncateUtf8End(renderCompact(batch), maxBytes);
}
const statuses = new Set(batch.map((notice) => notice.status));
const status: CompletionStatus = statuses.has("failed")
? "failed"
: statuses.has("terminated")
? "terminated"
: "success";
const processes = batch.map((notice) => ({
id: notice.id,
exitCode: notice.exitCode,
success: notice.success,
status: notice.status,
runtime: notice.runtime,
command: notice.command,
logPath: notice.logPath,
}));
const single = totalCount === 1 ? batch[0] : undefined;
return {
customType: "pi-babysit-process-end",
content,
display: true,
details: {
id: single?.id,
exitCode: single?.exitCode,
success: batch.every((notice) => notice.success),
status,
runtime: single?.runtime,
logPath: single?.logPath,
command: single?.command,
count: batch.length,
totalCount,
remainingCount: totalCount - batch.length,
processes,
},
};
}
/** Send once and only acknowledge notices represented in the accepted batch. */
export function deliverProcessCompletionMessage(
notices: ProcessCompletionNotice[],
send: (
message: ProcessCompletionMessage,
options: { triggerTurn: true; deliverAs: "steer" },
) => void,
onSent: (notice: ProcessCompletionNotice) => void,
): boolean {
if (notices.length === 0) return false;
let message: ProcessCompletionMessage;
try {
message = buildProcessCompletionMessage(notices);
send(message, { triggerTurn: true, deliverAs: "steer" });
} catch {
return false;
}
const deliveredIds = new Set(message.details.processes.map(({ id }) => id));
for (const notice of notices) {
if (deliveredIds.has(notice.id)) onSent(notice);
}
return true;
}
// ---------------------------------------------------------------------------
// parked-turn detection (shared rule with self-reap.ts)
// ---------------------------------------------------------------------------
// A turn that ends right after `babysit_run { command }` only means "parked
// awaiting the process-exit notification" — pi resumes it on its own; that is
// NOT task completion. Such runs stamp NOTIFY_MARKER into their tool result,
// so the marker in the LAST message's toolResult identifies a parked turn.
// (A subagent-profile run does NOT carry the marker: ending a turn to "wait"
// for a subagent is a guidance violation, and treating it as completion keeps
// the parent from hanging forever.) `process` is the legacy pi-processes tool.
function isParkedMessages(
messages:
| {
role?: string;
toolName?: string;
content?: unknown;
details?: { kind?: string; status?: string };
}[]
| undefined,
): boolean {
if (!messages) return false;
// Models sometimes add a short assistant note after starting a process. Scan
// the run rather than requiring the marker-bearing tool result to be the last
// message, otherwise that harmless note turns a parked build into false task
// completion (and lets the self-reaper kill it).
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message?.role !== "toolResult") continue;
if (message.toolName === "process") return true; // legacy pi-processes
if (message.toolName !== "babysit_run") continue;
if (message.details?.kind === "process" && message.details.status === "started") {
return true;
}
const text = Array.isArray(message.content)
? message.content
.filter((part): part is { type: "text"; text: string } =>
Boolean(part && typeof part === "object" && part.type === "text" && typeof part.text === "string"),
)
.map((part) => part.text)
.join("")
: typeof message.content === "string"
? message.content
: "";
if (/^Process started \(id: [^)]+\)\. \[notify-on-exit\]\nLog: /.test(text)) return true;
}
return false;
}
// ---------------------------------------------------------------------------
// parse a subagent's RPC event stream (from its babysit log)
// ---------------------------------------------------------------------------
interface ToolCall {
name: string;
summary: string;
}
export interface Progress {
turns: number;
/** Bounded recent calls for status rendering. */
toolCalls: ToolCall[];
/** Exact count, independent of the bounded recent-call ring. */
toolCallCount: number;
finalText: string;
/** Best-effort text from the currently streaming assistant message. */
streamingText: string;
/** Context size reported by the most recent assistant response. */
tokens?: number;
/** Cumulative model usage for the current subagent task. */
modelCalls: number;
usageTokens: number;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
reasoningTokens: number;
cost: number;
inputCost: number;
outputCost: number;
cacheReadCost: number;
cacheWriteCost: number;
errorMsg?: string;
// RPC lifecycle bookkeeping (computed over the analyzed log slice):
agentStarts: number;
agentEnds: number;
agentSettled: number;
lastEndWasProcessWait: boolean;
running: boolean; // an agent run is in flight right now
waitingOnProcess: boolean; // idle, but a process resume is pending
done: boolean; // task genuinely complete
}
function summarizeToolCall(name: string, args: Record): string {
const s = (v: unknown, n = 60) => {
const str = String(v ?? "");
return str.length > n ? `${str.slice(0, n - 1)}\u2026` : str;
};
switch (name) {
case "bash":
return `$ ${s(args.command)}`;
case "read":
return `read ${s(args.file_path ?? args.path)}`;
case "write":
return `write ${s(args.file_path ?? args.path)}`;
case "edit":
return `edit ${s(args.file_path ?? args.path)}`;
case "grep":
return `grep /${s(args.pattern, 40)}/`;
case "find":
return `find ${s(args.pattern ?? args.path, 40)}`;
case "ls":
return `ls ${s(args.path)}`;
case "babysit_run":
return `babysit ${s(args.command ?? args.task, 50)}`;
default:
return `${name} ${s(JSON.stringify(args), 40)}`;
}
}
function emptyProgress(): Progress {
return {
turns: 0,
toolCalls: [],
toolCallCount: 0,
finalText: "",
streamingText: "",
modelCalls: 0,
usageTokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
cost: 0,
inputCost: 0,
outputCost: 0,
cacheReadCost: 0,
cacheWriteCost: 0,
agentStarts: 0,
agentEnds: 0,
agentSettled: 0,
lastEndWasProcessWait: false,
running: false,
waitingOnProcess: false,
done: false,
};
}
export function subagentBudgetViolation(
progress: Progress,
budget?: SubagentBudget,
): string | null {
if (!budget) return null;
if (budget.maxCost != null && progress.cost >= budget.maxCost) {
return `cost $${progress.cost.toFixed(4)} reached maxCost $${budget.maxCost.toFixed(4)}`;
}
if (budget.maxTurns != null && progress.turns >= budget.maxTurns) {
return `${progress.turns} turns reached maxTurns ${budget.maxTurns}`;
}
if (budget.maxToolCalls != null && progress.toolCallCount >= budget.maxToolCalls) {
return `${progress.toolCallCount} tool calls reached maxToolCalls ${budget.maxToolCalls}`;
}
if (budget.maxUsageTokens != null && progress.usageTokens >= budget.maxUsageTokens) {
return `${progress.usageTokens} usage tokens reached maxUsageTokens ${budget.maxUsageTokens}`;
}
return null;
}
export function subagentBudgetSoftViolation(
progress: Progress,
budget?: SubagentBudget,
ratio = 0.8,
): string | null {
if (!budget || ratio <= 0 || ratio >= 1) return null;
if (budget.maxCost != null && progress.cost >= budget.maxCost * ratio) {
return `cost $${progress.cost.toFixed(4)} reached ${Math.round(ratio * 100)}% of maxCost $${budget.maxCost.toFixed(4)}`;
}
if (budget.maxTurns != null && progress.turns >= Math.max(1, Math.ceil(budget.maxTurns * ratio))) {
return `${progress.turns} turns reached ${Math.round(ratio * 100)}% of maxTurns ${budget.maxTurns}`;
}
if (
budget.maxToolCalls != null &&
progress.toolCallCount >= Math.max(1, Math.ceil(budget.maxToolCalls * ratio))
) {
return `${progress.toolCallCount} tool calls reached ${Math.round(ratio * 100)}% of maxToolCalls ${budget.maxToolCalls}`;
}
if (
budget.maxUsageTokens != null &&
progress.usageTokens >= Math.max(1, Math.ceil(budget.maxUsageTokens * ratio))
) {
return `${progress.usageTokens} usage tokens reached ${Math.round(ratio * 100)}% of maxUsageTokens ${budget.maxUsageTokens}`;
}
return null;
}
export function subagentBudgetAction(
progress: Progress,
budget: SubagentBudget | undefined,
exceededAt: number | undefined,
now: number,
graceMs: number,
): { action: "none" | "steer" | "kill"; reason?: string } {
const reason = subagentBudgetViolation(progress, budget);
if (!reason) return { action: "none" };
if (exceededAt == null) return { action: "steer", reason };
return now - exceededAt >= graceMs
? { action: "kill", reason }
: { action: "none", reason };
}
function updateProgressState(progress: Progress): Progress {
progress.running = progress.agentStarts > progress.agentEnds;
progress.waitingOnProcess =
!progress.running && progress.agentEnds > 0 && progress.lastEndWasProcessWait;
// `agent_end` is not final: Pi may still retry, compact-and-retry, or process
// queued continuations. `agent_settled` is the authoritative completion event.
progress.done =
!progress.running &&
progress.agentSettled > 0 &&
!progress.lastEndWasProcessWait;
return progress;
}
function parseEventLine(progress: Progress, raw: string): void {
const line = raw.replace(/\r$/, "").trim();
if (!line.startsWith("{")) return;
let event: Record;
try {
event = JSON.parse(line);
} catch {
return; // partial trailing line, etc.
}
switch (event.type) {
case "turn_start":
progress.turns++;
progress.streamingText = "";
break;
case "message_update": {
const update = event.assistantMessageEvent as
| { type?: string; delta?: string }
| undefined;
if (update?.type === "text_delta" && typeof update.delta === "string") {
progress.streamingText = clip(progress.streamingText + update.delta, ANSWER_MAX_BYTES);
}
break;
}
case "tool_execution_start": {
const name = String(event.toolName ?? "tool");
progress.toolCallCount++;
progress.toolCalls.push({
name,
summary: summarizeToolCall(name, (event.args as Record) ?? {}),
});
// Open-ended workers must not retain an unbounded tool history in Pi.
if (progress.toolCalls.length > 200) progress.toolCalls.splice(0, progress.toolCalls.length - 200);
break;
}
case "message_end": {
const message = event.message as
| {
role?: string;
content?: { type: string; text?: string }[];
stopReason?: string;
errorMessage?: string;
usage?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
reasoning?: number;
totalTokens?: number;
cost?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
};
}
| undefined;
if (message?.role === "assistant") {
const text = (message.content ?? [])
.filter((content) => content.type === "text" && content.text)
.map((content) => content.text)
.join("");
if (text.trim()) progress.finalText = clip(text, ANSWER_MAX_BYTES);
if (message.stopReason === "error") {
progress.errorMsg = message.errorMessage || "subagent model request failed";
}
progress.streamingText = "";
if (message.usage) {
const finite = (value: number | undefined) =>
typeof value === "number" && Number.isFinite(value) ? value : 0;
progress.modelCalls++;
progress.tokens = message.usage.totalTokens;
progress.usageTokens += finite(message.usage.totalTokens);
progress.inputTokens += finite(message.usage.input);
progress.outputTokens += finite(message.usage.output);
progress.cacheReadTokens += finite(message.usage.cacheRead);
progress.cacheWriteTokens += finite(message.usage.cacheWrite);
progress.reasoningTokens += finite(message.usage.reasoning);
progress.inputCost += finite(message.usage.cost?.input);
progress.outputCost += finite(message.usage.cost?.output);
progress.cacheReadCost += finite(message.usage.cost?.cacheRead);
progress.cacheWriteCost += finite(message.usage.cost?.cacheWrite);
progress.cost += finite(message.usage.cost?.total);
}
}
break;
}
case "agent_start":
progress.agentStarts++;
break;
case "agent_end": {
progress.agentEnds++;
progress.lastEndWasProcessWait =
typeof event.piBabysitParked === "boolean"
? event.piBabysitParked
: isParkedMessages(
event.messages as
| {
role?: string;
toolName?: string;
content?: unknown;
details?: { kind?: string; status?: string };
}[]
| undefined,
);
break;
}
case "agent_settled":
progress.agentSettled++;
break;
case "response":
if (event.success === false) {
progress.errorMsg = String(event.error ?? `rpc ${event.command ?? "command"} failed`);
}
break;
case "error":
progress.errorMsg = String(event.message ?? event.error ?? line);
break;
case "extension_error": {
const extension = event.extensionPath ? `${String(event.extensionPath)}: ` : "";
progress.errorMsg = `${extension}${String(event.error ?? "extension failed")}`;
break;
}
}
}
export function parseEvents(logText: string): Progress {
const progress = emptyProgress();
for (const line of logText.split("\n")) parseEventLine(progress, line);
return updateProgressState(progress);
}
export function buildSubagentDoneResult(
progress: Progress,
): { body: string; ok: boolean } {
const finalText = progress.finalText.trim();
return {
body: clip(
progress.errorMsg
? `Extension error: ${progress.errorMsg}` +
(finalText ? `\n\n--- final answer ---\n${finalText}` : "")
: finalText || "(no answer text)",
ANSWER_MAX_BYTES,
),
ok: !progress.errorMsg,
};
}
export function buildSubagentExitDiagnostic(
progress: Progress,
fullLogPath: string,
): string {
const body = clip(
progress.errorMsg ||
progress.streamingText.trim() ||
progress.finalText.trim() ||
"(no structured error was emitted; inspect the full log)",
ANSWER_MAX_BYTES,
);
return `${body}\n\nFull log: ${fullLogPath}`;
}
interface TaskProgressCache {
base: number;
offset: number;
pending: Buffer;
progress: Progress;
}
const taskProgressCache = new Map();
export function pruneTerminalSessionCache(
cache: Map,
sessions: Array<{ id: string; state: string }>,
): number {
const running = new Set(
sessions.filter((session) => session.state === "running").map((session) => session.id),
);
let removed = 0;
for (const id of cache.keys()) {
if (running.has(id)) continue;
cache.delete(id);
removed++;
}
return removed;
}
/** Parse only bytes appended since the previous observation of this task. */
function taskProgressOf(id: string): { progress: Progress; offset: number } {
const base = readMeta(id)?.promptOffset ?? 0;
const file = logPath(id);
const size = fs.statSync(file).size;
let cached = taskProgressCache.get(id);
if (!cached || cached.base !== base || size < cached.offset) {
cached = { base, offset: base, pending: Buffer.alloc(0), progress: emptyProgress() };
taskProgressCache.set(id, cached);
}
if (size > cached.offset) {
const added = Buffer.allocUnsafe(size - cached.offset);
const fd = fs.openSync(file, "r");
let read = 0;
try {
while (read < added.length) {
const count = fs.readSync(fd, added, read, added.length - read, cached.offset + read);
if (count === 0) break;
read += count;
}
} finally {
fs.closeSync(fd);
}
cached.offset += read;
const bytes = cached.pending.length
? Buffer.concat([cached.pending, added.subarray(0, read)])
: added.subarray(0, read);
let start = 0;
for (let index = 0; index < bytes.length; index++) {
if (bytes[index] !== 0x0a) continue;
parseEventLine(cached.progress, bytes.subarray(start, index).toString("utf8"));
start = index + 1;
}
cached.pending = Buffer.from(bytes.subarray(start));
}
updateProgressState(cached.progress);
const currentMeta = readMeta(id);
if (currentMeta?.budgetKilled && currentMeta.budgetReason) {
cached.progress.errorMsg = `Subagent budget exceeded: ${currentMeta.budgetReason}`;
}
if (cached.progress.agentEnds > 0) {
const meta = currentMeta;
if (meta) cleanupMessageTempDirs(id, meta, cached.progress.agentEnds);
}
// If the writer was observed mid-record, let `babysit expect --since` start
// before that partial line so a split agent_settled marker cannot be missed.
return {
progress: cached.progress,
offset: cached.offset - cached.pending.length,
};
}
// ---------------------------------------------------------------------------
// spawning — kind=process
// ---------------------------------------------------------------------------
// Friendly name → unique babysit session id (babysit ids allow [\w.-]).
const reservedSessionIds = new Set();
async function reserveUniqueSessionId(name: string): Promise {
const base = name.replace(/[^\w.-]+/g, "-").replace(/^-+|-+$/g, "") || "proc";
const taken = new Set((await listSessions()).sessions.map((s) => s.id));
for (const id of reservedSessionIds) taken.add(id);
let id = base;
for (let i = 2; taken.has(id); i++) id = `${base}-${i}`;
reservedSessionIds.add(id);
return id;
}
interface ProcOpts {
name?: string;
command: string;
cwd: string;
env?: Record;
timeout?: string; // default: none — dev servers may run indefinitely
idleTimeout?: string;
pty: boolean;
notificationGroup?: string;
}
export function processSessionEnvironment(
ctx: ExtensionContext,
reasoningLevel = (ctx as ExtensionContext & { thinkingLevel?: string }).thinkingLevel,
): Record {
return {
PI_SESSION_ID: ctx.sessionManager.getSessionId(),
PI_SESSION_FILE: ctx.sessionManager.getSessionFile(),
PI_PROVIDER: ctx.model?.provider,
PI_MODEL: ctx.model?.id,
PI_REASONING_LEVEL: reasoningLevel,
};
}
async function spawnProcess(opts: ProcOpts): Promise<{ id: string } | { error: string }> {
const bsArgs = ["run", "-d", "--json", "--size", "120x40"];
if (!opts.pty) bsArgs.push("--no-tty");
if (opts.timeout && opts.timeout !== "none") bsArgs.push("--timeout", opts.timeout);
if (opts.idleTimeout && opts.idleTimeout !== "none")
bsArgs.push("--idle-timeout", opts.idleTimeout);
const reservedId = opts.name ? await reserveUniqueSessionId(opts.name) : undefined;
if (reservedId) bsArgs.push("--id", reservedId);
bsArgs.push("--", SHELL, "-c", opts.command);
let r: Awaited>;
try {
r = await bs(bsArgs, { cwd: opts.cwd, env: opts.env });
} finally {
if (reservedId) reservedSessionIds.delete(reservedId);
}
if (r.code !== 0) {
return {
error:
r.stderr || r.stdout ||
`babysit run failed (exit ${r.code}, no output) — check that \`${BABYSIT_BIN}\` works and ${ROOT} is writable`,
};
}
let id: string;
try {
id = JSON.parse(r.stdout).id;
} catch {
return { error: `could not parse id from: ${r.stdout}` };
}
writeMeta(id, {
kind: "process",
name: opts.name ?? id,
command: opts.command,
notificationGroup: opts.notificationGroup,
notified: false,
startedAt: Date.now(),
});
return { id };
}
// ---------------------------------------------------------------------------
// spawning — kind=subagent
// ---------------------------------------------------------------------------
function writePromptTempFile(agentName: string, prompt: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-babysit-"));
const safe = agentName.replace(/[^\w.-]+/g, "_");
const file = path.join(dir, `prompt-${safe}.md`);
fs.writeFileSync(file, prompt, "utf-8");
return file;
}
// RPC messages are injected into the subagent's PTY stdin via `babysit send`.
// A PTY input queue is tiny (~1KB canonical limit on macOS), so long messages
// get truncated/dropped, mangling the RPC JSON and silently breaking spawn.
// Anything over this budget is written to a file instead, and only a short
// "read this file" instruction travels through the PTY.
const PTY_SAFE_MESSAGE_BYTES = 600;
const messageNeedsReadTool = (text: string): boolean =>
Buffer.byteLength(text, "utf-8") > PTY_SAFE_MESSAGE_BYTES;
interface DeliverableMessage {
message: string;
tempDir?: string;
}
function deliverableMessage(kind: "task" | "steering message", text: string): DeliverableMessage {
if (!messageNeedsReadTool(text)) return { message: text };
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-babysit-msg-"));
const file = path.join(dir, "message.md");
fs.writeFileSync(file, text, "utf-8");
return {
message: `Your full ${kind} is in the file ${file} — read it with the Read tool FIRST, then carry it out exactly as written.`,
tempDir: dir,
};
}
function discardDelivery(delivery: DeliverableMessage): void {
if (!delivery.tempDir) return;
try {
fs.rmSync(delivery.tempDir, { recursive: true, force: true });
} catch {
/* best-effort */
}
}
interface SubagentOpts {
name?: string;
agent?: AgentConfig;
task: string;
model?: string;
tools?: string[];
cwd: string;
depth: number;
maxDepth: number;
budget?: SubagentBudget;
// Idle-timeout is OFF by default: an RPC-mode pi is silent while it works,
// so idle detection would false-kill a busy subagent. The absolute timeout
// is the safety valve instead.
idleTimeout?: string;
timeout: string;
}
async function spawnSubagent(
opts: SubagentOpts,
): Promise<{ id: string; model?: string } | { error: string }> {
// Long-lived RPC worker: the task is NOT passed as argv — it is injected
// below as an RPC `prompt` command, whose response we validate so spawn
// failures (bad model, missing API key) are loud instead of a silent exit=1.
const piArgs: string[] = ["--mode", "rpc", "--no-session"];
const model = opts.model ?? opts.agent?.model;
if (model) piArgs.push("--model", model);
const tools = opts.tools ?? opts.agent?.tools;
if (messageNeedsReadTool(opts.task) && tools?.length && !tools.includes("read")) {
return {
error:
"This task exceeds the PTY-safe inline limit and must be delivered through a file, " +
"but the subagent tool allowlist excludes `read`. Add `read` or shorten the task.",
};
}
if (tools && tools.length > 0) piArgs.push("--tools", tools.join(","));
piArgs.push(
"--append-system-prompt",
subagentGuidance(
opts.depth,
opts.maxDepth,
isAllowedDirectBash("") && (!tools || tools.includes("bash")),
!tools || tools.includes("babysit_run"),
),
);
let promptTempFile: string | undefined;
if (opts.agent?.systemPrompt?.trim()) {
promptTempFile = writePromptTempFile(opts.agent.name, opts.agent.systemPrompt);
piArgs.push("--append-system-prompt", promptTempFile);
}
const cleanupPromptTemp = () => {
if (!promptTempFile) return;
try {
fs.rmSync(path.dirname(promptTempFile), { recursive: true, force: true });
} catch {
/* best-effort */
}
promptTempFile = undefined;
};
// Self-reaper: a finished subagent exits after a short idle grace instead of
// lingering until the absolute --timeout. Cancelled by a follow-up task, and
// it never reaps a turn parked on a process-exit notification. See self-reap.ts.
piArgs.push("--extension", path.join(EXT_DIR, "self-reap.ts"));
// A real PTY is used (NOT --no-tty): it lets a human `attach` and fully
// take over the subagent.
const bsArgs = [
"run",
"-d",
"--json",
"--size",
"120x40",
"--timeout",
opts.timeout,
];
// Pretty-print the compact JSONL stream for humans who `attach`. The RPC
// proxy retains authoritative message_end/response/error events and reduces
// redundant lifecycle/tool payloads; parseEvents accepts both log formats.
if (VIEW_CMD.trim()) bsArgs.push("--view-cmd", VIEW_CMD);
if (opts.idleTimeout && opts.idleTimeout !== "none") {
bsArgs.push("--idle-timeout", opts.idleTimeout);
}
const reservedId = opts.name ? await reserveUniqueSessionId(opts.name) : undefined;
if (reservedId) bsArgs.push("--id", reservedId);
bsArgs.push(
"--",
process.execPath,
path.join(EXT_DIR, "rpc-stream-proxy.mjs"),
"--",
PI_BIN,
...piArgs,
);
let r: Awaited>;
try {
r = await bs(bsArgs, {
cwd: opts.cwd,
env: {
[SUBAGENT_DEPTH_ENV]: String(opts.depth),
[SUBAGENT_MAX_DEPTH_ENV]: String(opts.maxDepth),
},
});
} finally {
if (reservedId) reservedSessionIds.delete(reservedId);
}
if (r.code !== 0) {
cleanupPromptTemp();
return { error: r.stderr || r.stdout || `babysit run failed (exit ${r.code}, no output) — check that \`${BABYSIT_BIN}\` works and ${ROOT} is writable` };
}
let id: string;
try {
id = JSON.parse(r.stdout).id;
} catch {
cleanupPromptTemp();
return { error: `could not parse id from: ${r.stdout}` };
}
// Stamp the kind IMMEDIATELY (with notified:true) so that if validation
// below fails and we kill the session, the exit poller does NOT mistake it
// for an un-notified process and fire a spurious process-end notification.
// The success path overwrites this with the full task meta.
writeMeta(id, {
kind: "subagent",
name: opts.name ?? id,
task: opts.task,
notified: true,
depth: opts.depth,
maxDepth: opts.maxDepth,
budget: opts.budget,
});
// Wait for pi to boot (first JSON event in the log), then inject the task.
// Pi has loaded --append-system-prompt before emitting that event, so the
// anonymous prompt file can be removed immediately instead of leaking.
const boot = await bs(["expect", "-s", id, "--timeout", "30s", '\\{"type"']);
cleanupPromptTemp();
if (boot.code !== 0) {
await bs(["kill", "-s", id]);
return {
error: boot.code === 124
? `subagent ${id} did not emit an RPC startup event within 30s`
: boot.stderr || `subagent ${id} startup probe failed (code ${boot.code})`,
};
}
const delivery = deliverableMessage("task", opts.task);
const sent = await sendRpc(id, {
type: "prompt",
message: `Task: ${delivery.message}`,
});
if ("error" in sent) {
discardDelivery(delivery);
await bs(["kill", "-s", id]);
return { error: `could not send task to subagent ${id}: ${sent.error}` };
}
// Validate the prompt was ACCEPTED (this is where "No API key found for …"
// and similar config errors surface — fail the spawn loudly).
const resp = await rpcResponse(id, sent.offset, "prompt", "60s");
if (!resp.ok) {
discardDelivery(delivery);
await bs(["kill", "-s", id]);
return { error: `subagent ${id} rejected the task: ${resp.error}` };
}
// Prompt acceptance does not guarantee provider authentication: Pi reports
// failures that occur after acceptance through the event stream. Probe a short
// window so immediate missing-key/config errors fail the spawn instead of
// creating a zero-work worker that the caller must discover later.
const startupProbe = await bs([
"expect",
"-s",
id,
"--since",
String(resp.offset),
"--timeout",
"500ms",
'(?m)^\\{"type":"(?:message_end|error|extension_error|agent_settled)"',
]);
if (startupProbe.code === 0) {
try {
const window = readLogWindowFrom(logPath(id), resp.offset);
const initialProgress = parseEvents(window.bytes.toString("utf8"));
if (initialProgress.errorMsg && initialProgress.modelCalls === 0) {
discardDelivery(delivery);
await bs(["kill", "-s", id]);
return { error: `subagent ${id} failed before its first model response: ${initialProgress.errorMsg}` };
}
} catch {
/* normal startup continues; the full stream remains available to check/wait */
}
}
// Report the RESOLVED model (a fuzzy pattern may match something unexpected;
// null means nothing resolved at all).
let resolvedModel: string | undefined;
const gs = await sendRpc(id, { type: "get_state" });
if (!("error" in gs)) {
const st = await rpcResponse(id, gs.offset, "get_state", "15s");
if (st.ok && st.data) {
const m = (st.data as { model?: { id?: string } | null }).model;
if (m === null) {
discardDelivery(delivery);
await bs(["kill", "-s", id]);
return {
error: `subagent ${id} has no usable model${opts.model ? ` (requested "${opts.model}")` : ""} — check the model name with \`pi --list-models\`.`,
};
}
resolvedModel = m?.id;
}
}
writeMeta(id, {
kind: "subagent",
name: opts.name ?? id,
task: opts.task,
promptOffset: resp.offset,
model: resolvedModel,
tools,
messageTempDirs: delivery.tempDir
? [{ dir: delivery.tempDir, afterAgentEnd: 1 }]
: undefined,
depth: opts.depth,
maxDepth: opts.maxDepth,
budget: opts.budget,
startedAt: Date.now(),
});
return { id, model: resolvedModel };
}
// ---------------------------------------------------------------------------
// widget (live counts)
// ---------------------------------------------------------------------------
// A subagent whose task is done stays alive as an idle RPC worker (for
// follow-ups), so "running" in babysit does NOT mean "working" — count
// processes / busy subagents / idle subagents separately.
export type WidgetSessionKind = "process" | "agent";
export type WidgetSessionState = "running" | "idle";
export function widgetSummaryText(procs: number, busy: number, idle: number): string {
const sections: string[] = [];
const running: string[] = [];
if (procs > 0) running.push(`${procs} process${procs > 1 ? "es" : ""}`);
if (busy > 0) running.push(`${busy} agent${busy > 1 ? "s" : ""}`);
if (running.length > 0) sections.push(`RUNNING ${running.join(" ")}`);
if (idle > 0) sections.push(`IDLE ${idle} agent${idle > 1 ? "s" : ""}`);
return sections.join(" ");
}
export function widgetSessionHeader(
id: string,
kind: WidgetSessionKind,
state: WidgetSessionState,
elapsed?: string,
): string {
const icon = state === "running" ? "▶" : "○";
return ` ${icon} ${id} ${kind.toUpperCase()} ${state.toUpperCase()}${elapsed ? ` age ${elapsed}` : ""}`;
}
function renderWidgetLines(procs: number, busy: number, idle: number, theme: Theme): string[] {
if (procs === 0 && busy === 0 && idle === 0) return [];
const sections: string[] = [];
const running: string[] = [];
if (procs > 0) running.push(`${procs} process${procs > 1 ? "es" : ""}`);
if (busy > 0) running.push(`${busy} agent${busy > 1 ? "s" : ""}`);
if (running.length > 0) {
sections.push(`${theme.fg("success", theme.bold("RUNNING"))} ${running.join(" ")}`);
}
if (idle > 0) {
sections.push(`${theme.fg("muted", theme.bold("IDLE"))} ${idle} agent${idle > 1 ? "s" : ""}`);
}
return [theme.bg("toolPendingBg", ` ${sections.join(" ")} `)];
}
function renderWidgetSessionHeader(
id: string,
kind: WidgetSessionKind,
state: WidgetSessionState,
elapsed: string,
theme: Theme,
): string {
const running = state === "running";
const icon = theme.fg(running ? "success" : "muted", running ? "▶" : "○");
const kindLabel = theme.fg(kind === "process" ? "accent" : "warning", theme.bold(kind.toUpperCase()));
const stateLabel = theme.fg(running ? "success" : "muted", theme.bold(state.toUpperCase()));
return ` ${icon} ${id} ${kindLabel} ${stateLabel}${elapsed ? theme.fg("dim", ` age ${elapsed}`) : ""}`;
}
// How many trailing output lines to show per running session in the widget.
const WIDGET_TAIL_LINES = 1;
const WIDGET_TAIL_WIDTH = 100;
// Strip ANSI/control escapes and clamp width so raw PTY output can't wrap or
// corrupt the widget area.
function sanitizeTailLine(s: string): string {
// PTY progress bars often redraw one logical line with carriage returns.
// Keep the latest frame rather than concatenating every historical frame.
const terminalFrame = s.split("\r").filter(Boolean).at(-1) ?? "";
const clean = terminalFrame
// CSI / OSC / other escape sequences
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
.replace(/\x1b[@-Z\\-_]|\x1b\[[0-?]*[ -/]*[@-~]/g, "")
// remaining non-printable control chars (keep tab)
.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
return clean.length > WIDGET_TAIL_WIDTH ? `${clean.slice(0, WIDGET_TAIL_WIDTH - 1)}…` : clean;
}
function readTailLines(file: string, lines: number, maxBytes = 64_000): string[] {
try {
const size = fs.statSync(file).size;
const start = Math.max(0, size - maxBytes);
const length = size - start;
const bytes = Buffer.allocUnsafe(length);
const fd = fs.openSync(file, "r");
let read = 0;
try {
while (read < length) {
const count = fs.readSync(fd, bytes, read, length - read, start + read);
if (count === 0) break;
read += count;
}
} finally {
fs.closeSync(fd);
}
const parts = bytes.subarray(0, read).toString("utf8").split("\n");
if (start > 0) parts.shift(); // first fragment may begin mid-line
return parts.slice(-Math.max(1, lines + 1));
} catch {
return [];
}
}
// Trailing lines to show for a running session (sanitized, unprefixed).
// Process tails are read directly from the bounded end of output.log, avoiding
// one `babysit log` subprocess per active process on every poll.
async function widgetTail(
id: string,
isSub: boolean,
subagentProgress?: Progress,
): Promise {
let raw: string[];
if (!isSub) {
raw = readTailLines(logPath(id), WIDGET_TAIL_LINES);
} else {
const progress = subagentProgress ?? taskProgressOf(id).progress;
if (progress.finalText.trim()) {
raw = progress.finalText.trim().split("\n");
} else if (progress.toolCalls.length > 0) {
raw = progress.toolCalls.map((tool) => tool.summary);
} else {
raw = progress.errorMsg ? [`⚠ ${progress.errorMsg}`] : [];
}
}
return raw
.map(sanitizeTailLine)
.filter((line) => line.trim().length > 0)
.slice(-WIDGET_TAIL_LINES);
}
// ---------------------------------------------------------------------------
// human take-over hint (no tmux dependency)
// ---------------------------------------------------------------------------
// The command a human can run in their OWN terminal/pane to take over a live
// process interactively (detach with Ctrl-\ Ctrl-\). `/babysit` shows this as a
// hint alongside an inline snapshot instead of spawning a tmux window itself.
function attachCmd(id: string): string {
return `BABYSIT_DIR=${shq(ROOT)} ${shq(BABYSIT_BIN)} attach -s ${shq(id)}`;
}
// ---------------------------------------------------------------------------
// waiting
// ---------------------------------------------------------------------------
interface WaitOutcome {
id: string;
kind: "done" | "exited" | "timeout" | "interrupted";
ok: boolean;
text: string;
status?: BsSession | null;
progress?: Progress;
}
export interface NestedUsage {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
total: number;
};
}
export function usageFromProgress(progress: Progress): NestedUsage | undefined {
if (progress.modelCalls === 0) return undefined;
return {
input: progress.inputTokens,
output: progress.outputTokens,
cacheRead: progress.cacheReadTokens,
cacheWrite: progress.cacheWriteTokens,
totalTokens: progress.usageTokens,
cost: {
input: progress.inputCost,
output: progress.outputCost,
cacheRead: progress.cacheReadCost,
cacheWrite: progress.cacheWriteCost,
total: progress.cost,
},
};
}
/** Charge one completed task exactly once, even across concurrent wait callers. */
function claimOutcomeUsage(outcome: WaitOutcome): NestedUsage | undefined {
if (!outcome.progress || (outcome.kind !== "done" && outcome.kind !== "exited")) return undefined;
const usage = usageFromProgress(outcome.progress);
if (!usage) return undefined;
const meta = readMeta(outcome.id);
if (meta?.kind !== "subagent") return undefined;
const offset = meta.promptOffset ?? 0;
if (meta.usageReportedOffset === offset) return undefined;
// `open(..., "wx")` is the cross-process compare-and-set. A resumed Pi
// session can briefly have overlapping extension processes; metadata alone
// would let both read the old value and charge the same nested usage.
const marker = path.join(metaDir(), `${outcome.id}.usage-${offset}.claimed`);
if (!claimFileOnce(marker, JSON.stringify({ pid: process.pid, claimedAt: Date.now() }))) {
return undefined;
}
meta.usageReportedOffset = offset;
writeMeta(outcome.id, meta); // compatibility/display hint; marker is authoritative
return usage;
}
function sumNestedUsage(values: Array): NestedUsage | undefined {
const present = values.filter((value): value is NestedUsage => Boolean(value));
if (present.length === 0) return undefined;
return present.reduce(
(total, value) => ({
input: total.input + value.input,
output: total.output + value.output,
cacheRead: total.cacheRead + value.cacheRead,
cacheWrite: total.cacheWrite + value.cacheWrite,
totalTokens: total.totalTokens + value.totalTokens,
cost: {
input: total.cost.input + value.cost.input,
output: total.cost.output + value.cost.output,
cacheRead: total.cost.cacheRead + value.cost.cacheRead,
cacheWrite: total.cost.cacheWrite + value.cost.cacheWrite,
total: total.cost.total + value.cost.total,
},
}),
{
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
);
}
// Wait for ONE subagent's current task. Completion = agent_settled without a
// parked babysit_run/process result (a parked run only means "waiting for
// process exit; pi will resume itself"). Parse appended bytes incrementally,
// then block on the next agent_settled via race-free byte offsets.
async function waitForTask(
id: string,
limitMs: number | null,
signal?: AbortSignal,
): Promise {
const t0 = Date.now();
for (;;) {
let observed: { progress: Progress; offset: number };
try {
observed = taskProgressOf(id);
} catch {
observed = { progress: emptyProgress(), offset: readMeta(id)?.promptOffset ?? 0 };
}
const { progress: prog, offset: cur } = observed;
const st = await statusOf(id);
const stats =
`turns=${prog.turns} calls=${prog.modelCalls} tools=${prog.toolCallCount}` +
(prog.tokens != null ? ` ctx=${prog.tokens}` : "") +
(prog.modelCalls > 0
? ` usage=${prog.usageTokens} (in=${prog.inputTokens} out=${prog.outputTokens} cache=${prog.cacheReadTokens}) $${prog.cost.toFixed(4)}`
: "");
if (prog.done) {
const completed = buildSubagentDoneResult(prog);
return {
id,
kind: "done",
ok: completed.ok,
text:
`Subagent ${id} finished its task (${stats}).\n` +
`${SUBAGENT_REUSE_HINT} Follow-up: babysit_send { id: "${id}" }, ` +
`or babysit_kill when done.\n\n${completed.body}`,
status: st,
progress: prog,
};
}
if (!st || st.state !== "running") {
// Never inject a raw RPC JSON tail into model context. Structured errors
// are parsed above; the complete stream remains available at the log path.
const diagnostic = buildSubagentExitDiagnostic(prog, logPath(id));
return {
id,
kind: "exited",
ok: false,
text:
`Subagent ${id} EXITED before completing the task ` +
`(state=${st?.state ?? "missing"}, exit_code=${st?.exit_code ?? "?"}, ${stats}).\n\n` +
diagnostic,
status: st,
progress: prog,
};
}
const timeoutOutcome = (): WaitOutcome => ({
id,
kind: "timeout",
ok: false,
text:
`⏱ wait timed out; subagent ${id} still ` +
`${prog.waitingOnProcess ? "waiting on a background process" : "working"} (${stats}).`,
status: st,
progress: prog,
});
// Still working — block until Pi declares the run fully settled. Unlike
// agent_end, this cannot fire before an automatic retry/compaction retry.
let expectTimeout = "0"; // indefinite
if (limitMs != null) {
const remaining = limitMs - (Date.now() - t0);
if (remaining <= 0) return timeoutOutcome();
expectTimeout = `${Math.ceil(remaining / 1000)}s`;
}
const e = await bs(
["expect", "-s", id, "--since", String(cur), "--timeout", expectTimeout, '"type":"agent_settled"'],
{ signal },
);
if (signal?.aborted || e.code === 130) {
return {
id,
kind: "interrupted",
ok: false,
text: `wait for ${id} was interrupted.`,
status: st,
progress: prog,
};
}
if (e.code === 124) return timeoutOutcome();
// e.code !== 0 (session likely exited mid-wait) falls through to the
// next loop iteration, where the exited branch reports the cause.
if (e.code !== 0) {
await new Promise((res) => setTimeout(res, 500));
}
}
}
// Mark a session as already reported so completion pollers do not send a
// duplicate message for something the agent just observed.
function suppressNotify(id: string, reason: "observed" | "kill" = "observed"): void {
const meta = readMeta(id);
if (!meta) return;
if (meta.kind === "process") {
meta.notified = true;
if (reason === "kill") meta.killNotificationSuppressed = true;
delete meta.notificationPaused;
} else {
meta.subagentCollectedOffset = meta.promptOffset ?? 0;
}
writeMeta(id, meta);
}
function collectSubagentOutcome(outcome: WaitOutcome): void {
if (outcome.kind !== "done" && outcome.kind !== "exited") return;
const meta = readMeta(outcome.id);
if (!meta || meta.kind !== "subagent") return;
meta.subagentCollectedOffset = meta.promptOffset ?? 0;
writeMeta(outcome.id, meta);
}
export interface WaitReservationState {
notified?: boolean;
killNotificationSuppressed?: boolean;
waitReservations?: number;
waitCompletionClaimed?: boolean;
}
export function canRestoreNotificationAfterWait(meta: WaitReservationState): boolean {
return (
meta.notified === true &&
meta.killNotificationSuppressed !== true &&
meta.waitCompletionClaimed !== true &&
(meta.waitReservations ?? 0) === 0
);
}
export function transitionWaitReservation(
state: WaitReservationState,
action: "reserve" | "abandon" | "claim",
): WaitReservationState {
const next = { ...state };
if (action === "reserve") {
next.waitReservations = (next.waitReservations ?? 0) + 1;
next.notified = true;
return next;
}
next.waitReservations = Math.max(0, (next.waitReservations ?? 0) - 1);
if (action === "claim") next.waitCompletionClaimed = true;
if (action === "claim" || next.waitCompletionClaimed) {
next.notified = true;
} else if (canRestoreNotificationAfterWait(next)) {
next.notified = false;
}
return next;
}
function updateWaitReservation(id: string, action: "reserve" | "abandon" | "claim"): void {
const meta = readMeta(id);
if (!meta || meta.kind !== "process") return;
writeMeta(id, { ...meta, ...transitionWaitReservation(meta, action) });
}
function pauseNotify(id: string): void {
const meta = readMeta(id);
if (meta && meta.kind === "process" && !meta.notificationPaused) {
meta.notificationPaused = true;
writeMeta(id, meta);
}
}
function resumeNotify(id: string): void {
const meta = readMeta(id);
if (meta && meta.kind === "process" && meta.notificationPaused) {
delete meta.notificationPaused;
writeMeta(id, meta);
}
}
// Wait for a PROCESS session: either until a regex appears in its output
// (`expect` — e.g. "server listening") or until the process exits.
async function waitForExit(
id: string,
limitMs: number | null,
signal?: AbortSignal,
expectPattern?: string,
outputSelection?: ProcessOutputSelection,
): Promise {
const t = limitMs != null ? `${Math.ceil(limitMs / 1000)}s` : "0";
if (expectPattern) {
const e = await bs(["expect", "-s", id, "--timeout", t, expectPattern], { signal });
if (signal?.aborted) {
return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
}
if (e.code === 130) {
const interruptedStatus = await statusOf(id);
if (interruptedStatus?.state === "running") {
return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
}
}
if (e.code === 0) {
return {
id,
kind: "done",
ok: true,
text: `Pattern /${expectPattern}/ matched in ${id} output (process still running).\nLog: ${logPath(id)}`,
};
}
const st0 = await statusOf(id);
if (e.code === 124 && st0?.state === "running") {
return {
id,
kind: "timeout",
ok: false,
text: `⏱ wait timed out; /${expectPattern}/ not seen in ${id} output yet (still running).`,
status: st0,
};
}
// fall through: session exited before the pattern appeared
} else {
// An explicit wait owns completion delivery. Reference-count the claim so
// one concurrent wait timing out cannot re-enable notifications underneath
// another wait that is still pending.
updateWaitReservation(id, "reserve");
let w: Awaited>;
let attempt = 0;
for (;;) {
w = await bs(["wait", "-s", id, "--timeout", t], { signal });
if (signal?.aborted) {
updateWaitReservation(id, "abandon");
return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
}
if (w.code === 0 || w.code === 124 || w.code === 130) break;
// A freshly spawned session can be visible in `list` before the backend's
// wait endpoint is ready, especially when sibling foreground tools start
// concurrently. Retry that transient startup race instead of reporting the
// still-running child as "exited with code ?".
const retryStatus = await statusOf(id);
if (retryStatus?.state !== "running" || attempt++ >= 3) break;
await new Promise((resolve) => setTimeout(resolve, 50 * attempt));
}
if (w.code === 130) {
const interruptedStatus = await statusOf(id);
if (interruptedStatus?.state === "running") {
updateWaitReservation(id, "abandon");
return { id, kind: "interrupted", ok: false, text: `wait for ${id} was interrupted.` };
}
}
if (w.code === 124) {
// 124 is ambiguous (timeout vs child exiting 124) — disambiguate.
const st0 = await statusOf(id);
if (st0?.state === "running") {
updateWaitReservation(id, "abandon");
return {
id,
kind: "timeout",
ok: false,
text: `⏱ wait timed out; process ${id} is still running.`,
status: st0,
};
}
}
}
const statusLookup = await lookupStatus(id);
if (statusLookup.error) {
// The registry could not be read, so do not convert a transient backend
// failure into a missing session or permanently claim its notification.
if (!expectPattern) updateWaitReservation(id, "abandon");
return {
id,
kind: "interrupted",
ok: false,
text: `Could not verify ${id} after waiting: ${statusLookup.error}`,
};
}
const st = statusLookup.session;
if (!st) {
if (!expectPattern) updateWaitReservation(id, "claim");
return { id, kind: "exited", ok: false, text: `No such session: ${id}` };
}
if (st.state === "running") {
if (!expectPattern) updateWaitReservation(id, "abandon");
return {
id,
kind: "interrupted",
ok: false,
text:
`The wait backend returned before process ${id} exited; the process is still running. ` +
`Use babysit_wait to continue waiting.\nLog: ${logPath(id)}`,
status: st,
};
}
if (expectPattern) suppressNotify(id);
else updateWaitReservation(id, "claim"); // the agent sees the exit here; don't notify again
const meta = readMeta(id);
const workerDead = st.state === "dead" && st.exit_code == null;
const ok = st.exit_code === 0;
const output = await selectedProcessOutput(id, st, outputSelection, signal);
return {
id,
kind: "exited",
ok,
text:
`Process ${id}${meta?.command ? ` (${summarizeNotificationCommand(meta.command)})` : ""} ` +
(workerDead
? "worker-dead: the babysit supervisor disappeared without an exit status"
: ok ? "completed successfully" : `exited with code ${st.exit_code ?? "?"}`) +
`${expectPattern ? ` before /${expectPattern}/ appeared` : ""}.` +
(workerDead
? " The supervisor disappeared without recording an exit; possible causes include host process cleanup, endpoint security, or a supervisor crash. The command may have started, so retry only if it is safe and idempotent."
: "") +
`\nLog: ${logPath(id)}` + output,
status: st,
};
}
const waitFor = (
id: string,
limitMs: number | null,
signal?: AbortSignal,
expectPattern?: string,
): Promise =>
kindOf(id) === "subagent"
? waitForTask(id, limitMs, signal)
: waitForExit(id, limitMs, signal, expectPattern);
// ---------------------------------------------------------------------------
// direct bash policy
// ---------------------------------------------------------------------------
// Heuristic (no shell AST): catch `... &` backgrounding (not `&&`), nohup,
// setsid, and disown — those should go through babysit_run instead.
function backgroundsItself(command: string): boolean {
const stripped = command.replace(/#[^\n]*/g, "").trimEnd();
if (/(^|[^&])&\s*$/.test(stripped)) return true;
if (/(^|[;&|]\s*)(nohup|setsid)\s/.test(stripped)) return true;
if (/\bdisown\b/.test(stripped)) return true;
return false;
}
/** Emergency escape hatch only. All ordinary shell commands go through babysit_run. */
export function isAllowedDirectBash(_command: string): boolean {
return process.env.PI_BABYSIT_ALLOW_BASH === "1";
}
export function activeToolsWithoutDirectBash(activeTools: string[], allowDirectBash: boolean): string[] {
return allowDirectBash ? activeTools : activeTools.filter((name) => name !== "bash");
}
export function automaticNotificationGroup(entry: unknown): string | undefined {
const candidate = entry as {
id?: unknown;
type?: unknown;
message?: { role?: unknown; content?: unknown };
};
if (
candidate?.type !== "message" ||
candidate.message?.role !== "assistant" ||
typeof candidate.id !== "string" ||
!Array.isArray(candidate.message.content)
) {
return undefined;
}
const group = `turn-${candidate.id}`;
const runs = candidate.message.content.filter((part) => {
if (!part || typeof part !== "object") return false;
const call = part as {
type?: unknown;
name?: unknown;
arguments?: Record;
};
if (call.type !== "toolCall" || call.name !== "babysit_run") return false;
const args = call.arguments ?? {};
const existing = typeof args.notificationGroup === "string"
? args.notificationGroup.trim()
: "";
return (
typeof args.command === "string" &&
args.command.length > 0 &&
args.profile !== "subagent" &&
args.foreground !== true &&
(existing === "" || existing === group)
);
});
return runs.length >= 2 ? group : undefined;
}
export function prepareBabysitRunArguments(args: unknown): unknown {
if (!args || typeof args !== "object") return args;
const input = args as Record;
if (
input.profile === "subagent" &&
input.continueAfterStart === true &&
input.foreground !== true
) {
const prepared = { ...input };
delete prepared.continueAfterStart;
return prepared;
}
return args;
}
export function resolveSubagentSendMode(
requested: "auto" | "steer" | "task",
streaming?: boolean,
currentTaskDone?: boolean,
): { mode: "steer" | "task" } | { error: "busy" | "unknown" | "unsettled" } {
if (requested === "steer") return { mode: "steer" };
if (requested === "auto") {
return { mode: streaming === false && currentTaskDone === true ? "task" : "steer" };
}
if (streaming === true) return { error: "busy" };
if (streaming === undefined || currentTaskDone === undefined) return { error: "unknown" };
if (!currentTaskDone) return { error: "unsettled" };
return { mode: "task" };
}
// ---------------------------------------------------------------------------
// extension
// ---------------------------------------------------------------------------
export default function (pi: ExtensionAPI) {
let pollTimer: ReturnType | undefined;
let pollNeeded = true;
let automaticGcRan = false;
let rootLeasePath: string | undefined;
const declaredToolErrors = new Set();
const sessionRpcTails = new Map>();
async function withSessionRpcLock(id: string, operation: () => Promise): Promise {
const previous = sessionRpcTails.get(id) ?? Promise.resolve();
let release!: () => void;
const gate = new Promise((resolve) => {
release = resolve;
});
const tail = previous.then(() => gate);
sessionRpcTails.set(id, tail);
await previous;
try {
return await operation();
} finally {
release();
if (sessionRpcTails.get(id) === tail) sessionRpcTails.delete(id);
}
}
// Pi only persists custom-tool failures when they are thrown or patched by a
// tool_result hook; an `isError` property returned from execute() is ignored.
// Keep the structured result (status, log path, diagnostics) while promoting
// its declared error bit at the supported hook boundary.
const registerTool = (
tool: ToolDefinition,
): void => {
const execute = tool.execute;
pi.registerTool({
...tool,
async execute(toolCallId, params, signal, onUpdate, ctx) {
const result = await execute(toolCallId, params, signal, onUpdate, ctx);
if ((result as typeof result & { isError?: boolean }).isError === true) {
declaredToolErrors.add(toolCallId);
}
return result;
},
});
};
pi.on("tool_result", (event) => {
if (declaredToolErrors.delete(event.toolCallId)) return { isError: true };
});
async function enforceSubagentBudgets(sessions: BsSession[]): Promise {
// Independent workers must not serialize 3-second RPC probes and delay
// unrelated completion notifications. Per-session RPC locks still preserve
// ordering within each worker.
await Promise.all(
sessions
.filter((session) => session.state === "running")
.map((session) =>
withSessionRpcLock(session.id, async () => {
const meta = readMeta(session.id);
if (meta?.kind !== "subagent" || !meta.budget || meta.budgetKilled) return;
let progress: Progress;
try {
progress = taskProgressOf(session.id).progress;
} catch {
return;
}
if (progress.done) return;
const now = Date.now();
const hardReason = subagentBudgetViolation(progress, meta.budget);
const softReason = subagentBudgetSoftViolation(progress, meta.budget);
if (hardReason) {
if (meta.budgetExceededAt == null) {
// The hard grace begins when the violation is observed, even if a
// wedged worker never accepts steering. This makes the cap enforceable.
meta.budgetExceededAt = now;
meta.budgetReason = hardReason;
writeMeta(session.id, meta);
const latestStatus = await statusOf(session.id);
if (latestStatus?.state !== "running") return;
const sent = await sendRpc(session.id, {
type: "steer",
message: `Hard budget reached (${hardReason}). Stop calling tools and return your best answer now.`,
});
if (!("error" in sent)) await rpcResponse(session.id, sent.offset, "steer", "3s");
return;
}
if (now - meta.budgetExceededAt < SUBAGENT_BUDGET_GRACE_MS) return;
const latestStatus = await statusOf(session.id);
if (latestStatus?.state !== "running") return;
const killed = await bs(["kill", "-s", session.id, "--json"]);
if (killed.code !== 0) return;
const terminal = await awaitConfirmedTermination(session.id);
const current = readMeta(session.id);
if (
terminal &&
isConfirmedTerminalState(terminal.state) &&
current?.kind === "subagent" &&
current.promptOffset === meta.promptOffset &&
current.budgetExceededAt === meta.budgetExceededAt
) {
current.budgetKilled = true;
current.budgetReason = current.budgetReason ?? hardReason;
writeMeta(session.id, current);
}
return;
}
if (!softReason || meta.budgetWarnedAt != null) return;
const latestStatus = await statusOf(session.id);
if (latestStatus?.state !== "running") return;
const sent = await sendRpc(session.id, {
type: "steer",
message: `Budget is approaching its limit (${softReason}). Wrap up now and preserve your best findings.`,
});
if ("error" in sent) return;
const accepted = await rpcResponse(session.id, sent.offset, "steer", "3s");
if (!accepted.ok) return;
const current = readMeta(session.id);
if (
current?.kind === "subagent" &&
current.promptOffset === meta.promptOffset &&
current.budgetWarnedAt == null
) {
current.budgetWarnedAt = now;
current.budgetWarningReason = softReason;
writeMeta(session.id, current);
}
}),
),
);
}
// Exit notifications for kind=process sessions: the poller detects
// running→exited transitions and injects ONE message (triggerTurn) for all
// processes that became deliverable in the same poll. This resumes an agent
// that ended its turn after babysit_run without spending one turn per exit.
// Kills via babysit_kill and exits already reported by babysit_wait are
// suppressed via meta.notified.
async function notifyEndedProcesses(
ctx: ExtensionContext,
snapshot?: BsSession[],
): Promise {
// Never steer a completion into an active agent turn. In particular, this
// lets an immediately-following babysit_wait reserve the completion first,
// instead of racing the poller and receiving both wait + auto notification.
if (shouldDeferCompletionNotification(ctx.isIdle())) return;
const sessions = snapshot ?? (await listSessions()).sessions;
const ready: Array<{ session: BsSession; meta: Meta }> = [];
for (const session of sessions) {
if (session.state === "running") continue;
const meta = readMeta(session.id);
if (!shouldDeliverProcessCompletion(meta)) continue;
// A notification group is delivered only after all currently known
// members have stopped, even when their exit times span many polls.
if (!isNotificationGroupReady(meta, sessions, readMeta)) continue;
// Delay delivery by one poll interval. This gives an agent that chose
// babysit_wait immediately after babysit_run enough time to claim the
// completion and suppress the otherwise duplicate automatic message.
if (!meta.completionObservedAt) {
meta.completionObservedAt = Date.now();
writeMeta(session.id, meta);
continue;
}
if (Date.now() - meta.completionObservedAt < POLL_MS) continue;
ready.push({ session, meta });
}
const prepared: ProcessCompletionNotice[] = [];
for (const { session, meta } of ready) {
const ok = session.exit_code === 0;
const status: CompletionStatus = ok
? "success"
: session.state === "dead" || session.exit_code == null
? "terminated"
: "failed";
const output = await inlineOutput(session.id, session, NOTIFY_OUTPUT_MAX_BYTES);
const runtime = meta.startedAt
? `${Math.round(((meta.completionObservedAt ?? Date.now()) - meta.startedAt) / 1000)}s`
: "?";
const summary = ok
? `Process "${session.id}" completed successfully after ${runtime}.`
: session.state === "dead" || session.exit_code == null
? `Process "${session.id}" was terminated after ${runtime}.`
: `Process "${session.id}" exited with code ${session.exit_code} after ${runtime}.`;
prepared.push({
id: session.id,
exitCode: session.exit_code,
success: ok,
status,
runtime,
summary,
command: meta.command,
logPath: logPath(session.id),
output,
});
}
if (prepared.length === 0) return;
// Output loading above is asynchronous. Refresh sessions and metadata
// immediately before the single send so concurrent wait/kill calls and newly
// started notification-group members are honored.
const refreshed = await listSessions();
if (refreshed.error) return;
const finalSessions = refreshed.sessions;
const metadataById = new Map();
const notices = prepared.flatMap((notice) => {
const current = readMeta(notice.id);
if (!shouldDeliverProcessCompletion(current)) return [];
if (!isNotificationGroupReady(current, finalSessions, readMeta)) return [];
metadataById.set(notice.id, current);
return [{ ...notice, command: current.command }];
});
// Output collection above yields to the event loop. Re-check idleness so a
// newly-started agent turn cannot receive a duplicate completion mid-turn.
if (shouldDeferCompletionNotification(ctx.isIdle())) return;
deliverProcessCompletionMessage(
notices,
(message, options) => pi.sendMessage(message, options),
(notice) => {
const meta = metadataById.get(notice.id);
if (!meta) return;
meta.notified = true;
delete meta.notificationPaused;
writeMeta(notice.id, meta);
},
);
}
async function notifySettledSubagents(
ctx: ExtensionContext,
snapshot?: BsSession[],
): Promise {
if (shouldDeferCompletionNotification(ctx.isIdle())) return;
const sessions = snapshot ?? (await listSessions()).sessions;
const ready: Array<{ id: string; offset: number; state: string; summary: string }> = [];
for (const session of sessions) {
const meta = readMeta(session.id);
if (!shouldDeliverSubagentCompletion(meta)) continue;
let progress: Progress;
try {
progress = taskProgressOf(session.id).progress;
} catch {
progress = emptyProgress();
}
if (session.state === "running" && !progress.done) continue;
const offset = meta.promptOffset ?? 0;
if (meta.subagentCompletionObservedOffset !== offset) {
meta.subagentCompletionObservedOffset = offset;
meta.subagentCompletionObservedAt = Date.now();
writeMeta(session.id, meta);
continue;
}
if (Date.now() - (meta.subagentCompletionObservedAt ?? 0) < POLL_MS) continue;
const summary = progress.done
? `task settled; ${progress.turns} turns, ${progress.toolCallCount} tools, $${progress.cost.toFixed(4)}`
: `worker ${session.state} with exit code ${session.exit_code ?? "?"}; partial usage $${progress.cost.toFixed(4)}`;
ready.push({ id: session.id, offset, state: session.state, summary });
}
if (ready.length === 0 || shouldDeferCompletionNotification(ctx.isIdle())) return;
const deliverable = ready.filter(({ id, offset }) => {
const meta = readMeta(id);
return shouldDeliverSubagentCompletion(meta) && (meta.promptOffset ?? 0) === offset;
});
if (deliverable.length === 0) return;
pi.sendMessage(
{
customType: "pi-babysit-subagent-ready",
content:
`${deliverable.length === 1 ? "A background subagent is" : `${deliverable.length} background subagents are`} ready to collect:\n` +
deliverable.map(({ id, summary }) => `- ${id}: ${summary}`).join("\n") +
"\nCall babysit_wait now to retrieve the answer and charge nested usage before finishing the parent task.",
display: true,
details: { subagents: deliverable.map(({ id, state }) => ({ id, state })) },
},
{ triggerTurn: true, deliverAs: "steer" },
);
for (const { id, offset } of deliverable) {
const meta = readMeta(id);
if (!meta || meta.kind !== "subagent" || (meta.promptOffset ?? 0) !== offset) continue;
meta.subagentNotifiedOffset = offset;
writeMeta(id, meta);
}
}
const refreshWidget = async (ctx: ExtensionContext, snapshot?: BsSession[]) => {
if (!ctx.hasUI) return;
const active = (snapshot ?? (await listSessions()).sessions).filter(
(session) => session.state === "running",
);
const subs = active.filter((session) => kindOf(session.id) === "subagent");
const procs = active.length - subs.length;
const progressById = new Map();
for (const subagent of subs) {
try {
progressById.set(subagent.id, taskProgressOf(subagent.id).progress);
} catch {
progressById.set(subagent.id, emptyProgress());
}
}
const idle = subs.filter((session) => progressById.get(session.id)?.done).length;
const theme = ctx.ui.theme;
const lines = renderWidgetLines(procs, subs.length - idle, idle, theme);
const tails = await Promise.all(
active.map((session) => {
const isSubagent = kindOf(session.id) === "subagent";
return widgetTail(
session.id,
isSubagent,
isSubagent ? progressById.get(session.id) : undefined,
);
}),
);
active.forEach((session, index) => {
const isSubagent = kindOf(session.id) === "subagent";
const state: WidgetSessionState = isSubagent && progressById.get(session.id)?.done
? "idle"
: "running";
const elapsed = elapsedOf(session.id);
const header = renderWidgetSessionHeader(
session.id,
isSubagent ? "agent" : "process",
state,
elapsed,
theme,
);
if (tails[index].length === 1) {
lines.push(`${header} ${tails[index][0]}`);
} else {
lines.push(header);
for (const tail of tails[index]) lines.push(` ${tail}`);
}
});
ctx.ui.setWidget("pi-babysit", lines, { placement: "belowEditor" });
};
type DisplayStatus = "started" | "running" | "idle" | "success" | "failed" | "terminated";
const renderStatus = (status: DisplayStatus, theme: Theme, prefix?: string): string => {
const labels: Record<
DisplayStatus,
{ icon: string; text: string; color: "accent" | "warning" | "success" | "error" }
> = {
started: { icon: "", text: "STARTED", color: "accent" },
running: { icon: "", text: "RUNNING", color: "accent" },
idle: { icon: "", text: "IDLE", color: "warning" },
success: { icon: "", text: "SUCCESS", color: "success" },
failed: { icon: "", text: "FAILED", color: "error" },
terminated: { icon: "", text: "TERMINATED", color: "error" },
};
const label = labels[status];
const text = prefix ? `${prefix} ${label.text}` : label.text;
const decorated = label.icon ? `${label.icon} ${text}` : text;
return theme.fg(label.color, theme.bold(decorated));
};
const outcomeStatus = (outcome: WaitOutcome): DisplayStatus =>
outcome.ok
? "success"
: outcome.status &&
(outcome.status.state === "dead" || outcome.status.exit_code == null)
? "terminated"
: "failed";
// Render snapshots and subagent answers INLINE in the transcript as formatted
// markdown, with a semantic status label that remains readable on any theme.
pi.registerMessageRenderer("pi-babysit-result", (message, _opts, theme) => {
const d = (message.details ?? {}) as {
title?: string;
body?: string;
status?: DisplayStatus;
};
const body =
d.body ?? (typeof message.content === "string" ? message.content : "");
const box = new Box(1, 0, (t) => theme.bg("toolSuccessBg", t));
if (d.status) box.addChild(new Text(renderStatus(d.status, theme), 0, 0));
if (d.title) box.addChild(new Text(theme.fg("accent", d.title), 0, 0));
box.addChild(new Markdown(body, 0, 0, getMarkdownTheme()));
return box;
});
// Process-end notification rendering with a colored lifecycle label. Keep the
// box background subtle: coloring a potentially large log excerpt is noisy.
pi.registerMessageRenderer("pi-babysit-process-end", (message, _opts, theme) => {
const content = typeof message.content === "string" ? message.content : "";
const d = (message.details ?? {}) as {
status?: DisplayStatus;
success?: boolean;
exitCode?: number | null;
command?: string;
count?: number;
};
const status =
d.status ?? (d.success ? "success" : d.exitCode == null ? "terminated" : "failed");
const payload = d.command
? ` ${summarizeNotificationCommand(d.command)}`
: d.count && d.count > 1
? ` ×${d.count}`
: "";
const header = theme.fg("warning", theme.bold(`babysit_run COMMAND${payload}`));
const box = new Box(1, 1, (t) => theme.bg("toolSuccessBg", t));
box.addChild(new Text(header, 0, 0));
box.addChild(new Text(renderStatus(status, theme), 0, 0));
box.addChild(new Text(theme.fg("toolOutput", content), 0, 0));
return box;
});
let polling = false;
pi.on("session_start", async (_event, ctx) => {
// Do not expose the built-in bash tool only to reject it after the model has
// already paid for a failed tool turn. The tool_call hook remains a fallback
// if another extension/preset re-enables bash later in the session.
const activeTools = pi.getActiveTools();
const supervisedTools = activeToolsWithoutDirectBash(
activeTools,
isAllowedDirectBash(""),
);
if (supervisedTools.length !== activeTools.length) pi.setActiveTools(supervisedTools);
// Session-local registry: scope the babysit root to this pi session so
// other sessions' processes/subagents are invisible here. Resuming a
// session keeps the same id, so its sessions come back with it.
releaseRootLease(rootLeasePath);
try {
ROOT = path.join(ROOT_BASE, ctx.sessionManager.getSessionId());
} catch {
ROOT = ROOT_BASE;
}
rootLeasePath = acquireRootLease(ROOT) ?? undefined;
if (!rootLeasePath) {
const desiredRoot = ROOT;
ROOT = `${desiredRoot}-active-${process.pid}-${Date.now()}`;
rootLeasePath = acquireRootLease(ROOT) ?? undefined;
if (ctx.hasUI) {
ctx.ui.notify(
`Babysit GC was busy for ${desiredRoot}; using an isolated root for this run.`,
"warning",
);
}
}
taskProgressCache.clear();
searchLogCache.clear();
pollNeeded = true;
const retentionDays = Number(process.env.PI_BABYSIT_RETENTION_DAYS ?? "3");
if (
!automaticGcRan &&
Number.isFinite(retentionDays) &&
retentionDays > 0 &&
automaticGcDue()
) {
automaticGcRan = true;
const gc = gcBabysitRoots({
rootBase: ROOT_BASE,
currentRoot: ROOT,
olderThanMs: retentionDays * 86_400_000,
dryRun: false,
});
markAutomaticGc();
if (gc.deleted.length > 0 && ctx.hasUI) {
ctx.ui.notify(
`pi-babysit GC removed ${gc.deleted.length} roots (${gc.bytes} bytes).`,
"info",
);
}
}
// Warn early if the binary is missing so the user isn't surprised only when
// a tool later fails. Tools/commands still enforce it via requireBabysit.
if (ctx.hasUI && !(await babysitAvailable())) {
ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "warning");
}
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(() => {
// Once a namespace has no live workers or pending notifications, avoid
// spawning `babysit list` forever while the UI is idle. A successful
// babysit_run re-arms polling below.
if (polling || !pollNeeded) return;
polling = true;
void (async () => {
const listed = await listSessions();
if (listed.error) {
pollNeeded = shouldKeepPollingAfterList(listed, readMeta);
throw new Error(listed.error);
}
const snapshot = listed.sessions;
await enforceSubagentBudgets(snapshot);
await Promise.all([
notifyEndedProcesses(ctx, snapshot),
notifySettledSubagents(ctx, snapshot),
refreshWidget(ctx, snapshot),
]);
pruneTerminalSessionCache(taskProgressCache, snapshot);
pollNeeded = shouldKeepPolling(snapshot, readMeta);
})()
.catch(() => {
// Keep polling armed after a transient CLI/filesystem error.
pollNeeded = true;
})
.finally(() => {
polling = false;
});
}, POLL_MS);
});
pi.on("session_shutdown", async (event) => {
if (pollTimer) clearInterval(pollTimer);
pollTimer = undefined;
// Pi also emits session_shutdown while hot-reloading and switching sessions.
// Preserve detached work for those lifecycle transitions; only a real Pi
// quit owns and terminates every worker in the current session namespace.
if (shouldTerminateSessionsOnShutdown(event?.reason)) {
await terminateRunningSessions();
}
releaseRootLease(rootLeasePath);
rootLeasePath = undefined;
});
pi.on("tool_call", async (event, ctx) => {
if (event.toolName === "babysit_run") {
const input = event.input as {
command?: unknown;
profile?: unknown;
foreground?: unknown;
notificationGroup?: unknown;
};
if (
typeof input.command === "string" &&
input.profile !== "subagent" &&
input.foreground !== true &&
(typeof input.notificationGroup !== "string" || input.notificationGroup.trim() === "")
) {
const group = automaticNotificationGroup(ctx.sessionManager.getLeafEntry());
if (group) input.notificationGroup = group;
}
return;
}
if (event.toolName !== "bash") return;
const command = String((event.input as { command?: unknown }).command ?? "");
if (backgroundsItself(command)) {
return {
block: true,
reason:
`This bash command tries to run in the background. Use babysit_run instead, e.g. ` +
`babysit_run({ name: "background-process", command: ${JSON.stringify(command.replace(/\s*&\s*$/, ""))} })`,
};
}
if (isAllowedDirectBash(command)) return;
return {
block: true,
reason:
"Use babysit_run for shell commands so output is supervised and captured outside model context. " +
"Inspect an existing session log with babysit_check { id, lines, pattern? }. " +
`Retry as babysit_run({ command: ${JSON.stringify(command)} }).`,
};
});
// ----- babysit_run --------------------------------------------------------
registerTool({
name: "babysit_run",
label: "Babysit: run",
description:
"Run a supervised shell command, or start a reusable pi subagent with `profile: \"subagent\"`. " +
"Use `foreground` for results needed now; otherwise long commands notify on exit. Full logs stay on disk. " +
"`returnPattern`/`returnLines` bound foreground output. Sessions support check, wait, send, and kill.",
promptSnippet: "Run supervised commands or bounded pi subagents with context-safe logs",
promptGuidelines: [
"Use babysit_run for shell commands and give meaningful sessions a stable name; bundle tiny related read-only observations into one command.",
"Use babysit_run foreground mode for one process or subagent whose result is needed now; never issue sibling foreground runs in parallel. For parallel process checks, start background runs with continueAfterStart and collect them with one multi-session babysit_wait.",
"Use returnPattern/returnLines for noisy commands. During edit/fix loops run targeted checks first and one full validation suite at the end instead of repeating every full gate.",
"After a background process starts, stop the turn for its automatic notification; never poll or sleep. Use continueAfterStart only for specific non-polling process work.",
"Inspect large logs with a narrow babysit_check pattern and maxBytes rather than broad tails.",
"Use retryOnWorkerDeath only once and only for idempotent commands; retries may duplicate side effects.",
"Delegate independent work with bounded babysit_run subagents. Prefer foreground for one result needed now; every background subagent must be collected with babysit_wait before the parent task finishes. Size budgets above the worker's initial context and expected tool count; maxUsageTokens counts cumulative input/cache tokens and can overshoot by one in-flight model call.",
"Omit babysit_run.agent unless you know a named agent definition exists in the selected agentScope.",
"Subagent recursion defaults to depth 1; only a top-level caller may explicitly raise maxDepth.",
],
prepareArguments(args) {
return prepareBabysitRunArguments(args) as never;
},
parameters: Type.Object({
command: Type.Optional(
Type.String({
description: "Shell command to run (process mode). Mutually exclusive with profile/task.",
}),
),
name: Type.Optional(
Type.String({
description: "Friendly stable name for a process or subagent (becomes the session id), e.g. 'cargo-build' or 'review-api'.",
}),
),
profile: Type.Optional(
StringEnum(["subagent"] as const, {
description: "Session profile. 'subagent' spawns a pi worker; requires `task`.",
}),
),
task: Type.Optional(
Type.String({ description: "The task for the subagent to perform (subagent profile)." }),
),
agent: Type.Optional(
Type.String({ description: "Named agent definition (see ~/.pi/agent/agents). Subagent profile only." }),
),
model: Type.Optional(Type.String({ description: "Model override for the subagent, e.g. 'sonnet'." })),
tools: Type.Optional(
Type.Array(Type.String(), { description: "Tool allowlist for the subagent." }),
),
maxDepth: Type.Optional(
Type.Integer({
minimum: 1,
description:
"Maximum subagent nesting depth. Top-level subagent mode only; default 1 prevents workers from spawning workers. Nested workers inherit this limit and cannot override it.",
}),
),
maxCost: Type.Optional(
Type.Number({
exclusiveMinimum: 0,
description: "Subagent only: steer it to wrap up at this cumulative reported cost, then kill after the budget grace period.",
}),
),
maxTurns: Type.Optional(
Type.Integer({
minimum: 1,
description:
"Subagent only: observed turn threshold for steering it to wrap up. In-flight work can overshoot before the poller intervenes.",
}),
),
maxToolCalls: Type.Optional(
Type.Integer({
minimum: 1,
description:
"Subagent only: observed tool-call threshold for steering it to wrap up. A parallel in-flight tool batch can overshoot.",
}),
),
maxUsageTokens: Type.Optional(
Type.Integer({
minimum: 1,
description:
"Subagent only: observed cumulative reported totalTokens threshold for steering it to wrap up.",
}),
),
agentScope: Type.Optional(
StringEnum(["user", "project", "both"] as const, {
description: "Where to discover named agents. Default 'user'.",
}),
),
timeout: Type.Optional(
Type.String({
description:
"Absolute auto-kill after this long (e.g. 30m). Default: none for processes (dev servers may run forever), 15m for subagents. 'none' disables.",
}),
),
idleTimeout: Type.Optional(
Type.String({
description:
"Auto-kill after NO output for this long (e.g. 90s). Off by default — silence is often legitimate (a busy subagent, a quiet server). Set it only for commands that stream steadily.",
}),
),
pty: Type.Optional(
Type.Boolean({
description:
"Process mode: run in a PTY (default true; enables interactive input/screen). false = plain pipes for cleaner line-oriented logs.",
}),
),
foreground: Type.Optional(
Type.Boolean({
description: "Process or subagent: wait for completion and return the result in this tool call.",
}),
),
returnPattern: Type.Optional(
Type.String({ description: "Foreground/quick process: return only latest regex matches." }),
),
returnLines: Type.Optional(
Type.Integer({ minimum: 1, maximum: 200, description: "Lines retained by returnPattern/tail (default 30)." }),
),
maxBytes: Type.Optional(
Type.Integer({ minimum: 1_000, maximum: ANSWER_MAX_BYTES, description: "Returned process-output cap (default 8 KB)." }),
),
notificationGroup: Type.Optional(
Type.String({
description:
"Process mode: defer automatic completion until every running process with this group has stopped, then send one batched notification. Sibling background runs are auto-grouped when omitted.",
}),
),
continueAfterStart: Type.Optional(
Type.Boolean({
description:
"Process mode. Default false: starting a process ENDS the current turn (you are resumed by the exit notification). Set true only for immediate, specific, non-polling process work. In subagent mode true is accepted as a compatibility alias for the default background behavior.",
}),
),
retryOnWorkerDeath: Type.Optional(
Type.Boolean({
description:
"Process mode only. Retry once if the babysit worker is killed externally during startup. Use only for safe, idempotent commands because the first attempt may have produced side effects.",
}),
),
}),
async execute(_id, params, _signal, _onUpdate, ctx) {
await requireBabysit();
const isSubagent = params.profile === "subagent";
if (isSubagent && !params.task) {
return {
content: [{ type: "text", text: "profile 'subagent' requires `task`." }],
isError: true,
details: {},
};
}
if (!isSubagent && !params.command) {
return {
content: [{ type: "text", text: "Provide `command` (process) or profile 'subagent' + `task`." }],
isError: true,
details: {},
};
}
if (params.command && isSubagent) {
return {
content: [{ type: "text", text: "`command` and profile 'subagent' are mutually exclusive." }],
isError: true,
details: {},
};
}
const hasBudget =
params.maxCost != null ||
params.maxTurns != null ||
params.maxToolCalls != null ||
params.maxUsageTokens != null;
if (!isSubagent && hasBudget) {
return {
content: [{ type: "text", text: "Subagent budget parameters require profile 'subagent'." }],
isError: true,
details: {},
};
}
if (isSubagent && params.notificationGroup) {
return {
content: [{ type: "text", text: "`notificationGroup` is available only in process mode." }],
isError: true,
details: {},
};
}
if (isSubagent && (params.returnPattern || params.returnLines != null || params.maxBytes != null)) {
return {
content: [{ type: "text", text: "`returnPattern`, `returnLines`, and `maxBytes` are process-output options." }],
isError: true,
details: {},
};
}
if (params.foreground && params.continueAfterStart) {
return {
content: [{ type: "text", text: "`foreground` and `continueAfterStart` are mutually exclusive." }],
isError: true,
details: {},
};
}
// Compute nesting only for subagent mode. Ordinary command processes remain
// available even when the hosting agent is at its subagent depth limit.
const nesting = isSubagent
? planSubagentSpawn(params.maxDepth)
: undefined;
if (nesting && !nesting.allowed) {
return {
content: [{ type: "text", text: nesting.error }],
isError: true,
details: {},
};
}
// --- process mode ---
if (!isSubagent) {
if (params.returnPattern) {
try {
new RegExp(params.returnPattern);
} catch (error) {
return {
content: [{ type: "text", text: `Invalid returnPattern: ${String(error)}` }],
isError: true,
details: {},
};
}
}
const outputSelection: ProcessOutputSelection | undefined =
params.returnPattern || params.returnLines != null || params.maxBytes != null
? { pattern: params.returnPattern, lines: params.returnLines, maxBytes: params.maxBytes }
: undefined;
const spawnOpts: ProcOpts = {
name: params.name,
command: params.command as string,
cwd: ctx.cwd,
timeout: params.timeout,
idleTimeout: params.idleTimeout,
pty: params.pty ?? true,
env: processSessionEnvironment(ctx, pi.getThinkingLevel()),
notificationGroup: params.notificationGroup?.trim() || undefined,
};
let res = await spawnProcess(spawnOpts);
if ("error" in res) {
return {
content: [{ type: "text", text: `Failed to start process: ${res.error}` }],
isError: true,
details: {},
};
}
pollNeeded = true;
await refreshWidget(ctx);
// One-shot / non-interactive mode has no event loop that can deliver an
// exit notification. `foreground: true` provides the same single-tool-call
// result in interactive mode, avoiding a separate babysit_wait model turn.
// The command remains supervised, logged, killable, and subject to its
// babysit timeout in either case.
if (!ctx.hasUI || params.foreground) {
// The babysit supervisor owns the absolute command timeout. Waiting with
// the same deadline here races its terminal-state write and can return a
// false "still running" result at the boundary, so wait for the
// supervisor's definitive exit instead.
let outcome = await waitForExit(res.id, null, _signal, undefined, outputSelection);
let retried = false;
if (params.retryOnWorkerDeath && outcome.status?.state === "dead" && outcome.status.exit_code == null) {
const retry = await spawnProcess(spawnOpts);
if (!("error" in retry)) {
res = retry;
retried = true;
outcome = await waitForExit(res.id, null, _signal, undefined, outputSelection);
}
}
if (ctx.hasUI) await refreshWidget(ctx);
return {
content: [{ type: "text", text: `${retried ? "Retried once after external worker death.\n" : ""}${outcome.text}` }],
isError: !outcome.ok,
details: {
id: res.id,
kind: "process",
command: params.command,
logPath: logPath(res.id),
retried,
status: outcomeStatus(outcome),
},
};
}
// Keep ordinary quick commands ergonomic. Give the process a short grace
// period; if it exits, return lifecycle metadata + log path immediately.
// A process still running after the grace follows the parked-turn /
// automatic-notification contract below.
await bs(["wait", "-s", res.id, "--timeout", QUICK_COMMAND_GRACE], { signal: _signal });
let quickStatus = await statusOf(res.id);
let retried = false;
if (params.retryOnWorkerDeath && quickStatus?.state === "dead" && quickStatus.exit_code == null) {
// This attempt is already represented by the retrying tool result; do
// not let the exit poller emit a second, stale completion message.
suppressNotify(res.id);
const retry = await spawnProcess(spawnOpts);
if (!("error" in retry)) {
res = retry;
retried = true;
await bs(["wait", "-s", res.id, "--timeout", QUICK_COMMAND_GRACE], { signal: _signal });
quickStatus = await statusOf(res.id);
}
}
if (quickStatus && quickStatus.state !== "running") {
const outcome = await waitForExit(res.id, null, _signal, undefined, outputSelection);
await refreshWidget(ctx);
return {
content: [{ type: "text", text: `${retried ? "Retried once after external worker death.\n" : ""}${outcome.text}` }],
isError: !outcome.ok,
details: {
id: res.id,
kind: "process",
command: params.command,
logPath: logPath(res.id),
retried,
status: outcomeStatus(outcome),
},
};
}
const continueAfter = params.continueAfterStart === true;
const nextStep = continueAfter
? "Continue with specific non-polling work now; the exit notification will arrive on its own."
: "This turn will stop now so you can wait for the automatic process-end notification. Do not call babysit_check just to see whether it is still running.";
return {
content: [
{
type: "text",
text:
`${retried ? "Retried once after external worker death.\n" : ""}` +
`Process started (id: ${res.id}). ${NOTIFY_MARKER}\nLog: ${logPath(res.id)}\n${nextStep}\n` +
`Inspect: babysit_check { id: "${res.id}" } (screen: true for TUIs) · ` +
`Wait: babysit_wait { id: "${res.id}" } · Kill: babysit_kill { id: "${res.id}" }\n` +
`Human can watch/take over: /babysit`,
},
],
details: {
id: res.id,
kind: "process",
command: params.command,
logPath: logPath(res.id),
retried,
status: "started" satisfies DisplayStatus,
},
// Do not return `terminate: true` here. In RPC/subagent hosts that hint
// can shut down the hosting pi worker, whose process-tree cleanup then
// kills the otherwise detached babysit supervisor and closes its PTY
// (observed as an immediate `^D`). The prompt contract tells the model
// to stop after this result instead; the NOTIFY_MARKER still identifies
// a parked turn to the parent/self-reaper logic.
terminate: false,
};
}
// --- subagent mode ---
let agent: AgentConfig | undefined;
if (params.agent) {
const scope = (params.agentScope ?? "user") as AgentScope;
const { agents } = discoverAgents(ctx.cwd, scope);
agent = agents.find((a) => a.name === params.agent);
if (!agent) {
const avail = agents.map((a) => a.name).join(", ") || "none";
return {
content: [
{ type: "text", text: `Unknown agent "${params.agent}". Available: ${avail}. Omit \`agent\` to use the default subagent configuration.` },
],
isError: true,
details: {},
};
}
}
// The branch above guarantees a successful plan in subagent mode.
const subagentNesting = nesting as Extract;
const res = await spawnSubagent({
name: params.name,
agent,
task: params.task as string,
model: params.model,
tools: params.tools,
cwd: ctx.cwd,
depth: subagentNesting.childDepth,
maxDepth: subagentNesting.maxDepth,
budget:
hasBudget
? {
maxCost: params.maxCost,
maxTurns: params.maxTurns,
maxToolCalls: params.maxToolCalls,
maxUsageTokens: params.maxUsageTokens,
}
: undefined,
timeout: params.timeout ?? "15m",
idleTimeout: params.idleTimeout,
});
if ("error" in res) {
return {
content: [{ type: "text", text: `Failed to spawn subagent: ${res.error}` }],
isError: true,
details: {},
};
}
pollNeeded = true;
await refreshWidget(ctx);
if (params.foreground || !ctx.hasUI) {
const outcome = await waitForTask(res.id, null, _signal);
collectSubagentOutcome(outcome);
const usage = claimOutcomeUsage(outcome);
if (ctx.hasUI) await refreshWidget(ctx);
return {
content: [{ type: "text", text: outcome.text }],
isError: !outcome.ok,
usage,
details: {
id: res.id,
kind: "subagent",
name: params.name ?? res.id,
agent: agent?.name,
model: res.model,
task: params.task,
depth: subagentNesting.childDepth,
maxDepth: subagentNesting.maxDepth,
status: outcomeStatus(outcome),
},
};
}
return {
content: [
{
type: "text",
text:
`Subagent started (id: ${res.id})${agent ? ` [agent: ${agent.name}]` : ""}${res.model ? ` [model: ${res.model}]` : ""} [depth: ${subagentNesting.childDepth}/${subagentNesting.maxDepth}].\n` +
`Task accepted — running in the background. You MUST collect it with babysit_wait before finishing the parent task; use foreground: true next time when no independent work is available.\n` +
`Progress: babysit_check { id: "${res.id}" } (only when inspection is needed)\n` +
`Collect: babysit_wait { id: "${res.id}" }\n` +
`Human can watch/steer: /babysit (pick ${res.id})`,
},
],
details: {
id: res.id,
kind: "subagent",
name: params.name ?? res.id,
agent: agent?.name,
model: res.model,
task: params.task,
depth: subagentNesting.childDepth,
maxDepth: subagentNesting.maxDepth,
status: "started" satisfies DisplayStatus,
},
};
},
// Make the execution kind and payload identifiable before the result arrives.
// Use semantic theme colors rather than the fallback gray tool-call text.
renderCall(args, theme) {
const isSubagent = args.profile === "subagent";
const kind = isSubagent ? "AGENT" : "COMMAND";
const payload = isSubagent ? args.task : args.command;
const agent = isSubagent && args.agent ? ` [${args.agent}]` : "";
const title = theme.bold(`babysit_run ${kind}`);
const detail =
typeof payload === "string" && payload.length > 0
? ` ${summarizeNotificationCommand(payload)}`
: "";
return new Text(theme.fg("warning", `${title}${agent}${detail}`), 0, 0);
},
renderResult(result, { isPartial }, theme, context) {
const details = (result.details ?? {}) as {
kind?: "process" | "subagent";
status?: DisplayStatus;
};
const content = result.content
.filter((item): item is { type: "text"; text: string } => item.type === "text")
.map((item) => item.text)
.join("\n");
// A vanished supervisor must never be presented as success, even if an
// older/stale result omitted status details or the host did not preserve
// the custom isError field. The textual diagnosis is part of our stable
// tool contract, so give it precedence over all fallback classification.
const workerDead =
content.includes("worker-dead") ||
content.includes("babysit supervisor disappeared");
const status: DisplayStatus = isPartial
? "running"
: workerDead
? "terminated"
: details.status ??
(context.isError
? "failed"
: details.kind === "subagent" || content.includes(NOTIFY_MARKER)
? "started"
: "success");
const label = renderStatus(status, theme);
return new Text(content ? `${label}\n${theme.fg("toolOutput", content)}` : label, 0, 0);
},
});
// ----- babysit_check ------------------------------------------------------
registerTool({
name: "babysit_check",
label: "Babysit: check",
description:
"Inspect babysit session(s). Without an id: lists running sessions by default; use `state: \"all\"` " +
"for history, or filter by terminal state/kind. With an id: a process shows state + recent " +
"output, searches its log with `pattern`, " +
"or captures the rendered screen with `screen: true`; a subagent shows live progress " +
"(or raw log matches with `pattern`). Results are bounded by `lines` and clipped. " +
"Do NOT poll this while merely waiting for a process to end — the exit notification is automatic.",
promptSnippet: "Check status/progress of babysit sessions (processes and subagents)",
parameters: Type.Object({
id: Type.Optional(Type.String({ description: "Session id. Omit to list sessions." })),
state: Type.Optional(
StringEnum(["running", "terminal", "all"] as const, {
description: "List mode only: state filter. Defaults to running; terminal means any non-running state.",
}),
),
kind: Type.Optional(
StringEnum(["process", "subagent", "all"] as const, {
description: "List mode only: session kind filter. Defaults to all.",
}),
),
tools: Type.Optional(
Type.Number({ description: "Subagent: how many recent tool calls to show (default 8, max 50)." }),
),
lines: Type.Optional(
Type.Number({ description: "How many tail lines or latest matches to show (default 30, max 200)." }),
),
maxBytes: Type.Optional(
Type.Integer({
minimum: 1_000,
maximum: ANSWER_MAX_BYTES,
description: "Maximum returned bytes for this check (default 4 KB).",
}),
),
pattern: Type.Optional(
Type.String({
description:
"Search this session's raw log with a regular expression; returns the latest bounded matches.",
}),
),
screen: Type.Optional(
Type.Boolean({
description:
"Process: capture the rendered terminal screen instead of the log stream (for full-screen TUIs).",
}),
),
}),
async execute(_id, params, signal) {
await requireBabysit();
if (!params.id) {
const { sessions, error } = await listSessions();
if (error) {
return {
content: [{ type: "text", text: `Could not list sessions: ${error}` }],
isError: true,
details: {},
};
}
if (sessions.length === 0) {
return { content: [{ type: "text", text: "No babysit sessions." }], details: { sessions: [] } };
}
const stateFilter = params.state ?? "running";
const kindFilter = params.kind ?? "all";
const stateMatches = (session: BsSession) => stateFilter === "all"
? true
: stateFilter === "running"
? session.state === "running"
: session.state !== "running";
const kindOfSession = (session: BsSession) => readMeta(session.id)?.kind ?? "process";
const selected = sessions.filter((session) =>
stateMatches(session) && (kindFilter === "all" || kindOfSession(session) === kindFilter),
);
const reveal: string[] = [];
if (stateFilter !== "all" && sessions.some((session) => !stateMatches(session))) {
reveal.push('state: "all"');
}
if (kindFilter !== "all" && sessions.some((session) => kindOfSession(session) !== kindFilter)) {
reveal.push('kind: "all"');
}
const revealHint = reveal.length > 0 ? `; use ${reveal.join(" and ")} to widen the list` : "";
if (selected.length === 0) {
const hidden = sessions.length;
return {
content: [{
type: "text",
text: `No ${stateFilter}${kindFilter === "all" ? "" : ` ${kindFilter}`} babysit sessions.${hidden > 0 ? ` ${hidden} session(s) hidden${revealHint}.` : ""}`,
}],
details: { sessions: [], total: sessions.length, hidden, state: stateFilter, kind: kindFilter },
};
}
const lines = selected.map((s) => {
const meta = readMeta(s.id);
const kind = meta?.kind ?? "process";
const flag = s.note ? ` ⚑ ${s.note}` : "";
const ec = s.exit_code != null ? ` exit=${s.exit_code}` : "";
const depth =
kind === "subagent" && meta?.depth != null
? ` depth=${meta.depth}/${meta.maxDepth ?? "?"}`
: "";
const what = (kind === "subagent" ? meta?.task : meta?.command) ?? "";
const preview = what.length > 60 ? `${what.slice(0, 57)}…` : what;
return `${s.id} [${kind}] ${s.state}${ec}${depth}${flag}${preview ? ` — ${preview}` : ""}`;
});
const hidden = sessions.length - selected.length;
const suffix = hidden > 0 ? `\n… ${hidden} session(s) hidden${revealHint}.` : "";
return {
content: [{ type: "text", text: clip(lines.join("\n") + suffix) }],
details: { sessions: selected, total: sessions.length, hidden, state: stateFilter, kind: kindFilter },
};
}
const st = await statusOf(params.id);
if (!st) {
return {
content: [{ type: "text", text: `No such session: ${params.id}` }],
isError: true,
details: {},
};
}
const meta = readMeta(params.id);
const nLines = Math.min(Math.max(1, Math.floor(params.lines ?? 30)), 200);
const checkMaxBytes = params.maxBytes ?? TAIL_MAX_BYTES;
if (params.pattern !== undefined) {
if (params.screen) {
return {
content: [{ type: "text", text: "`pattern` and `screen` are mutually exclusive." }],
isError: true,
details: {},
};
}
if (params.pattern.length === 0) {
return {
content: [{ type: "text", text: "`pattern` must not be empty." }],
isError: true,
details: {},
};
}
const result = await searchLog(params.id, params.pattern, nLines, signal, checkMaxBytes);
if (result.error) {
return {
content: [{ type: "text", text: result.error }],
isError: true,
details: {},
};
}
const kind = meta?.kind ?? "process";
const header = `[${kind}] state=${st.state}\nlog: ${logPath(params.id)}`;
const body = result.text
? `--- latest matches /${params.pattern}/ ---\n${result.text}`
: `(no output matching /${params.pattern}/)`;
return {
content: [{ type: "text", text: clip(`${header}\n${body}`, checkMaxBytes) }],
details: { status: st, kind, logPath: logPath(params.id), pattern: params.pattern },
};
}
// --- process ---
if (meta?.kind !== "subagent") {
const parts: string[] = [];
let header = `[process] state=${st.state}`;
if (st.state === "running") {
const el = elapsedOf(params.id);
if (el) header += ` elapsed=${el}`;
}
if (st.exit_code != null) header += ` exit_code=${st.exit_code}`;
if (meta?.command) header += `\ncommand: ${summarizeNotificationCommand(meta.command)}`;
header += `\nlog: ${logPath(params.id)}`;
if (st.note) header += ` ⚑ ${st.note}`;
parts.push(header);
if (params.screen) {
const sc = await bs(["screenshot", "-s", params.id, "--trim"]);
parts.push(`--- screen ---\n${clip(sc.stdout.trimEnd(), checkMaxBytes) || "(blank screen)"}`);
} else {
const tail = clip(
(await bs(["log", "-s", params.id, "--tail", String(nLines)])).stdout.trimEnd(),
checkMaxBytes,
);
parts.push(tail ? `--- recent output ---\n${tail}` : "(no output yet)");
}
return {
content: [{ type: "text", text: clip(parts.join("\n"), checkMaxBytes) }],
details: { status: st, kind: "process", logPath: logPath(params.id) },
};
}
// --- subagent: analyze only bytes appended for the current task ---
const prog = taskProgressOf(params.id).progress;
const nTools = Math.min(Math.max(1, params.tools ?? 8), 50);
const recent = prog.toolCalls.slice(-nTools);
const parts: string[] = [];
let header = `[subagent] state=${st.state}`;
if (meta.depth != null) header += ` depth=${meta.depth}/${meta.maxDepth ?? "?"}`;
if (st.state === "running") {
const el = elapsedOf(params.id);
if (el) header += ` elapsed=${el}`;
header += prog.done
? " · task-complete (idle — follow-up via babysit_send, or babysit_kill)"
: prog.waitingOnProcess
? " · waiting-on-background-process"
: " · working";
}
if (st.exit_code != null) header += ` exit_code=${st.exit_code}`;
header += ` turns=${prog.turns} calls=${prog.modelCalls} tools=${prog.toolCallCount}`;
if (prog.tokens != null) header += ` ctx=${prog.tokens}`;
if (prog.modelCalls > 0) header += ` usage=${prog.usageTokens} $${prog.cost.toFixed(4)}`;
if (st.note) header += ` ⚑ ${st.note}`;
parts.push(header);
if (prog.errorMsg) parts.push(`⚠ error: ${clip(prog.errorMsg, ANSWER_MAX_BYTES)}`);
if (recent.length > 0) {
const skipped = Math.max(0, prog.toolCallCount - recent.length);
parts.push(
`--- recent tool calls${skipped > 0 ? ` (+${skipped} earlier)` : ""} ---\n` +
recent.map((t) => ` ${t.summary}`).join("\n"),
);
}
if (prog.finalText.trim()) {
parts.push(`--- answer so far ---\n${clip(prog.finalText.trim(), ANSWER_MAX_BYTES)}`);
} else if (prog.toolCallCount === 0 && st.state !== "running") {
parts.push(buildSubagentExitDiagnostic(prog, logPath(params.id)));
} else if (prog.toolCallCount === 0) {
parts.push("(starting up… no events yet)");
} else {
parts.push("(working… no answer text yet)");
}
return {
content: [{ type: "text", text: clip(parts.join("\n"), checkMaxBytes) }],
details: { status: st, progress: prog, kind: "subagent" },
};
},
});
// ----- babysit_send -------------------------------------------------------
registerTool({
name: "babysit_send",
label: "Babysit: send",
description:
"Send input to a babysit session. Process: `text` types a line into its stdin (PTY), " +
"`keys` presses named keys (Enter, Tab, Esc, Up/Down/Left/Right, C-c, F1…) — use with " +
"babysit_check { screen: true } to drive interactive programs. Subagent: `text` is " +
"STEERING while it works, or a NEW TASK after the current task settles (mode: auto/steer/task) — this " +
"is how you resume a finished subagent with full context.",
promptSnippet: "Send text/keys to a process, or steering/follow-up tasks to a subagent",
parameters: Type.Object({
id: Type.String({ description: "Session id." }),
text: Type.Optional(
Type.String({ description: "Text to send (a line for processes; a message for subagents)." }),
),
keys: Type.Optional(
Type.Array(Type.String(), {
description: "Process only: named keys pressed in order (e.g. ['Down','Down','Enter'], ['C-c']).",
}),
),
mode: Type.Optional(
StringEnum(["auto", "steer", "task"] as const, {
description:
"Subagent only. auto (default): steer unless the current task is settled. task requires confirmed settlement; steer always sends guidance.",
}),
),
noNewline: Type.Optional(
Type.Boolean({ description: "Process only: don't append a trailing newline to `text`." }),
),
}),
async execute(_id, params) {
await requireBabysit();
const st = await statusOf(params.id);
if (!st || st.state !== "running") {
return {
content: [
{ type: "text", text: `Session ${params.id} is not running (${st?.state ?? "missing"}).` },
],
isError: true,
details: {},
};
}
const meta = readMeta(params.id);
// --- process: raw text/keys into the PTY ---
if (meta?.kind !== "subagent") {
if (!params.text && !params.keys?.length) {
return {
content: [{ type: "text", text: "Provide `text` or `keys`." }],
isError: true,
details: {},
};
}
if (params.keys?.length) {
const r = await bs(["key", "-s", params.id, ...params.keys]);
if (r.code !== 0) {
return {
content: [{ type: "text", text: r.stderr || "key send failed" }],
isError: true,
details: {},
};
}
}
if (params.text != null) {
const args = ["send", "-s", params.id];
if (params.noNewline) args.push("--no-newline");
args.push(params.text);
const r = await bs(args);
if (r.code !== 0) {
return {
content: [{ type: "text", text: r.stderr || "send failed" }],
isError: true,
details: {},
};
}
}
return {
content: [
{
type: "text",
text: `Sent to ${params.id}. Read the reaction with babysit_check { id: "${params.id}"${params.keys?.length ? ", screen: true" : ""} } — don't expect the echo of your own input.`,
},
],
details: { kind: "process" },
};
}
// --- subagent: steer / follow-up task over RPC ---
return withSessionRpcLock(params.id, async () => {
const lockedStatus = await statusOf(params.id);
const meta = readMeta(params.id);
if (lockedStatus?.state !== "running" || meta?.kind !== "subagent") {
return {
content: [
{
type: "text" as const,
text: `Session ${params.id} is not a running subagent (${lockedStatus?.state ?? "missing"}).`,
},
],
isError: true,
details: {},
};
}
if (!params.text) {
return {
content: [{ type: "text", text: "Provide `text` (steering or follow-up task)." }],
isError: true,
details: {},
};
}
if (messageNeedsReadTool(params.text) && meta.tools?.length && !meta.tools.includes("read")) {
return {
content: [
{
type: "text",
text: "This message is too large for PTY-safe inline delivery, but the subagent cannot read the required temporary file. Add `read` to its tool allowlist or shorten the message.",
},
],
isError: true,
details: {},
};
}
let mode = params.mode ?? "auto";
if (mode === "auto" || mode === "task") {
// A prompt sent while the current run is streaming can queue behind that
// run while immediately replacing our per-task offsets and budget state.
// Establish idleness before every new task; auto safely falls back to
// steering when state is unknown, while an explicit task fails closed.
const gs = await sendRpc(params.id, { type: "get_state" });
let streaming: boolean | undefined;
if (!("error" in gs)) {
const r = await rpcResponse(params.id, gs.offset, "get_state", "10s");
if (r.ok) streaming = Boolean((r.data as { isStreaming?: boolean })?.isStreaming);
}
let currentTaskDone: boolean | undefined;
try {
currentTaskDone = taskProgressOf(params.id).progress.done;
} catch {
/* fail closed below rather than replacing unknown task state */
}
const resolved = resolveSubagentSendMode(mode, streaming, currentTaskDone);
if ("error" in resolved) {
return {
content: [{
type: "text",
text: resolved.error === "busy"
? `Subagent ${params.id} is still streaming; use mode \"steer\" or wait for the current task to settle before starting another task.`
: resolved.error === "unsettled"
? `Subagent ${params.id} has not settled its current task (it may be parked on a background process); wait for completion before starting another task.`
: `Could not verify that subagent ${params.id} is idle and settled; retry with mode \"task\" after checking its state.`,
}],
isError: true,
details: { mode: "task" },
};
}
mode = resolved.mode;
}
const deliveryCleanupAfter =
mode === "steer"
? (() => {
try {
return taskProgressOf(params.id).progress.agentEnds + 1;
} catch {
return 1;
}
})()
: 1;
const delivery = deliverableMessage(
mode === "steer" ? "steering message" : "task",
params.text,
);
const cmd =
mode === "steer"
? { type: "steer", message: delivery.message }
: { type: "prompt", message: delivery.message };
const sent = await sendRpc(params.id, cmd);
if ("error" in sent) {
discardDelivery(delivery);
return {
content: [{ type: "text", text: sent.error }],
isError: true,
details: {},
};
}
const resp = await rpcResponse(params.id, sent.offset, cmd.type, "15s");
if (!resp.ok) {
discardDelivery(delivery);
return {
content: [{ type: "text", text: `${cmd.type} was not accepted: ${resp.error}` }],
isError: true,
details: { mode },
};
}
if (mode === "task") {
// New task → new bookkeeping window, so check/wait track THIS task.
writeMeta(params.id, {
kind: "subagent",
task: params.text,
// Start after the prompt-acceptance response. This excludes a
// preceding run that settled between `send` and RPC acceptance.
promptOffset: resp.offset,
model: meta?.model,
tools: meta?.tools,
messageTempDirs: delivery.tempDir
? [{ dir: delivery.tempDir, afterAgentEnd: deliveryCleanupAfter }]
: undefined,
depth: meta?.depth,
maxDepth: meta?.maxDepth,
budget: meta?.budget,
// Each follow-up task receives fresh budget and usage-accounting windows.
budgetWarnedAt: undefined,
budgetWarningReason: undefined,
budgetExceededAt: undefined,
budgetReason: undefined,
budgetKilled: undefined,
usageReportedOffset: undefined,
});
} else if (delivery.tempDir && meta) {
writeMeta(params.id, {
...meta,
messageTempDirs: [
...(meta.messageTempDirs ?? []),
{ dir: delivery.tempDir, afterAgentEnd: deliveryCleanupAfter },
],
});
}
return {
content: [
{
type: "text",
text:
mode === "steer"
? `Steering queued for ${params.id} (delivered between turns).`
: `New task started on ${params.id} — wait for it with babysit_wait.`,
},
],
details: { mode, kind: "subagent" },
};
});
},
});
// ----- babysit_wait -------------------------------------------------------
registerTool({
name: "babysit_wait",
label: "Babysit: wait",
description:
"Block until babysit session(s) finish, then return the result. A process finishes " +
"when it EXITS (or, with `expect`, as soon as a regex appears in its output — e.g. wait " +
"for 'listening on' before hitting a dev server). A subagent finishes when its current " +
"TASK completes (the worker remains reusable only during its configured idle grace). Pass `id` for one session, or " +
"`ids` + `mode`: 'all' (default) waits for every one, 'any' returns on the FIRST finisher. " +
"Multi-session results are capped at the inline-output limit (8 KB by default); use `maxBytes` " +
"to opt into a larger result up to 24 KB. " +
"Prefer ending your turn over babysit_wait when a process result is not needed this turn — " +
"the exit notification will resume you.",
promptSnippet: "Block until session(s) finish — process exit / output pattern / subagent task done",
parameters: Type.Object({
id: Type.Optional(Type.String({ description: "Session id (single wait)." })),
ids: Type.Optional(
Type.Array(Type.String(), {
maxItems: MAX_MULTI_WAIT_SESSIONS,
description: `Session ids for a multi-wait (use with mode; maximum ${MAX_MULTI_WAIT_SESSIONS}, no duplicates).`,
}),
),
mode: Type.Optional(
StringEnum(["all", "any"] as const, {
description: "Multi-wait mode: 'all' (default) or 'any' (first to finish wins).",
}),
),
timeout: Type.Optional(
Type.String({ description: "Give up after this long (e.g. 5m). Default: wait indefinitely." }),
),
expect: Type.Optional(
Type.String({
description:
"Process only: return as soon as this regex appears in the output (readiness marker) instead of waiting for exit.",
}),
),
maxBytes: Type.Optional(
Type.Integer({
minimum: 1_000,
maximum: ANSWER_MAX_BYTES,
description:
"Multi-session result cap in bytes. Defaults to PI_BABYSIT_INLINE_OUTPUT_MAX_BYTES (8 KB).",
}),
),
}),
async execute(_id, params, signal) {
await requireBabysit();
const ids = params.ids?.length ? params.ids : params.id ? [params.id] : [];
if (ids.length === 0) {
return {
content: [{ type: "text", text: "Provide `id` or a non-empty `ids` array." }],
isError: true,
details: {},
};
}
if (ids.length > MAX_MULTI_WAIT_SESSIONS) {
return {
content: [{ type: "text", text: `Multi-wait supports at most ${MAX_MULTI_WAIT_SESSIONS} sessions.` }],
isError: true,
details: {},
};
}
if (new Set(ids).size !== ids.length) {
return {
content: [{ type: "text", text: "Multi-wait session ids must be unique." }],
isError: true,
details: {},
};
}
const limitMs = parseDurMs(params.timeout);
const multiResultMaxBytes = params.maxBytes ?? INLINE_OUTPUT_MAX_BYTES;
if (params.timeout !== undefined && limitMs === null) {
throw new Error(
`Invalid timeout ${JSON.stringify(params.timeout)}; use an integer with ms, s, m, or h (for example "90s" or "5m").`,
);
}
if (ids.length === 1) {
const r = await waitFor(ids[0], limitMs, signal, params.expect);
collectSubagentOutcome(r);
const usage = claimOutcomeUsage(r);
return {
content: [{ type: "text", text: r.text }],
isError: !r.ok,
usage,
details: {
status: r.status,
progress: r.progress,
timedOut: r.kind === "timeout",
interrupted: r.kind === "interrupted",
},
};
}
if ((params.mode ?? "all") === "all") {
// Parallel waits; report every result in input order.
const results = await Promise.all(
ids.map((i) => waitFor(i, limitMs, signal, params.expect)),
);
const ok = results.every((r) => r.ok);
results.forEach(collectSubagentOutcome);
const usage = sumNestedUsage(results.map(claimOutcomeUsage));
return {
content: [
{
type: "text",
text: clipMultiWaitResult(
results.map((result) => `── ${result.id} [${result.kind}] ──\n${result.text}`).join("\n\n"),
multiResultMaxBytes,
),
},
],
isError: !ok,
usage,
details: {
results: results.map((r) => ({ id: r.id, kind: r.kind, ok: r.ok })),
},
};
}
// mode "any": race the waits, then cancel the losers (their sessions keep
// running — only OUR blocked `expect`/`wait` children are cancelled).
const ctrl = new AbortController();
const onOuterAbort = () => ctrl.abort();
signal?.addEventListener("abort", onOuterAbort, { once: true });
try {
const first = await Promise.race(
ids.map((i) => waitFor(i, limitMs, ctrl.signal, params.expect)),
);
const others = ids.filter((i) => i !== first.id);
collectSubagentOutcome(first);
const usage = claimOutcomeUsage(first);
return {
content: [
{
type: "text",
text: clipMultiWaitResult(
`First to finish: ${first.id} [${first.kind}]` +
(others.length ? ` — still waiting-able: ${others.join(", ")}` : "") +
`\n\n${first.text}`,
multiResultMaxBytes,
),
},
],
isError: !first.ok,
usage,
details: { first: { id: first.id, kind: first.kind, ok: first.ok }, remaining: others },
};
} finally {
ctrl.abort();
signal?.removeEventListener("abort", onOuterAbort);
}
},
});
// ----- babysit_kill -------------------------------------------------------
registerTool({
name: "babysit_kill",
label: "Babysit: kill",
description: "Terminate a babysit session (process or subagent).",
promptSnippet: "Terminate a babysit session",
parameters: Type.Object({ id: Type.String({ description: "Session id." }) }),
async execute(_id, params, _signal, _onUpdate, ctx) {
await requireBabysit();
// Prevent the exit poller racing a requested kill, but restore delivery
// on every failure. Permanent suppression happens only after terminal
// state is independently confirmed.
pauseNotify(params.id);
const fail = async (message: string, status?: BsSession | null) => {
resumeNotify(params.id);
await refreshWidget(ctx);
return {
content: [{ type: "text" as const, text: message }],
isError: true,
details: { id: params.id, status: status?.state, logPath: logPath(params.id) },
};
};
const r = await bs(["kill", "-s", params.id, "--json"]);
const backendError = r.code !== 0
? (r.stderr || r.stdout || "kill failed").trim()
: validateKillResponse(r.stdout);
// A backend can report an escalation error after the child has already
// reached a persisted terminal state. Reconcile against authoritative
// state before deciding whether to restore completion notifications.
const status = await awaitConfirmedTermination(params.id);
const confirmation = resolveKillConfirmation(params.id, backendError, status?.state);
if (!confirmation.confirmed) return fail(confirmation.error, status);
if (!status) return fail(`Kill could not be verified: session ${params.id} disappeared.`);
suppressNotify(params.id, "kill");
await refreshWidget(ctx);
return {
content: [
{
type: "text",
text:
`Killed ${params.id} (confirmed ${status.state}).` +
(confirmation.warning ? ` Backend warning after termination: ${confirmation.warning}` : ""),
},
],
details: {
id: params.id,
status: status.state,
exitCode: status.exit_code,
logPath: logPath(params.id),
backendWarning: confirmation.warning,
},
};
},
});
// ----- /babysit -----------------------------------------------------------
// Arrow up/down picker over all sessions (like /stash). Renders an INLINE
// snapshot (no tmux): running process → current rendered screen + recent
// output + a copy-paste `babysit attach` take-over hint; running subagent →
// read-only progress; finished → summary. Re-run to refresh.
pi.registerCommand("babysit", {
description: "Pick a session to inspect, or `/babysit gc [days]` to remove old terminal roots",
handler: async (args, ctx) => {
const command = args.trim();
if (command === "gc" || command.startsWith("gc ")) {
const daysText = command.slice(2).trim();
const days = daysText ? Number(daysText) : 14;
if (!Number.isFinite(days) || days <= 0) {
ctx.ui.notify("Usage: /babysit gc [positive retention days]", "error");
return;
}
const options = {
rootBase: ROOT_BASE,
currentRoot: ROOT,
olderThanMs: days * 86_400_000,
};
const preview = gcBabysitRoots({ ...options, dryRun: true });
if (preview.candidates.length === 0) {
ctx.ui.notify(
`No terminal pi-babysit roots older than ${days} days are safe to remove.`,
"info",
);
return;
}
const confirmed = await ctx.ui.confirm(
"Remove old pi-babysit roots?",
`${preview.candidates.length} roots · ${preview.bytes} bytes · older than ${days} days\n` +
"Running supervisors and the current Pi session are excluded.",
);
if (!confirmed) return;
const removed = gcBabysitRoots({ ...options, dryRun: false });
ctx.ui.notify(
`Removed ${removed.deleted.length} pi-babysit roots (${removed.bytes} bytes).`,
"info",
);
return;
}
if (command) {
ctx.ui.notify("Usage: /babysit or /babysit gc [days]", "error");
return;
}
if (!(await babysitAvailable())) {
ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "error");
return;
}
const sessions = (await listSessions()).sessions.sort((a, b) =>
a.state === b.state ? 0 : a.state === "running" ? -1 : 1,
);
if (sessions.length === 0) {
ctx.ui.notify("No babysit sessions.", "info");
return;
}
const whatOf = (s: BsSession): string => {
const meta = readMeta(s.id);
return ((meta?.kind === "subagent" ? meta.task : meta?.command) ?? "")
.replace(/\s+/g, " ")
.trim();
};
// Labels must be unique for index mapping; the id makes them unique.
const labels = sessions.map((s) => {
const kind = kindOf(s.id);
const icon = s.state === "running" ? "⏳" : s.exit_code === 0 ? "✓" : "✗";
const ec = s.exit_code != null ? ` exit=${s.exit_code}` : "";
const flag = s.note ? " ⚑" : "";
const what = whatOf(s);
const preview = what.length > 60 ? `${what.slice(0, 57)}…` : what;
return `${icon} ${s.id}${flag} [${kind}] ${s.state}${ec}${preview ? ` — ${preview}` : ""}`;
});
const choice = await ctx.ui.select("Babysit sessions:", labels);
if (!choice) return;
const picked = sessions[labels.indexOf(choice)];
if (!picked) return;
const kind = kindOf(picked.id);
const elapsed = picked.state === "running" ? elapsedOf(picked.id) : null;
const elapsedSuffix = elapsed ? ` ${elapsed}` : "";
// Inline snapshot (running) or summary (finished) — no tmux window.
if (kind === "subagent") {
// Parse the RPC event stream and show the final answer, not raw JSONL.
const prog = taskProgressOf(picked.id).progress;
const stats =
`turns=${prog.turns} calls=${prog.modelCalls} tools=${prog.toolCallCount}` +
(prog.tokens != null ? ` ctx=${prog.tokens}` : "") +
(prog.modelCalls > 0 ? ` usage=${prog.usageTokens} $${prog.cost.toFixed(4)}` : "");
const body =
(prog.finalText.trim() ||
prog.errorMsg ||
`(no structured output; full log: ${logPath(picked.id)})`) +
(picked.state === "running"
? "\n\n_Live subagent (read-only). Re-run `/babysit` to refresh this snapshot._"
: "");
const title =
`${picked.id} ${picked.state}${elapsedSuffix}` +
(picked.exit_code != null ? ` (exit=${picked.exit_code})` : "") +
` ${stats}`;
const status: DisplayStatus = prog.errorMsg
? "failed"
: prog.running || prog.waitingOnProcess
? "running"
: prog.done
? "idle"
: picked.exit_code === 0
? "success"
: "terminated";
if (ctx.hasUI) {
pi.sendMessage({
customType: "pi-babysit-result",
content: title,
display: true,
details: { title, body, status },
});
} else {
ctx.ui.notify(`${title}\n\n${body}`, "info");
}
} else {
const meta = readMeta(picked.id);
const running = picked.state === "running";
// For a LIVE process show the CURRENT rendered screen (TUIs redraw in
// place, so the raw stream isn't representative); for a finished one
// the recorded tail is enough.
const screen = running
? (await bs(["screenshot", "-s", picked.id, "--trim"])).stdout.trimEnd()
: "";
const tail = (await bs(["log", "-s", picked.id, "--tail", "30"])).stdout.trimEnd();
const title =
`${picked.id} ${picked.state}${elapsedSuffix}` +
(picked.exit_code != null ? ` (exit=${picked.exit_code})` : "");
const body =
(meta?.command ? `\`${meta.command}\`\n\n` : "") +
(screen ? `**screen**\n\`\`\`\n${screen}\n\`\`\`\n\n` : "") +
(tail
? `**recent output**\n\`\`\`\n${tail}\n\`\`\``
: screen
? ""
: "(no output)") +
(running
? `\n\n_Take over in your own terminal:_ \`${attachCmd(picked.id)}\` _(detach: Ctrl-\\ Ctrl-\\)._ Re-run \`/babysit\` to refresh this snapshot.`
: "");
const status: DisplayStatus = running
? "running"
: picked.exit_code === 0
? "success"
: picked.state === "dead" || picked.exit_code == null
? "terminated"
: "failed";
if (ctx.hasUI) {
pi.sendMessage({
customType: "pi-babysit-result",
content: title,
display: true,
details: { title, body, status },
});
} else {
ctx.ui.notify(`${title}\n\n${body}`, "info");
}
}
},
});
}