import { spawn } from 'node:child_process'; /** * Work that outlives a tool call, owned by whoever started it. * * `bash` had no background mode, and the reason it could not simply grow one * is recorded in the commit that removed the suggestion from its schema: on * the `linux-namespace` isolation tier the wrapping `sh` is PID 1 of a fresh * PID namespace, the kernel destroys a PID namespace when its init exits, * and a backgrounded grandchild goes with it. `sh -c "long-thing & echo go"` * therefore returns in milliseconds looking like it worked, with the work * already dead — on the SUCCESSFUL path, not on timeout or abort. * * So backgrounding cannot be delegated to the shell. **This registry holds * the process itself**, for its whole life, which is what keeps the * namespace alive and gives the job an identity to poll, output to read, and * an owner to be torn down with. * * Every bound here is a refusal rather than a silent adjustment, and the * output cap reports what it dropped. A background job whose tail vanished * quietly is worse than one that was refused: the model reads it as the * whole output and concludes the build passed. */ export type BackgroundJobStatus = 'running' | 'exited' | 'killed'; export interface BackgroundJob { readonly id: string; /** Whoever the job dies with — a turn id, or the session id for a job bound to the session. */ readonly owner: string; readonly command: string; readonly status: BackgroundJobStatus; readonly startedAt: number; readonly exitedAt?: number; readonly exitCode?: number; readonly signal?: string; } export interface BackgroundJobOutput { readonly chunk: string; /** * Pass back as `fromOffset` to continue. Counted in bytes over the whole * stream INCLUDING what the cap dropped, so a caller polling in a loop * cannot silently re-read or skip. */ readonly nextOffset: number; /** * Bytes the cap discarded before `chunk`. Never silent: a job whose tail * vanished quietly reads as a complete result that happens to be short. */ readonly droppedBytes: number; readonly status: BackgroundJobStatus; readonly exitCode?: number; } /** A job's process, when something other than the registry starts it. */ export interface JobProcess { readonly child: ReturnType; /** How to stop it, when the registry's process-group kill would not reach everything. */ kill?(signal: NodeJS.Signals): void; } export interface StartJobParams { readonly owner: string; readonly command: string; readonly workingDirectory: string; readonly env?: Readonly>; /** * Start the process yourself — a sandbox does, so the job runs inside * its boundary. Absent, the registry runs `/bin/sh -c command` on the * host. The process must be the leader of its own group and must not * expect stdin. */ readonly spawn?: () => JobProcess; } export interface BackgroundJobRegistryConfig { /** * Refused past this many LIVE jobs for one owner. * * Per owner rather than global: one turn spawning a hundred watchers must * not be able to refuse a different turn its first. */ readonly maxJobsPerOwner?: number; /** Retained output per job. Oldest bytes go first, and are counted. */ readonly maxOutputBytesPerJob?: number; } /** A start that would exceed a declared bound. */ export declare class BackgroundJobLimitError extends Error { readonly details: { owner: string; limit: number; }; constructor(details: { owner: string; limit: number; }); } /** An id nothing in this registry knows. */ export declare class UnknownBackgroundJobError extends Error { readonly details: { id: string; }; constructor(details: { id: string; }); } export declare class BackgroundJobRegistry { private readonly config; private readonly jobs; private counter; private readonly exitListeners; /** * Be told when a job ends, whoever owns it. A job outlives the call that * started it, so the one thing the model could not do was learn that it * had finished without polling; a turn subscribes here and turns the exit * into a notice on its next tool result. Returns the unsubscribe. */ onExit(listener: (job: BackgroundJob) => void): () => void; private announceExit; constructor(config?: BackgroundJobRegistryConfig); private get maxJobs(); private get maxBytes(); /** Live jobs for one owner, oldest first. */ list(owner: string): readonly BackgroundJob[]; start(params: StartJobParams): BackgroundJob; /** The record, or throw for an id this registry does not know. */ get(id: string): BackgroundJob; /** * Await one job's exit — the public counterpart to `onExit`, for a * caller that wants a single result rather than a standing * subscription. Resolves at once for a job that has already stopped, so * a caller that lost the race against a fast-finishing job never blocks * on a promise that would otherwise never settle. * * `signal` is honoured: an aborted wait rejects and detaches rather than * holding the internal exit promise's continuation open for a job that * may run for another hour. That only ends the WAIT — the job itself is * untouched either way, exactly as a timed-out `kill`-less wait leaves * it. See `wait-for-job-bounds.ts`, the first caller. */ waitForExit(id: string, opts?: { signal?: AbortSignal; }): Promise; /** * Output since `fromOffset`, with what the cap dropped stated. * * Offsets count the whole stream rather than the retained buffer, so a * poller that falls behind the cap is TOLD it fell behind instead of * being handed a seamless-looking excerpt. */ read(id: string, opts?: { fromOffset?: number; }): BackgroundJobOutput; /** SIGTERM the tree, then SIGKILL after the shared grace period. */ kill(id: string): Promise; /** * Kill everything one owner started. * * The teardown call. Without it a turn that ends leaves its jobs running * with nothing left that knows their ids — the orphan this whole module * exists to make impossible. */ killOwner(owner: string): Promise; /** Drop the record for a job that has already stopped. */ forget(id: string): void; } /** * One owner's view of the registry. * * The owner is bound here rather than passed by the caller, which is the * whole point: a tool holding this cannot start a job billed to somebody * else's turn, nor read or kill one. `list` and the lookups are filtered to * the same owner, so an id from another turn reads as unknown — the same * answer the tenant checks give elsewhere in this tree, and for the same * reason. */ export declare function bindOwner(registry: BackgroundJobRegistry, owner: string, defaults?: { readonly workingDirectory?: string; readonly env?: Record; /** See `StartJobParams.spawn`; given the resolved command, directory and env. */ readonly spawn?: (params: { readonly command: string; readonly workingDirectory: string; readonly env?: Record; }) => JobProcess; /** * Be told that the model said it is waiting on a job's exit. * * The registry does not keep this: wait-intent belongs to the TURN that * expressed it, not to a registry a host may share across turns and * sessions. The turn passes its own recorder here — `AwaitedJobs`, which * is what the iteration loop holds open for. Absent means nobody is * listening, and `markAwaited` is then absent from the bound ref rather * than present and silently doing nothing. */ readonly onAwaited?: (id: string) => void; }): { kill: (id: string) => Promise; list: () => readonly BackgroundJob[]; markAwaited?: ((id: string) => void) | undefined; start: (params: { command: string; workingDirectory?: string; }) => BackgroundJob; get: (id: string) => BackgroundJob; read: (id: string, opts?: { fromOffset?: number; }) => BackgroundJobOutput; waitForExit: (id: string, opts?: { signal?: AbortSignal; }) => Promise; }; //# sourceMappingURL=registry.d.ts.map