import * as plugins from './plugins.js'; import { type IControllerTerminal, type IControllerTerminalSnapshotFrame, type TControllerTerminalId, type TControllerTerminalAgentKind, type TControllerTerminalAgentLaunchMode } from '../dist_ts_interfaces/index.js'; import type { IControllerResourceDocument } from './interfaces.projects.js'; export interface ITerminalOutputPayload { terminalId: TControllerTerminalId; /** * Absolute byte offset of this chunk in the terminal's output stream, or the offset a snapshot * frame's reconstructed state is exact at. */ offset: number; dataBase64: string; /** Set on every frame of a reconstructed screen state. */ snapshot?: IControllerTerminalSnapshotFrame; ended?: boolean; } /** * Only involuntary loss is announced. An explicit detach, a hung-up connection and a peer * superseding its own attachment are all client-initiated, so they need no notification. */ export type TControllerTerminalDetachReason = 'delivery_failed'; export interface IControllerPtySpawner { ensurePtySupport(): Promise; execSpawnStreamingInteractiveControlPty(commandArg: string, argsArg?: string[], optionsArg?: plugins.smartshell.IPtyDirectSpawnOptions): Promise; } export interface IControllerTerminalShell { executable: string; args: string[]; environment: NodeJS.ProcessEnv; } export type TControllerTerminalStopReason = 'exited' | 'closed'; export interface IControllerTerminalStartResult { terminal: IControllerTerminal; /** Which upstream flag an agent root resolved to; absent for plain shells. */ launchMode?: TControllerTerminalAgentLaunchMode; } export interface IControllerTerminalManagerOptions { resolveProjectDirectory: (projectIdArg: string) => Promise; sendOutput: (peerIdArg: string, payloadArg: ITerminalOutputPayload) => Promise; onTerminalsChanged: (projectIdArg: string) => void; onTerminalStopped: (projectIdArg: string, terminalIdArg: TControllerTerminalId, exitCodeArg: number | undefined, /** * `exited` is the root ending on its own, `closed` is the controller taking it down. Only the * former may record a stopped intent; the latter must leave an agent chat wanting to run. */ reasonArg: TControllerTerminalStopReason) => Promise; /** * An attachment ended without the peer asking for it. Purely informational: the manager has * already dropped the peer, and the notification travels over the same transport whose failure * usually caused it, so it is best effort. */ onPeerDetached?: (peerIdArg: string, terminalIdArg: TControllerTerminalId, reasonArg: TControllerTerminalDetachReason) => void; ptySpawner?: IControllerPtySpawner; resolveShell?: () => Promise; cleanupTimeoutMs?: number; /** * Delay schedule between output delivery attempts. The last entry is the clamp used once the * schedule is shorter than the attempt count. Configurable so tests do not sleep the * production budget. */ outputRetryBackoffMs?: readonly number[]; /** * Mints the caller credential this terminal's descendants present to `agl mcp`, so the * controller authorizes the chat running in the terminal instead of a caller-supplied task id. * Minted per start, which makes every restart mint a fresh credential and strand the old one. */ mintCallerCredential?: (inputArg: { projectId: string; resourceId: string; agentSessionId?: string; }) => string | undefined; } export declare const resolveControllerTerminalShell: (environmentArg?: NodeJS.ProcessEnv, platformArg?: NodeJS.Platform, validateExecutableArg?: (candidateArg: string, platformArg: NodeJS.Platform) => Promise) => Promise; export declare class ControllerTerminalManager { private readonly options; private readonly ptySpawner; private readonly resolveShell; private readonly cleanupTimeoutMs; private readonly outputRetryBackoffMs; private readonly entries; private readonly projectStates; private readonly activeRetirements; private readonly pendingRemovalCleanups; private state; private shell?; private initPromise?; private closePromise?; private pendingCreates; private readonly pendingCreatesByProject; private peerGeneration; constructor(options: IControllerTerminalManagerOptions); init(): Promise; listTerminals(projectIdArg: string): Promise; createTerminal(projectIdArg: string, titleArg?: string): Promise; resolveTerminalResourceMetadata(projectIdArg: string, titleArg?: string, agentKindArg?: TControllerTerminalAgentKind): Promise<{ title: string; terminal: NonNullable; }>; startTerminalResource(resourceArg: IControllerResourceDocument): Promise; stopTerminalResource(projectIdArg: string, resourceIdArg: string): Promise; isTerminalRunning(projectIdArg: string, resourceIdArg: string): boolean; /** * The sanitized shell environment plus this start's caller credential. The credential is written * explicitly here and is never inheritable, so a value present in the controller's own * environment can never reach a child and impersonate a terminal. */ private spawnEnvironmentFor; private spawnTerminal; renameTerminal(projectIdArg: string, terminalIdArg: string, titleArg: string): Promise; renameTerminalResource(projectIdArg: string, terminalIdArg: string, titleArg: string): Promise; removeTerminal(projectIdArg: string, terminalIdArg: string): Promise; attach(peerIdArg: string, projectIdArg: string, terminalIdArg: string, isPeerConnectedArg?: () => boolean): Promise; detach(peerIdArg: string, projectIdArg: string, terminalIdArg: string): Promise; detachPeer(peerIdArg: string): Promise; input(peerIdArg: string, projectIdArg: string, terminalIdArg: string, dataArg: Buffer): Promise; resize(peerIdArg: string, projectIdArg: string, terminalIdArg: string, rowsArg: number, colsArg: number): Promise; retireProject(projectIdArg: string, retireArg: () => Promise): Promise; closeAll(): Promise; private projectState; private pruneProjectState; private runProjectOperation; private waitForProjectDrain; private waitForAllProjectDrains; private waitForRemovalCleanups; private reserveCreate; private releaseCreate; private createTerminalId; private requireShell; private requireEntry; private assertEntryRunning; private assertAttached; private runEntryControl; private appendOutput; private schedulePeerDelivery; /** * One delivery pass: the reconstructed screen first when the peer owes one, then raw output up * to the end this pass was scheduled for. A snapshot already carries the stream up to at least * that end, so the range after it is usually empty and whatever arrived meanwhile is picked up * by the next pass. A pass that discovers the window moved past the cursor owes a snapshot * again and resolves it here rather than leaving it to the rescheduler: exit finalization calls * this directly and has no rescheduler, so a peer would otherwise be detached without its final * state and without `ended`. The repetition is bounded by the per-peer snapshot budget. */ private deliverToPeer; /** * Serializes the mirror at the stream position it has parsed up to and sends it as the frames * that open the peer's stream. The peer's cursor moves to that position, so live output picks * up exactly where the reconstructed state ends — no gap, no repeated byte. * * Returns false when the peer went away, which leaves delivery to the caller's own teardown. A * mirror that is gone, or a peer that cannot be caught up within its snapshot budget, throws: * the delivery budget's own catch detaches the peer and announces it, where returning would * leave the peer owing a snapshot nobody can deliver. */ private deliverSnapshot; private deliverOutputRange; /** * Delivers one frame against a bounded retry budget. Re-sending is idempotent at the client * because a raw frame carries its absolute offset, a snapshot frame carries its position inside * the snapshot — both documented on the protocol as the rule a client deduplicates by — and the * peer cursor only advances after a delivery resolves. When a deadline is supplied the caller is * exit finalization, which owns the deadline outright — retrying there would fight it, so a * single attempt is made. */ private deliverPeerFrame; private announcePeerDetached; private readOutputFrame; private sendPeerOutput; private detachPeerState; private applyMinimumSize; private applyMinimumSizeUntilStable; private requestSizeReconciliation; private confirmRootExit; private finalizeExitedPeer; private finalizeExitedEntry; /** * Gives up on an entry whose root could not be confirmed stopped within its deadline. Such an * entry can no longer serve anyone — admission is refused from the moment a close is signalled — * so its emulator is released rather than left parsing a pty that outlived its kill for the rest * of the controller's life. Peers are left in place: a root that does exit later still finalizes * them, and one that owes a snapshot is detached through the delivery budget instead of being * served a state nobody tracks any more. */ private abandonEntry; /** * SIGTERM lets an agent root flush its transcript and run its own shutdown hooks, which is what * makes the conversation resumable rather than truncated. It is bounded so that a hung flush * cannot consume the shutdown deadline shared by every other close. */ private terminateGracefully; private closeEntry; private closeProjectEntries; }