import type { AgentNotificationMethod, AgentNotificationParamsByMethod, AgentRequestMethod, AgentRequestParamsByMethod, AgentRequestResponsesByMethod, ContentBlock, SendRequestOptions, SessionModeState, StopReason } from "@agentclientprotocol/sdk"; import type { AgentHistoryEntry, AgentSessionRef, McpServerConfig, PromptImage } from "@automatalabs/shared-types"; import type { RunOptions } from "@automatalabs/shared-types"; import { WorkflowError } from "@automatalabs/shared-types"; import type { TSchema } from "typebox"; import type { Backend, BackendId } from "./backend.js"; import type { NegotiatedCapabilities } from "./capabilities.js"; import { type PooledConnection, type SessionHandle, type SteeringOutcome } from "./acp-client.js"; import type { AcpEventListener, AcpEventName } from "./events.js"; import type { ElicitationResolver, PermissionResolver } from "./permissions.js"; /** Options for AcpAgentRunner.openSession(): backend selection and session/new inputs for one * held-open interactive ACP session. `cwd` is required and absolute; unlike run(), there is no * default to process.cwd() because the session can span many turns. The session-scoped * permission/elicitation resolvers win over the runner-wide defaults; tool allow/deny policy is * used only when no permission resolver is present. */ export interface InteractiveSessionOptions { /** Model spec: registered first segment routes once; any remaining id is sent verbatim. */ model?: string; /** Structured-output contract for this session's turns (same dialect `run()` accepts). * Folded into the backend's native schema channels exactly like `run()`: session/new * `_meta` for backends that carry the schema there (Claude), per-turn `_meta` for * backends that forward it on the turn (Codex, custom), and — for backends whose agent * may ignore the `_meta` forward entirely (`embedSchemaInPrompt`) — into the prompt * text itself. The schema does not change the interactive contract otherwise: the host * drives the repair ladder itself (e.g. `resolveStructuredOutput` over the session) and * reads the result through `currentTurnText()`/`finalMessageText()`/`rawStructuredOutput()`. * The client-hosted StructuredOutput capture tool is never injected on the interactive * path (it is a per-call run() device). */ schema?: TSchema; /** Agent-advertised session mode id. Strict: openSession fails rather than running unconfined. */ mode?: string; /** Agent-advertised ACP session config options, applied verbatim in sorted option-id order. */ configOptions?: Record; /** Coarse tier consulted only when `model` is unset. */ tier?: string; /** Absolute working directory for ACP session/new. Required for held-open sessions. */ cwd: string; /** Tool allow-list used by the headless permission auto-responder. */ toolNames?: string[]; /** Tool deny-list, applied after the allow-list. */ disallowedToolNames?: string[]; /** Session-scoped permission resolver; overrides the runner-wide resolver for this session. */ onPermissionRequest?: PermissionResolver; /** Session-scoped elicitation resolver; overrides the runner-wide resolver for this session. */ onElicitation?: ElicitationResolver; /** The model id accepted for the session (display/telemetry). */ onModelResolved?: RunOptions["onModelResolved"]; /** Compatibility callback for non-resolution subsystems or third-party runners. */ onModelFallback?: RunOptions["onModelFallback"]; /** Event/telemetry label stamped onto this session's emitted ACP events. */ label?: string; /** Correlation id stamped into session/new `_meta` and emitted event context. */ runId?: string; /** Generic session-scoped `_meta` passthrough merged under backend-computed session meta. */ meta?: Record; /** CODEX-ONLY base instruction override, forwarded at session/new. */ baseInstructions?: string; /** CODEX-ONLY developer instruction override, forwarded at session/new. */ developerInstructions?: string; /** Client-provided MCP servers to attach at session/new. */ mcpServers?: McpServerConfig[]; /** Keep accumulated text/history after each prompt turn; default false for held-open sessions. */ retainSessionLog?: boolean; /** Skip the release-time best-effort ACP `session/close` so the agent-persisted session stays * re-openable later (`loadSession`/`resumeSession` with this session's `sessionRef`). The * dedicated process is disposed either way. Default false (close when advertised). */ keepSession?: boolean; /** Host-owned cancellation. Aborting releases this interactive session. */ signal?: AbortSignal; } /** One completed interactive prompt turn. `text` is the assistant text from THIS turn only: * it is read from SessionHandle.currentTurnText(), the same turn-segmented accessor run() uses * for structured-output repair turns. */ export interface InteractiveTurn { readonly stopReason: StopReason; readonly text: string; } type Subscribe = (name: K, listener: AcpEventListener) => () => void; /** Internal construction bag for the runner-owned wrapper around an already-open ACP session. */ interface InteractiveSessionDeps { readonly session: SessionHandle; readonly connection: PooledConnection; readonly backend: Backend; readonly subscribe: Subscribe; readonly onRelease: (self: InteractiveSession) => void; readonly signal?: AbortSignal; readonly label?: string; readonly cwd: string; readonly keepSession: boolean; /** The session's structured-output contract (see `InteractiveSessionOptions.schema`). */ readonly schema: TSchema | undefined; } /** A held-open multi-turn ACP session backed by a dedicated agent process. Only one prompt may * be in flight at a time; hosts that want queued turns should serialize calls themselves so * cancellation, permissions, and turn text stay attributable to a single active turn. If the * dedicated process dies, the runner observes that per session by auto-releasing this wrapper: * session-scoped listeners are removed, later prompt() calls fail with the released-session * error, and `session_close` is emitted on this session's event stream. The connection-scoped * `backend_error` event is emitted on the runner bus only; it is not delivered through * session.on(). */ export declare class InteractiveSession { readonly sessionId: string; readonly backendId: BackendId; private readonly session; private readonly connection; private readonly backend; private readonly subscribe; private readonly onReleaseCallback; private readonly signal; private readonly label; private readonly subscriptions; /** The re-attach arm's release watchers (see `waitForRelease`). */ private readonly releaseWatchers; private readonly cwd; private readonly keepSession; /** The session's structured-output contract (see `InteractiveSessionOptions.schema`). */ private readonly schema; private removeAbort; private promptInFlight; private releasePromise; /** Construct the public wrapper around an already-open ACP session. Hosts normally receive * instances from AcpAgentRunner.openSession(), which supplies the internal session/connection * dependencies and owns lifecycle tracking. */ constructor(deps: InteractiveSessionDeps); /** Negotiated initialize capabilities for this session's dedicated connection. */ get capabilities(): NegotiatedCapabilities | undefined; /** Agent-advertised session mode catalog plus the currently active mode, if supported. */ get modes(): SessionModeState | null | undefined; /** Assistant text accumulated in this session's retained log. */ get text(): string; /** The latest turn's assistant text (turn-segmented, like `run()`'s * no-schema result path). Added for the REPL broker's result shaping; * additive passthrough to `SessionHandle`. */ currentTurnText(): string; /** The latest turn's FINAL assistant message (the schema-extraction * source `run()` uses; prose extraction over the whole turn would * resurrect the first-JSON-wins bug for schema-shaped progress * messages). Added for the REPL broker's structured-output ladder; * additive passthrough to `SessionHandle`. */ finalMessageText(): string; /** The latest turn's assistant text with the §5 message joiner: * same-message deltas concatenate verbatim and distinct messages join * with "\n\n". Additive passthrough to `SessionHandle`. */ foldedTurnText(): string; /** This session's structured-output contract (set at open via * `InteractiveSessionOptions.schema`), or undefined for plain sessions. */ get outputSchema(): TSchema | undefined; /** Claude's raw `structured_output` for the latest turn, if any (the * native structured channel the runner's ladder tries first). Added * for the REPL broker's structured-output ladder; additive passthrough * to `SessionHandle`. */ rawStructuredOutput(): unknown; /** Message/tool history accumulated in this session's retained log. */ get history(): readonly AgentHistoryEntry[]; /** Send one prompt turn. A concurrent prompt on the same InteractiveSession is rejected with a * clear host-side error; queueing is deliberately left to the host so turn boundaries remain * explicit. Per-turn images are appended only to this prompt, and SessionHandle.prompt() * performs capability adaptation before sending. * * The `onHandoff` option is the host's explicit handoff acknowledgment: it fires exactly * once the prompt has passed every preflight check (released session, aborted signal, * prompt-in-flight, image validation) AND the underlying ACP session/prompt request has * actually been invoked — the call below runs synchronously through request construction * and the wire send, so by the time the acknowledgment fires the payload is on the wire: * the point of no return. A host that records a "delivered" marker for the prompt (the * REPL broker's queued-steer delivery marker) MUST record it here rather than when the * returned promise is created: an async pre-handoff rejection (released session, aborted * signal, or prompt-in-flight) never reaches this line, and a marker recorded before it * would make a restore skip a turn that was never delivered. The acknowledgment firing * AFTER the invocation is the crash-boundary contract (review regression: it used to * fire BEFORE, so a crash in that interval left a durable "delivered" marker on a prompt * the backend never received — and a restore then skipped a never-delivered turn): a * crash before the acknowledgment leaves the prompt undelivered-in-the-store and a * restore re-issues it (at-least-once); a crash after it would replay a turn that is * already on the wire, which the marker's host prevents. A throwing callback aborts the * turn — its error propagates through the normal mapping — but the backend prompt is * ALREADY invoked at that point, so the turn is the host's delivery-failure path, never * a not-sent turn. */ prompt(content: string | ContentBlock[], opts?: { images?: readonly PromptImage[]; promptMeta?: Record; onHandoff?: () => void; }): Promise; /** Inject a follow-up into the prompt currently in flight. Idle callers must use prompt(): * steering has no client-owned turn, output, usage, or retry path. Concurrent steer calls are * sent independently and left to the backend's ordering semantics. */ steer(content: string | ContentBlock[], opts?: { images?: readonly PromptImage[]; promptMeta?: Record; }): Promise; /** RAW protocol escape hatch for held-open sessions. Params carry `sessionId` explicitly; * use `session.sessionId` so the wire call targets this session. Prefer named wrappers when * they exist because they preserve engine semantics such as accumulation/drain and usage * recording; calling session/prompt here bypasses those paths. */ request(method: Method, params: AgentRequestParamsByMethod[Method], options?: SendRequestOptions): Promise; request(method: string, params?: Params, options?: SendRequestOptions): Promise; /** Switch this session's ACP operating mode. Fails strictly when the agent did not advertise it. */ setMode(modeId: string): Promise; /** RAW protocol notification escape hatch for held-open sessions. Params carry `sessionId` * explicitly; use `session.sessionId` so the wire call targets this session. */ notify(method: Method, params: AgentNotificationParamsByMethod[Method]): Promise; notify(method: string, params?: Params): Promise; /** Best-effort ACP session/cancel for the active turn. Pending permission/elicitation * resolvers are settled as cancelled by the SessionHandle/PooledConnection cancel path. */ cancel(): Promise; /** * The loaded session's founding-turn completion — the re-attach arm's task * source (phase D of the REPL orchestrator roadmap; the broker drives this * on a session re-opened with `runner.loadSession()` after a daemon * restart). Resolves with the turn that was in flight at the backend when * the session was loaded, so a re-attached call's continuation fires * exactly once, through the same record → settle → consume pump as a live * call. * * **The authoritative-completion seam** (the spec-owed decision, * documented here). Completion evidence comes from TWO channels, by * backend class: * * 1. **The `_session/loaded_turn` vendor extension** (the steering- * extension precedent; advertised at initialize * (`_meta.loadedTurn.supported === true`), served by the in-repo * `@automatalabs/pi-acp` and `@automatalabs/codex-acp`): * `session/load` obliges the agent to replay the entire persisted * conversation before resolving (the runner marks the LOAD BOUNDARY * synchronously after the response), and the seam then asks the * backend `_session/loaded_turn/query` whether the founding turn is * still running RIGHT NOW. The backend answers with one of three * terminal classifications: * * - **`running`** — the founding turn is still executing at the * backend. The seam KEEPS THE LOADED SESSION ATTACHED and waits * for the `_session/loaded_turn/ended` notification — the turn's * authoritative terminal marker (a quiet gap is only a * progress-stream gap, never terminal evidence; the notification * fires when the turn ends, carrying the stop reason or the * error). It absorbs the turn's live `session/update` stream * meanwhile, so a completion settles with the turn's REAL * accumulated text. The wait is bounded by * `LOADED_TURN_MAX_WAIT_MS` (default 15 min; * `AGENTPRISM_ACP_LOADED_TURN_MAX_WAIT_MS` — the "never hang * unobserved" backstop); a bound expiry rejects with the * `LoadedTurnStillRunningError` (the broker re-arms the wait on * the still-attached session — the notification may still arrive * later). * - **`completed`** — no turn is running, and the founding turn * observably completed while this host was down: the replay's * trailing assistant message is its FINAL message, so the seam * resolves with it IMMEDIATELY (`{ stopReason: "end_turn", text }` * — the stop reason is synthesized because the protocol's replay * carries none; the text is the turn's real accumulated outcome, * and the broker's result-shaping ladder reads the same * transcript). A backend that answers `completed` while the * replay does NOT end with an assistant message contradicts * itself — the final message is not in the replay, so the seam * rejects with the safe-re-issue class. * - **`interrupted`** — no turn is running, and the founding turn * ended without a terminal assistant message (it was * interrupted/failed/abandoned while the host was down). Its * outcome is not observable, but nothing is running at the * backend, so the seam rejects with the SAFE-RE-ISSUE class (the * broker re-issues under the same call id — no duplication * possible). * * A QUERY FAILURE (the capability gate or a wire error) is NOT the * missing extension: the seam falls THROUGH to the observation path * below instead of classifying unobservable (phase-F review round * 2 — the loaded session may still be executing, and a * possibly-running call is never released-and-re-issued). * * 2. **The observation path — backends WITHOUT the extension (the * built-in claude and opencode backends today), and extension * backends whose query failed.** The authoritative observation is * the loaded session's OWN stream plus its replay, under the * CONNECTION-DEATH CONTRACT (live-verified against the current * built-in servers): every built-in ACP server terminates its * sessions' in-flight turns when the client connection closes — * claude-agent-acp and pi-acp exit on connection close and cancel * their turns (`connection.closed.then(shutdown)` → teardown → * cancel + kill), `opencode acp` exits on stdin EOF, and codex-acp * ends/kills the codex process — and their persisted transcripts * contain only COMPLETED messages. So after a daemon crash the * founding turn is NEVER still running at the backend, and the * replay's trailing content is authoritative: an assistant message * is the turn's terminal message (completed while down); anything * else means the turn died mid-way (interrupted — nothing running, * safe to re-issue). The one caveat is the in-flight-wire race — * content still streaming when the load response resolves — so the * seam first runs a bounded POST-LOAD CONTINUATION WATCH * (`LOADED_TURN_OBSERVE_MS`, default 1 s; * `AGENTPRISM_ACP_LOADED_TURN_OBSERVE_MS`): any CONTENT update * applied after the load boundary is LIVE CONTINUATION — the * authoritative still-running signal — and flips the classification * to the keep-attached wait (below). No content within the window * → classify from the replay (completed / interrupted) — but ONLY * on a VERIFIED BUILT-IN backend (`connectionDeathVerified`: the * four built-in instances; a custom registry entry's * connection-death behavior is not live-verified, so its quiet * window is NOT terminal evidence — phase-F review round 3: the * replay classification used to apply to every extension-less * backend and every query failure, so a durable custom backend * could have a still-running turn settled from stale/partial * replay or re-issued, violating the no-duplicate invariant). The * window * is the spec-owed concrete decision replacing the rejected * quiet-grace heuristic: the grace is bounded AND the classification * rests on the connection-death contract, never on a quiet gap * alone (phase-D review round 3 rejected the unbounded settle-from- * trailing-chunk guess; a still-running turn's quiet parks are * never settled here — a park produces no content, but for the * built-ins no turn can be running at restore in the first place). * * **The keep-attached still-running wait** (both channels): the * loaded session stays attached, the turn's live stream is absorbed, * and the seam waits for the terminal state — the `_session/loaded_turn/ended` * notification when the backend pushes one (an extension backend, or * a seam-less backend that sends it anyway), the max-wait bound (the * "never hang unobserved" backstop), or the session's release. A * bound expiry rejects with the `LoadedTurnStillRunningError`, and * the broker RE-ARMS the wait on the still-attached session (phase-F * review round 2: a possibly-running call is never re-issued — the * re-issue arm is reserved for observably-dead calls); a cancel or * the broker's drain settles it. * * The unconditional arms stay: a handle that was never load-marked * (not produced by the runner's `loadSession` path), and a transcript * with no user message at all (the recorded session never received its * prompt — nothing reached the backend), both reject immediately with * the safe-re-issue class. A released/dead session mid-wait rejects * through the same plain class (a dead process means the backend turn * died with it — re-issue is safe; the broker's own teardown releases * are handled by the broker's drain state). */ awaitCurrentTurn(): Promise; /** * The observation path — backends WITHOUT the `_session/loaded_turn` * extension (the built-in claude and opencode backends today), and * extension backends whose query failed (see `awaitCurrentTurn`'s * doc for the full semantics — the connection-death contract, the * post-load continuation watch, and the replay probe). Never settles * a quiet gap, never re-issues a possibly-running turn: the * classification is authoritative ONLY for the VERIFIED BUILT-INS * (`connectionDeathVerified`) because their ACP servers terminate * in-flight turns when the client connection closes (live-verified), * and their replay holds only completed messages. A CUSTOM backend * (a registered registry entry — its connection-death behavior is * NOT live-verified) that stays quiet through the window is NOT * classified from the replay: its turn may still be running at the * backend, so the seam keeps the loaded session attached and waits * for the authoritative terminal state instead (phase-F review round * 3: the quiet-window-plus-replay classification used to apply to * every extension-less backend and every query failure, so a durable * custom backend could have a still-running turn settled from stale * replay or re-issued — the no-duplicate invariant requires the * verified assumption to be restricted to the verified backends). */ private observeLoadedTurn; /** Is this session's backend one of the four built-ins whose ACP * servers' connection-death behavior is LIVE-VERIFIED (claude, * codex, opencode, pi — every built-in server terminates its * sessions' in-flight turns when the client connection closes, so a * restored session's replay holds only completed messages)? The * observation path's quiet-window-plus-replay classification is * authoritative ONLY for these; a CUSTOM backend (a registered * registry entry — even one that shadows a built-in name) can keep a * turn running while quiet, so its quiet window degrades to the * keep-attached still-running wait (phase-F review round 3: the * verified assumption must be restricted to the verified built-ins). * Instance-based: a custom backend registered under a built-in name * is a `CustomAcpBackend` and is never counted as verified. */ private get connectionDeathVerified(); /** The post-load continuation watch: resolve true on the first CONTENT * update applied after the load boundary (live-continuation evidence — * the authoritative still-running signal), false when the observation * window elapses without one (or the session is released — the caller * re-checks `releasePromise` before classifying). Bookkeeping updates * (usage, mode, available commands — claude emits an * `available_commands_update` right after every load) never count: * the flag flips only on content updates. */ private waitForPostLoadContent; /** * The keep-attached still-running wait (the extension's `running` arm * AND the observation path's live-continuation arm): the loaded * session stays attached, the turn's live stream is absorbed, and the * seam waits for the terminal state — the `_session/loaded_turn/ended` * notification when the backend pushes one, the max-wait bound (the * "never hang unobserved" backstop), or the session's release — * whichever comes first (no polling: a long still-running turn is * observed with zero busy work). A bound expiry rejects with the * re-armable `LoadedTurnStillRunningError`: the broker re-arms the * wait on the still-attached session — a later ended notification — or * a cancel — still settles the call (phase-F review round 2: a * possibly-running call is never re-issued). */ private waitForRunningLoadedTurn; /** The `_session/loaded_turn/ended` resolution: the turn that was running * at load ended. A turn that ended with an ERROR is a definite * rejection (never settled as success — `LoadedTurnFailedError`, the * settle-as-rejection class the broker records and delivers); a turn * that ended with a response resolves with its stop reason (the * notification's, restricted to the ACP vocabulary — a server-specific * reason the seam does not speak synthesizes `end_turn`, exactly like * the completed-while-down arm) and the accumulated text. */ private loadedTurnEndedResult; /** Resolve on the session's next `_session/loaded_turn/ended` * notification (the re-attach arm's authoritative terminal wait — * zero polling; the subscription is one-shot and removed the moment it * fires, and a notification that already arrived fires immediately). */ private nextLoadedTurnEnded; /** Subscribe to runner events for THIS ACP session only. Events from other one-shot or * interactive sessions on the same runner are filtered out by sessionId. The returned * unsubscribe thunk and every still-live subscription are removed automatically on release. */ on(name: K, listener: AcpEventListener): () => void; /** Resolve when the session is released (or immediately when it * already is) — the re-attach arm's release watch, so a session that * dies or is disposed while the seam waits unblocks the wait instead * of parking it until the max-wait expiry. */ private waitForRelease; /** Release the ACP session and close the dedicated process. Idempotent. Session close is * best-effort and bounded by SessionHandle; process disposal mirrors pool teardown. */ release(): Promise; /** The loaded session's recorded founding-turn terminal state (the * `_session/loaded_turn/ended` notification, when the backend pushed * one — a seam-less backend that sends it anyway), or null when the * turn has not ended (yet). The broker's non-re-armable settlement * wait reads this surface instead of re-invoking a seam that can * never observe the terminal state. */ loadedTurnEndedState(): { stopReason?: string; error?: { name: string; message: string; }; } | null; /** Watch the loaded-turn-ended channel: the listener fires when the * `_session/loaded_turn/ended` notification arrives (and immediately * for a notification that already arrived). Returns the unsubscribe * thunk. The broker's non-re-armable settlement wait's observability * surface (the same channel the seam's own wait subscribes to). */ subscribeLoadedTurnEnded(listener: () => void): () => void; /** Resolve when the session is released (its dedicated process died or * was disposed); never resolves on a live session. The broker's * non-re-armable settlement wait's release watch — a released * session means the backend turn died with the process (the * safe-re-issue class). */ released(): Promise; /** The re-attach handle for this session — persist it, then re-open later with * `runner.loadSession()`/`resumeSession()` (`backendId` doubles as the `model` routing spec). * Reopen flags mirror the connected agent's advertised persistence; an agent that persists * nothing leaves them all false and this ref is a tombstone once released. */ get sessionRef(): AgentSessionRef; private doRelease; private removeSubscriptions; } /** The re-attach arm's duplicate-risk rejection: the loaded session's * founding turn MAY STILL BE RUNNING at the backend and its terminal * state is unobservable — a `running`-classified turn produced no * terminal notification within the max-wait bound (the observation * path's live-continuation arm included). The host must NEVER settle * partial output (a quiet gap is only a progress-stream gap) and NEVER * re-issue a possibly-running call: the broker KEEPS THE LOADED SESSION * ATTACHED and re-arms the seam on it — the doc's second reconciliation * arm, re-attach to a still-running task — for every form of this * rejection (phase-F review round 2: the old non-re-armable form * pushed the broker to release the loaded session and re-issue the * call, which could duplicate a still-running backend turn; re-issue * is now reserved for the observably-dead classes). A later terminal * notification — or a cancel — still settles the call. The marker * property is structural, so third-party adapter seams can throw the * same class of rejection; `rearmable` is retained for compatibility * with those seams (the broker re-arms both forms). */ export declare class LoadedTurnStillRunningError extends Error { readonly rearmable: boolean; readonly loadedTurnStillRunning = true; constructor(message: string, rearmable: boolean); } /** The re-attach arm's settle-as-rejection class: the loaded session's * founding turn RAN and FAILED at the backend (the `_session/loaded_turn/ * ended` notification carried its error). A definite outcome — the host * records and settles it as a rejection, exactly like a live prompt that * rejects; it is never re-issued (the task already ran to a terminal * state) and never settled as success (partial text is not an outcome). * Marker property is structural, like `LoadedTurnStillRunningError`. */ export declare class LoadedTurnFailedError extends WorkflowError { readonly loadedTurnFailed = true; constructor(message: string); } /** Is this a loaded-turn still-running rejection (the broker's * never-settle-a-quiet-gap classification)? Structural marker, so * third-party adapter seams can throw the same class. */ export declare function isLoadedTurnStillRunningError(error: unknown): error is LoadedTurnStillRunningError; /** Is this a loaded-turn failed-at-backend rejection (the broker's * settle-as-rejection classification)? Structural marker, so third-party * adapter seams can throw the same class. */ export declare function isLoadedTurnFailedError(error: unknown): error is LoadedTurnFailedError; export {}; //# sourceMappingURL=interactive.d.ts.map