/** * Pi error / exit containment (T11761 · S1 · T11897). * * The Pi agent loop runs **in-process** inside the Cleo daemon with ZERO * authority. Two library packages are in the safe import set * (`@earendil-works/pi-agent-core` + `@earendil-works/pi-ai`) and both are * verified exit-clean, but the daemon MUST be protected from a process * termination originating from a Pi code path. This module is the containment * boundary: it converts a `process.exit()` call or a `process.exitCode` mutation * attempted while running Pi code into a typed {@link PiContainmentError} thrown * back to the caller. * * ## Containment scope (honest guarantee) * * The `process.exit` trap is process-global and REF-COUNTED across overlapping * {@link wrapPiCall} scopes, so it covers the synchronous body, every awaited * continuation, AND any deferred/detached exit (`setTimeout`, un-awaited * promise, microtask) that fires while ANY Pi call is still active. The ONLY * residual window is an exit fired AFTER the last active call has settled. To * close that too — a daemon-lifetime guarantee covering ANY present/future Pi * exit, synchronous or detached — the daemon calls {@link installDaemonExitGuard} * once at startup to PIN the trap for its whole lifetime. * * Scope discipline (S1): NO `pi-ai`/`pi-agent-core` imports, NO DB access, NO * LLM calls. Import-time side-effect free — the logger is resolved lazily * (never at module top level). * * @epic T10403 * @task T11761 * @task T11897 */ import { ExitCode } from '@cleocode/contracts'; import { CleoError } from '../../errors.js'; /** * Stable string codes for the Pi-containment error surface. * * `CleoError` carries a NUMERIC {@link ExitCode}; these string codes are the * machine-readable discriminators a caller switches on, carried on * {@link PiContainmentError.piCode} (and mirrored into the LAFS error `details` * via `CleoErrorDetails`). They are NOT exit codes themselves. */ export type PiContainmentCode = /** Pi (or a transitive dep) called `process.exit()` while in-process. */ 'E_PI_PROCESS_EXIT_TRAPPED' /** Pi (or a transitive dep) mutated `process.exitCode` while in-process. */ | 'E_PI_EXIT_CODE_MUTATION_TRAPPED' /** The wrapped Pi call was aborted via the supplied `AbortSignal`. */ | 'E_PI_ABORTED' /** The wrapped Pi call threw an unexpected error. */ | 'E_PI_LOOP_FAILED'; /** * Typed containment error for the Pi in-process embed. * * Extends {@link CleoError} so it flows through the existing LAFS error * projection (`toLAFSError`/`toProblemDetails`). It carries the Pi-specific * string {@link PiContainmentCode} on {@link piCode} (the numeric `ExitCode` * super-field is `INTERNAL`/`GENERAL_ERROR`-class — it is the daemon, not the * caller, that is being protected, so the failure is an internal one). */ export declare class PiContainmentError extends CleoError { /** Machine-readable Pi-containment discriminator. */ readonly piCode: PiContainmentCode; /** * @param piCode - The Pi-containment discriminator. * @param message - Human-readable description. * @param options - Optional `cause` and the numeric `ExitCode` (defaults to * {@link ExitCode.GENERAL_ERROR}). */ constructor(piCode: PiContainmentCode, message: string, options?: { cause?: unknown; exitCode?: ExitCode; }); } /** * Type guard for {@link PiContainmentError} (cross-realm-safe via `piCode`). * * @param err - The value to test. * @returns `true` when `err` is a {@link PiContainmentError}. */ export declare function isPiContainmentError(err: unknown): err is PiContainmentError; /** * Convert an arbitrary thrown value into a {@link PiContainmentError}. * * A value that is ALREADY a {@link PiContainmentError} passes through unchanged * (so an exit-trap error is not re-wrapped as a generic loop failure). Anything * else becomes an `E_PI_LOOP_FAILED` carrying the original as `cause`. * * @param err - The thrown value. * @returns A typed {@link PiContainmentError}. */ export declare function wrapPiError(err: unknown): PiContainmentError; /** * Pin the `process.exit` trap for the ENTIRE daemon lifetime. * * This is the durable, complete fix for the async-exit leak: once pinned, the * trap is NEVER restored, so a `process.exit()` originating from ANY Pi code path * — synchronous, awaited, deferred (`setTimeout`), detached (fire-and-forget), * present or future — is neutralized for as long as the daemon runs. Call this * once at daemon startup BEFORE any Pi-touching work is dispatched. * * Idempotent. The returned function un-pins (e.g. for a graceful shutdown that * legitimately needs to exit); after un-pinning, the trap is restored when no * {@link wrapPiCall} scope is active. * * @returns A function that un-pins the daemon-lifetime trap. * * @example * ```ts * // daemon bootstrap, before serving requests: * const unpin = installDaemonExitGuard(); * // ... daemon runs; all Pi exits are trapped for the whole lifetime ... * unpin(); // only if a controlled shutdown must reach the real process.exit * ``` */ export declare function installDaemonExitGuard(): () => void; /** * Un-pin the daemon-lifetime `process.exit` trap (idempotent). * * Identical to the closure returned by {@link installDaemonExitGuard}, but * callable WITHOUT that handle — for code paths that must reach the real * `process.exit` yet do not hold the closure (e.g. a daemon bootstrap-failure * handler in a different module). After un-pinning, the trap is restored once no * {@link wrapPiCall} scope is active. A no-op if the guard was never pinned. * * Idempotent: safe to call multiple times and from multiple paths. */ export declare function releaseDaemonExitGuard(): void; /** * Run a Pi-touching async function under process-exit containment + abort * routing. * * Behaviour: * - Acquires the REF-COUNTED, process-global {@link acquireExitTrap} for the * duration of `fn` and releases it in a `finally`. A `process.exit()` call — * synchronous, awaited, OR deferred/detached but firing while ANY Pi call is * still active — becomes a thrown {@link PiContainmentError} (the daemon * survives). A `process.exitCode` mutation is detected-and-restored the instant * `fn` settles, then surfaced as a thrown `E_PI_EXIT_CODE_MUTATION_TRAPPED`. * For a guarantee that spans even an exit fired AFTER the last call settles, * the daemon should additionally pin the trap once at startup via * {@link installDaemonExitGuard}. * - If `signal` is already aborted, rejects with `E_PI_ABORTED` WITHOUT invoking * `fn`. Otherwise the signal is propagated to `fn` (the caller threads it into * Pi's agent loop); an abort that surfaces as a thrown `AbortError`/`DOMException` * is normalized to `E_PI_ABORTED`. * - Any other thrown value is normalized via {@link wrapPiError}. * * @typeParam T - The resolved value type of the wrapped call. * @param fn - The Pi-touching async function. Receives the (live) `AbortSignal` * so it can thread cancellation into the agent loop. * @param signal - Optional cancellation signal. * @returns The resolved value of `fn`. * @throws {PiContainmentError} On exit-trap, abort, or any failure from `fn`. * * @example * ```ts * const result = await wrapPiCall(async (sig) => runAgentLoop(prompts, ctx, cfg, emit, sig), signal); * ``` */ export declare function wrapPiCall(fn: (signal: AbortSignal) => Promise, signal?: AbortSignal): Promise; //# sourceMappingURL=pi-errors.d.ts.map