// Child → parent approval/prompt relay for subagents.
//
// pi-subagents runs each subagent as a headless child `pi` process with stdin
// IGNORED (`-p --mode json`), so the child's permission gate has no interactive
// way to reach a human. When the child is guarded by privateer's moat (the
// discovered gate shim) a gated action would otherwise just fail-closed (deny).
// This module gives that child a way OUT: it writes its approval request to a
// per-session directory on disk; the PARENT privateer session (which owns the
// RemoteBridge / relay to the app) watches that directory, relays the request to
// the phone for Allow/Deny, and writes the answer back as a reply file the child
// is polling for.
//
// Why our own channel and not pi-subagents' supervisor channel: we own BOTH
// processes, and the gate ask is a Pi permission decision — not a
// `contact_supervisor` tool call — so piggy-backing on pi-subagents' internal
// channel would couple us to its provisioning. A privateer-owned dir, addressed
// by an env var we set (inherited through pi-subagents' `{...process.env}` spawn),
// is fully under our control and unit-testable without a live Pi.
//
// Transport shape (mirrors pi-subagents' own request/reply-file dance so the
// semantics are familiar): `
/requests/.json` written atomically by the
// child; `/replies/.json` written atomically by the parent; the parent
// deletes the request once answered. Everything is fail-closed: no parent, a gone
// controller, a timeout, or an abort all resolve the child to "deny"/null.
import { mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
// The env var carrying the channel dir to a subagent child. Set by the parent into
// its OWN process.env before subagents spawn, so pi-subagents' `{...process.env}`
// spawn inherits it into every (possibly nested) child.
export const SUBAGENT_CHANNEL_ENV = "PRIVATEER_SUBAGENT_CHANNEL";
// A relayed permission approval. `kind`/`title`/`detail` mirror PermissionRequest
// so the parent can hand it straight to the bridge's requestApproval.
export interface ApprovalAsk {
type: "approval";
kind?: string;
title: string;
detail: string;
}
// A relayed selection prompt (an extension's ctx.ui.select in the child).
export interface SelectAsk {
type: "select";
title: string;
options: { value: string; label: string; hint?: string }[];
current?: string;
}
// A relayed free-form text prompt (an extension's ctx.ui.input in the child).
export interface InputAsk {
type: "input";
title: string;
placeholder?: string;
}
export type SubagentAsk = ApprovalAsk | SelectAsk | InputAsk;
// The parent's answer. `decision` for approvals ("allow"/"deny"); `value` for
// select/input (the chosen value / typed line, or null for a dismiss/deny).
export interface SubagentReply {
decision?: "allow" | "deny";
value?: string | null;
}
interface RequestEnvelope {
id: string;
ask: SubagentAsk;
// The subagent that raised it, for display/audit (best-effort; from env).
agent?: string;
}
interface ReplyEnvelope {
id: string;
reply: SubagentReply;
}
// Deterministic per-session channel dir. Keyed by the PARENT session id so a parent
// watches exactly the children it spawned, and two concurrent parents never cross
// wires. Under the OS temp dir (world-unreadable 0700), never the repo/agent dir.
export function channelDirForSession(sessionId: string): string {
const safe = sessionId.replace(/[^\w.-]+/g, "_") || "session";
return join(tmpdir(), "privateer-subagent-channels", safe);
}
function requestsDir(dir: string): string {
return join(dir, "requests");
}
function repliesDir(dir: string): string {
return join(dir, "replies");
}
// Create the channel dir tree (idempotent). Parent calls this before advertising the
// env var; child tolerates a missing tree by treating it as "no parent" (deny).
export function ensureChannelDir(dir: string): void {
mkdirSync(requestsDir(dir), { recursive: true, mode: 0o700 });
mkdirSync(repliesDir(dir), { recursive: true, mode: 0o700 });
}
// Atomic JSON write: write a sibling temp file then rename, so a reader never sees a
// half-written file (rename is atomic within a dir on POSIX).
function writeJsonAtomic(path: string, value: unknown): void {
const tmp = `${path}.${randomUUID()}.tmp`;
writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
renameSync(tmp, path);
}
function readJson(path: string): T | undefined {
try {
return JSON.parse(readFileSync(path, "utf8")) as T;
} catch {
return undefined;
}
}
function safeUnlink(path: string): void {
try {
rmSync(path, { force: true });
} catch {
/* already gone */
}
}
// ── child side ───────────────────────────────────────────────────────────────
export interface AskOptions {
timeoutMs?: number;
pollMs?: number;
signal?: AbortSignal;
agent?: string;
}
const DEFAULT_TIMEOUT_MS = 10 * 60_000; // match pi-subagents' 10-min ask ceiling
const DEFAULT_POLL_MS = 200;
// Forward an ask to the parent and await its reply. Resolves to the reply, or null
// on timeout / abort / no channel — every non-answer is a fail-closed null so the
// caller (the gate) denies. Safe to call from a headless child with no stdio.
export async function askParent(dir: string, ask: SubagentAsk, opts: AskOptions = {}): Promise {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
const id = randomUUID();
const reqPath = join(requestsDir(dir), `${id}.json`);
const replyPath = join(repliesDir(dir), `${id}.json`);
try {
ensureChannelDir(dir);
writeJsonAtomic(reqPath, { id, ask, agent: opts.agent } satisfies RequestEnvelope);
} catch {
return null; // no channel / unwritable → fail closed
}
const deadline = Date.now() + timeoutMs;
try {
for (;;) {
if (opts.signal?.aborted) return null;
const reply = readJson(replyPath);
if (reply && reply.id === id) return reply.reply ?? null;
if (Date.now() >= deadline) return null;
await sleep(Math.min(pollMs, Math.max(0, deadline - Date.now())), opts.signal);
}
} finally {
// Best-effort cleanup so a timed-out/aborted request doesn't linger and a stale
// reply doesn't confuse a later run (ids are unique, but keep the dir tidy).
safeUnlink(reqPath);
safeUnlink(replyPath);
}
}
function sleep(ms: number, signal?: AbortSignal): Promise {
return new Promise((resolve) => {
if (ms <= 0) return resolve();
const t = setTimeout(done, ms);
const onAbort = () => done();
function done() {
clearTimeout(t);
signal?.removeEventListener("abort", onAbort);
resolve();
}
signal?.addEventListener("abort", onAbort, { once: true });
});
}
// ── parent side ──────────────────────────────────────────────────────────────
export interface WatcherHandle {
stop: () => void;
}
// The parent's answerer: given a child's ask, return the reply (relaying to the app
// and awaiting the human, in the real wiring). Returning a rejecting/denying reply
// or throwing both fail closed — the watcher writes a deny/null reply on a throw.
export type AskHandler = (ask: SubagentAsk, meta: { id: string; agent?: string }) => Promise;
export interface WatchOptions {
pollMs?: number;
onError?: (err: unknown) => void;
}
// Watch the channel for child requests, answer each via `handler`, and write the
// reply back (then delete the request). Requests are handled at most once — an id is
// marked in-flight before the async handler runs so a fast poll can't double-serve.
// A handler that throws yields a fail-closed reply (deny / null value). Returns a
// handle whose stop() ends the poll loop.
export function watchSubagentChannel(dir: string, handler: AskHandler, opts: WatchOptions = {}): WatcherHandle {
const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
const inFlight = new Set();
let stopped = false;
let timer: ReturnType | undefined;
const failClosed = (ask: SubagentAsk): SubagentReply =>
ask.type === "approval" ? { decision: "deny" } : { value: null };
const serve = (id: string, env: RequestEnvelope): void => {
inFlight.add(id);
void Promise.resolve()
.then(() => handler(env.ask, { id, agent: env.agent }))
.catch((err) => {
opts.onError?.(err);
return failClosed(env.ask);
})
.then((reply) => {
try {
writeJsonAtomic(join(repliesDir(dir), `${id}.json`), { id, reply } satisfies ReplyEnvelope);
} catch (err) {
opts.onError?.(err);
} finally {
safeUnlink(join(requestsDir(dir), `${id}.json`));
inFlight.delete(id);
}
});
};
const tick = (): void => {
if (stopped) return;
try {
ensureChannelDir(dir);
for (const name of readdirSync(requestsDir(dir))) {
if (!name.endsWith(".json")) continue;
const id = name.slice(0, -5);
if (inFlight.has(id)) continue;
const env = readJson(join(requestsDir(dir), name));
if (env && env.id === id && env.ask) serve(id, env);
}
} catch (err) {
opts.onError?.(err);
}
if (!stopped) timer = setTimeout(tick, pollMs);
};
tick();
return {
stop: () => {
stopped = true;
if (timer) clearTimeout(timer);
},
};
}