/** * Pi adapter for the host-neutral main-session execution seam (#526 / S1b-2). * Owns argv construction, spawn/SIGTERM/close, and session codec helpers. * public-cli runners project RoleTurnRequest; this module is the sole argv owner. */ import { execFile, spawn } from "node:child_process"; import { constants } from "node:fs"; import { access, appendFile, readFile, realpath } from "node:fs/promises"; import { basename, delimiter, dirname, isAbsolute, join, resolve } from "node:path"; import { platform } from "node:process"; import { promisify } from "node:util"; import { randomUUID } from "node:crypto"; import type { DurablePrincipal, DurablePrincipalAuthority, MethodBinding, RoleTurnHost, RoleTurnKnownFailure, RoleTurnModelConfig, RoleTurnRequest, RoleTurnResult, } from "../host-contracts.ts"; import { ExplicitInternalActivationError, isOfficerReviewSeat } from "../host-contracts.ts"; import { applyEngineChildEnv, ENGINE_MODEL_FLAG_NAME, normalizeEngineName } from "../engine-detour.ts"; import { projectActivationFlags } from "../role-activation-flags.ts"; import { encodeUserDialogueStdin } from "../user-dialogue-stdin.ts"; /** Package-relative Internal role entrypoint (ADR 0052; same path as public-cli registry). */ const INTERNAL_ROLE_ENTRYPOINT_RELATIVE = "extensions/role-runtime.ts"; export function resolveInternalRoleEntrypoint(packageRoot: string): string { return join(packageRoot, INTERNAL_ROLE_ENTRYPOINT_RELATIVE); } /** Non-dispatch args: load Internal once and exit via Pi help (no model turn). */ export const EXPLICIT_INTERNAL_LOAD_PROBE_ARGS = [ "--no-skills", "--no-prompt-templates", "--no-themes", "--no-context-files", "--no-session", "--help", ] as const; export function buildExplicitInternalActivationArgs( selectedRoleEntry: string, extraArgs: readonly string[] = [], ): string[] { return ["--no-extensions", "-e", selectedRoleEntry, ...extraArgs]; } /** * Pi argv for seat model. Host only passes through resolved values — no local * thinking whitelist and no package default fill. Bare provider/model omits * --thinking so Pi owns its own default (#346/#384). Explicit thinking only. */ function buildSeatModelCliArgs(model: RoleTurnModelConfig | undefined): string[] { if (model === undefined) return []; return [ "--provider", model.provider, "--model", model.model, ...(model.thinking === undefined ? [] : ["--thinking", model.thinking]), ]; } /** * Pi last hop only: shared envelope flags → controlled-session argv pairs (#819). * Flag membership/values stay in projectActivationFlags (middle layer). */ function activationFlagsToPiArgv(flags: ReadonlyMap): string[] { const args: string[] = []; for (const [name, value] of flags) { if (value === false) continue; args.push(`--${name}`); if (value !== true) args.push(String(value)); } return args; } function buildMethodArgs(methods: readonly MethodBinding[]): string[] { const skillArgs: string[] = []; for (const method of methods) { if (method.kind === "skill") { skillArgs.push("--skill", method.path); } } return skillArgs; } /** * Pi-native `/skill:` for a single forced method (ADR 0082: pi is one adapter * with no privilege; Pi-only seams stay inside the pi adapter). * Driven by typed RoleTurnRequest.methods — middle-layer decision, Pi-only syntax. * Zero/many methods leave the prompt alone (Fixer optional pair stays `--skill` only). */ export function applyPiNativeSkillInvocation( methods: readonly MethodBinding[], prompt: string, ): string { const skills = methods.filter((method) => method.kind === "skill"); if (skills.length !== 1) return prompt; const name = basename(dirname(skills[0]!.path)); if (name.length === 0) return prompt; const token = `/skill:${name}`; const trimmed = prompt.trimStart(); if ( trimmed === token || trimmed.startsWith(`${token} `) || trimmed.startsWith(`${token}\n`) ) { return prompt; } return prompt.length === 0 ? token : `${token} ${prompt}`; } /** * Pi last-hop argv after `--no-extensions -e entry` (#819). * Activation flag membership comes from middle-layer projectActivationFlags; * this function only renders session coords, controlled constants, and pairs. * User dialogue is not an argv element (#879): it rides spawn stdin. */ export function buildPiTurnExtraArgs( request: RoleTurnRequest, authority: DurablePrincipalAuthority, extraPiArgs: readonly string[] = [], ): string[] { const { sessionFile, sessionDirectory } = authority.decode(request.principal); return [ "--no-skills", ...buildMethodArgs(request.methods), "--no-prompt-templates", "--no-themes", "--no-context-files", "--session", sessionFile, "--session-dir", sessionDirectory, ...extraPiArgs, // Envelope assembly = projectActivationFlags; pi only renders argv pairs. ...activationFlagsToPiArgv(projectActivationFlags(request)), ...piEngineModelArgs(request), "--mode", "json", ...buildSeatModelCliArgs(request.model), ]; } /** Engine name stays on child env; model has no env fallback (#883 / #879). */ function piEngineModelArgs(request: RoleTurnRequest): string[] { const model = normalizeEngineName(request.engineModel); if (model === undefined) return []; return [`--${ENGINE_MODEL_FLAG_NAME}`, model]; } function piUserDialogueBody(request: RoleTurnRequest): string { const rawPrompt = request.continuation.kind === "initial" || request.continuation.kind === "resume" ? request.continuation.prompt : (() => { const _exhaustive: never = request.continuation; return _exhaustive; })(); return request.continuation.kind === "resume" ? rawPrompt : applyPiNativeSkillInvocation(request.methods, rawPrompt); } export type PiSpawnRunner = ( args: readonly string[], options: { cwd: string; env: NodeJS.ProcessEnv; timeoutMs?: number; /** Parent cancellation; the child gets the same graceful SIGTERM as a budget. */ signal?: AbortSignal; /** User dialogue body; omitted from argv so execve cannot E2BIG (#879). */ stdin?: string; }, ) => Promise<{ code: number | null; stderr: string; timedOut: boolean; knownFailure?: RoleTurnKnownFailure; }>; export type LaunchedPiIdentity = { readonly executable: string; readonly version: string; }; export type LaunchedRolePackageIdentity = { readonly roleEntry: string; readonly rolePackageRoot: string; readonly rolePackageVersion: string; readonly entryMode: "public-cli"; }; export type PiRoleTurnHostConfig = { readonly packageRoot: string; readonly principalAuthority: DurablePrincipalAuthority; /** Test / seat-specific extra Pi args (faux provider etc.). */ readonly extraPiArgs?: readonly string[]; readonly timeoutMs?: number; /** Low-level spawn seam (tests inject faux children). */ readonly spawnRunner?: PiSpawnRunner; readonly recordLaunchedPiIdentity?: ( runDirectory: string, identity: LaunchedPiIdentity, ) => Promise; readonly recordLaunchedRolePackageIdentity?: ( runDirectory: string, identity: LaunchedRolePackageIdentity, ) => Promise; readonly observeLaunchedRolePackageIdentity?: ( packageRoot: string, roleEntrypoint: string, ) => Promise; }; const execFileAsync = promisify(execFile); async function resolveSelectedPi( command: string, cwd: string, env: NodeJS.ProcessEnv, ): Promise { const searchPath = env.PATH ?? (platform === "win32" ? (process.env.PATH ?? "") : "/usr/bin:/bin"); const candidates = isAbsolute(command) || command.includes("/") ? [resolve(cwd, command)] : searchPath.split(delimiter).map((dir) => resolve(cwd, dir, command)); for (const candidate of candidates) { try { await access(candidate, constants.X_OK); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT" || code === "ENOTDIR" || code === "EACCES") continue; throw new ExplicitInternalActivationError( `Pi executable resolution failed: ${String((error as Error).message)}`, { knownCause: "activation", cause: error }, ); } return await realpath(candidate); } throw new ExplicitInternalActivationError(`Pi executable not found: ${command}`, { knownCause: "activation", }); } async function selectedPiIdentity( command: string, cwd: string, env: NodeJS.ProcessEnv, ): Promise { const executable = await resolveSelectedPi(command, cwd, env); const { stdout } = await execFileAsync(executable, ["--version"], { cwd, env, encoding: "utf8", }); const version = stdout.trim(); if (version === "") throw new Error(`Pi executable returned an empty version: ${executable}`); return { executable, version }; } /** * Default child runner: canonically select `pi` on PATH (or PI_BINARY) and launch * that exact file. Close settles exactly once for natural return / error / SIGTERM. */ export function createDefaultPiSpawnRunner(options: { recordLaunchedPiIdentity?: ( runDirectory: string, identity: LaunchedPiIdentity, ) => Promise; }): PiSpawnRunner { return async (args, spawnOptions) => { const command = spawnOptions.env.PI_BINARY ?? "pi"; const piIdentity = await selectedPiIdentity(command, spawnOptions.cwd, spawnOptions.env); return await new Promise((resolveResult, reject) => { // Child stdout is discarded at the stdio seam (CLAUDE.md Role invocation // evidence). Do not pipe or accumulate it. stderr stays piped for diagnostics. const child = spawn(piIdentity.executable, [...args], { cwd: spawnOptions.cwd, env: spawnOptions.env, stdio: ["pipe", "ignore", "pipe"], }); if (child.stdin === null) { throw new Error("Pi child stdin pipe was not created"); } if (child.stderr === null) { throw new Error("Pi child stderr pipe was not created"); } let stdinDeliveryError: Error | undefined; child.stdin.on("error", (error) => { stdinDeliveryError ??= error; }); if (spawnOptions.stdin !== undefined) { child.stdin.write(spawnOptions.stdin); } child.stdin.end(); let stderr = ""; let timedOut = false; // No default wall clock. Only an explicit caller budget arms a timer (ADR 0010). // SIGKILL is unconditionally forbidden — graceful SIGTERM only. let timer: ReturnType | undefined; let settled = false; // `error` fires for pre-spawn failures (ENOENT after identity check is a // true activation failure) or kill/dispatch errors. Retain it so `close` // remains the SOLE settlement point (spec-B: child close once). Only // fall back to rejecting on `error` if `close` never fires (e.g. spawn // never succeeded so no `close` event will arrive). let hasSpawned = false; let executionError: Error | undefined; const armTimeoutAfterChildReady = (): void => { if (spawnOptions.timeoutMs === undefined) return; timer = setTimeout(() => { timedOut = true; child.kill("SIGTERM"); }, spawnOptions.timeoutMs); }; // Parent cancellation reaches the nested activation: same graceful SIGTERM, // same single close settlement. SIGKILL stays forbidden (#675 / ADR 0010). const parentSignal = spawnOptions.signal; const terminateForParentAbort = (): void => { child.kill("SIGTERM"); }; parentSignal?.addEventListener("abort", terminateForParentAbort, { once: true }); let identityRecorded: Promise = Promise.resolve(); child.once("spawn", () => { hasSpawned = true; if (parentSignal?.aborted === true) terminateForParentAbort(); armTimeoutAfterChildReady(); const runDirectory = spawnOptions.env.AK_ROLE_RUN_DIR; if ( typeof runDirectory === "string" && runDirectory !== "" && options.recordLaunchedPiIdentity !== undefined ) { identityRecorded = options.recordLaunchedPiIdentity(runDirectory, piIdentity); } }); child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += chunk; }); child.on("error", (error) => { // A pre-spawn error has no child lifecycle to close. After spawn, // retain the execution error and let the mandatory close event own // cleanup and the single settlement. if (settled || hasSpawned) { executionError = error; return; } if (timer !== undefined) clearTimeout(timer); parentSignal?.removeEventListener("abort", terminateForParentAbort); settled = true; reject(error); }); child.on("close", (code) => { if (timer !== undefined) clearTimeout(timer); parentSignal?.removeEventListener("abort", terminateForParentAbort); void identityRecorded.then( () => { if (settled) return; settled = true; if (executionError !== undefined) { reject(executionError); return; } if (stdinDeliveryError !== undefined) { reject(stdinDeliveryError); return; } resolveResult({ code, stderr, timedOut, }); }, (error) => { if (settled) return; settled = true; reject(error); }, ); }); }); }; } /** Create the production Pi RoleTurnHost (composition-root assembly). */ export function createPiRoleTurnHost(config: PiRoleTurnHostConfig): RoleTurnHost { const spawnRunner = config.spawnRunner ?? createDefaultPiSpawnRunner({ ...(config.recordLaunchedPiIdentity === undefined ? {} : { recordLaunchedPiIdentity: config.recordLaunchedPiIdentity }), }); return { async executeTurn(request: RoleTurnRequest): Promise { // #617 DK-7: Pi argv gets projected native paths once; never record bytes. // Pi already owns its own session file, so only sitian prior volume rides in. // #879: station-child officer dialogue keeps peer words — do not splice // host-transition priorNativePaths into the review prompt body. let turnRequest = request; const officerStationChild = request.stationChild === true && isOfficerReviewSeat(request.activation.role); const paths = !officerStationChild && request.hostTransition?.priorNativeKind === "sitian" ? request.hostTransition.priorNativePaths : undefined; if ( request.continuation.kind === "resume" && paths !== undefined && paths.length > 0 ) { turnRequest = { ...request, continuation: { ...request.continuation, prompt: `${request.continuation.prompt}\n${paths.join("\n")}`, }, }; } const roleEntry = await realpath(resolveInternalRoleEntrypoint(config.packageRoot)); const extraArgs = buildPiTurnExtraArgs( turnRequest, config.principalAuthority, config.extraPiArgs ?? [], ); const args = buildExplicitInternalActivationArgs(roleEntry, extraArgs); const stdin = encodeUserDialogueStdin(piUserDialogueBody(turnRequest)); // Shared envelope isolates this call's court identity: omitting courtAttemptId // must not inherit a parent process.env.AK_ROLE_COURT_ATTEMPT (#637). const env: NodeJS.ProcessEnv = { ...process.env, HOME: request.home, PI_CODING_AGENT_DIR: request.agentDir, AK_ROLE_RUN_DIR: request.runDirectory, // Nested public summons resolve package root without import.meta under jiti (#675). // Child-process scoped only — not written back onto the parent process.env. AK_ROLE_PACKAGE_ROOT: config.packageRoot, }; if (request.courtAttemptId === undefined) delete env.AK_ROLE_COURT_ATTEMPT; else env.AK_ROLE_COURT_ATTEMPT = request.courtAttemptId; // Public-invocation scope (#537): omit must not inherit a parent env value. if (request.invocationScopeId === undefined) delete env.AK_ROLE_INVOCATION_SCOPE; else env.AK_ROLE_INVOCATION_SCOPE = request.invocationScopeId; // Selected host axis (#537 / ADR 0082): omit must not inherit a parent env value. if (request.host === undefined || request.host.trim() === "") delete env.AK_ROLE_HOST; else env.AK_ROLE_HOST = request.host.trim(); applyEngineChildEnv(env, request.engine); // Nested auditor dossier tool binds the parent run pointer when published. if ( typeof process.env.AK_ROLE_AUDITOR_SOURCE_RUN === "string" && process.env.AK_ROLE_AUDITOR_SOURCE_RUN.trim() !== "" ) { env.AK_ROLE_AUDITOR_SOURCE_RUN = process.env.AK_ROLE_AUDITOR_SOURCE_RUN; } // Audited-subject input selects soul materials (same for nested and direct). if ( typeof process.env.AK_ROLE_AUDITOR_SUBJECT === "string" && process.env.AK_ROLE_AUDITOR_SUBJECT.trim() !== "" ) { env.AK_ROLE_AUDITOR_SUBJECT = process.env.AK_ROLE_AUDITOR_SUBJECT; } if ( config.recordLaunchedRolePackageIdentity !== undefined && config.observeLaunchedRolePackageIdentity !== undefined ) { await config.recordLaunchedRolePackageIdentity( request.runDirectory, await config.observeLaunchedRolePackageIdentity(config.packageRoot, roleEntry), ); } const timeoutMs = request.timeoutMs ?? config.timeoutMs; return await spawnRunner(args, { cwd: request.cwd, env, stdin, ...(timeoutMs === undefined ? {} : { timeoutMs }), ...(request.signal === undefined ? {} : { signal: request.signal }), }); }, }; } import { sitianReport } from "../sitian-facade.ts"; /** * Append one custom JSONL entry to the durable principal's session file. * Pi session codec only — AK artifact O_EXCL retention stays in public-cli. */ export async function appendPiSessionCustomEntry( authority: DurablePrincipalAuthority, principal: DurablePrincipal, customType: string, data: unknown, ): Promise { const { sessionFile } = authority.decode(principal); const text = await readFile(sessionFile, "utf8"); let parentId: string | null = null; for (const line of text.trim().split("\n").filter(Boolean)) { const entry = JSON.parse(line) as { id?: unknown; type?: unknown }; if (typeof entry.id === "string" && entry.type !== "session") parentId = entry.id; } const timestamp = new Date().toISOString(); const pointerLine = `${JSON.stringify({ type: "custom", customType, data, id: randomUUID(), parentId, timestamp, })}\n`; await appendFile(sessionFile, pointerLine, "utf8"); try { sitianReport({ level: "event", kind: "dispatch-error", sessionParent: sessionFile, payload: { customType, data }, source: "pi-role-turn-host", }); } catch {} }