/** * session-dispatch.ts, how work that arrives for a session THIS surface hosts * reaches the loop, now that the register is not in this process. * * ── The seam ─────────────────────────────────────────────────────────────── * * A surface composition used to own a persisting `SharedSessionBroker`, and the * broker's `setContinuationRunner` was how the graph said "when a continuation * arrives for a session, spawn this". The broker was the register AND the * dispatcher, and the surface owned both. * * As a client it owns neither. The daemon holds the register; a surface only * needs to RECEIVE dispatch for sessions it is running. That is exactly the * `SessionContinuationDispatch` seam the client shape takes, one method, * `setContinuationRunner`, and this module satisfies it over the wire: * `sessions.inputs.list` for continuation-intent inputs on the sessions this * surface hosts, the bound runner for each, `sessions.inputs.deliver` to * acknowledge. * * ── The reply half ───────────────────────────────────────────────────────── * * The runner returns the id of the agent it started, and that id is not * bookkeeping, it is the reply binding. When the daemon spawns a continuation * itself, `SharedSessionBroker.bindAgent` pairs the agent with the input it was * started for and announces the pairing, which is how an answer to a message * that arrived from Telegram/Slack/ntfy finds its way back to that * conversation. Dispatched over the wire, the agent runs HERE, so nothing in * the daemon could make that pairing: the id was dropped, no binding existed, * and a channel message answered by a surface was answered into the void. * * So this dispatcher reports both halves of the pairing: * - on dispatch, `deliver` carries `agentId` and marks the input DELIVERED, * "collected, and this agent is answering it". The daemon binds the reply * there. * - when that agent finishes, `deliver` carries the answer and marks the input * COMPLETED, "finished acting on it, here is what it said". The daemon * writes it into the session and pushes it down the reply pipeline, exactly * as its own completion poll does for the agents it spawned itself. * * The finish half needs a way to read this surface's own agent outcomes, which * is the `readAgentOutcome` option. A host that does not supply one keeps the * old single-acknowledgement behavior (still carrying `agentId`, so the reply * is bound) rather than silently claiming an answer it cannot produce. * * ── Discipline (inherited from the spine client, deliberately) ───────────── * * Every wire call is best-effort and never throws into the render or keystroke * path. A failed poll leaves the cursor where it was, so the input is retried * next tick; `deliver` is the only de-duplication, because an input already * advanced past `queued` is not returned again. Nothing here blocks a turn. * * ── Why it polls ─────────────────────────────────────────────────────────── * * The same reason the inbound steer poller does: this is not a hot path, a * continuation arrives seconds apart at most, and a poll survives a suspended * laptop and a dropped tunnel without a reconnect state machine. The SSE stream * carries the same transitions for anything that genuinely needs per-token * latency. */ import { logger } from '../../utils/index.js'; import type { SharedSessionInputRecord } from '../../control-plane/index.js'; import type { SessionContinuationDispatch } from '../client-services.js'; import { type AgentCompletionRecordView } from '../../agents/completion-answer.js'; /** What a surface's own agent register says about a dispatched run. */ export interface SurfaceAgentOutcome { readonly status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; /** The finished output. Only read on a terminal status; empty is a real answer (silence). */ readonly answer?: string | undefined; } /** * Map a surface's own agent record onto the outcome this dispatcher reports. * * A missing record is `null`, "this surface no longer knows about that run", * and is deliberately distinct from a run still in flight. The answer text is * the SHARED rule (agents/completion-answer.ts), the same one the daemon * renders for the runs it hosts itself, so an answer does not read differently * depending on which process happened to execute it. */ export declare function readSurfaceAgentOutcome(record: AgentCompletionRecordView | null | undefined): SurfaceAgentOutcome | null; /** * The narrow inbound wire surface this dispatcher needs, `sessions.inputs.list` * and `sessions.inputs.deliver`. A product's own operator client satisfies it * structurally, so a test injects a stub instead of a port. */ export interface SessionInputsWireClient { listInputs(sessionId: string, options: { readonly state?: string; readonly since?: number; readonly limit?: number; }): Promise<{ readonly inputs: readonly SharedSessionInputRecord[]; }>; deliverInput(sessionId: string, inputId: string, options?: { readonly consumed?: boolean | undefined; readonly agentId?: string | undefined; readonly answer?: string | undefined; readonly status?: 'completed' | 'failed' | 'cancelled' | undefined; }): Promise; } export interface WireSessionDispatchOptions { /** The sessions this surface is hosting right now. Re-read every tick. */ readonly hostedSessionIds: () => readonly string[]; /** * This surface's own read of a dispatched agent's state. Supplying it is what * lets the answer reach the conversation the message came from; omitting it * keeps the reply binding but leaves the answer un-reported. */ readonly readAgentOutcome?: ((agentId: string) => SurfaceAgentOutcome | null) | undefined; /** Poll interval; defaults to two seconds. */ readonly intervalMs?: number; readonly log?: Pick; readonly now?: (() => number) | undefined; } export interface WireSessionDispatch extends SessionContinuationDispatch { /** Attach the wire once a daemon has been adopted. Idempotent per base URL. */ activate(client: SessionInputsWireClient): void; /** Detach; the bound runner is kept so a re-adopted daemon resumes dispatch. */ deactivate(reason: string): void; /** Stop polling entirely. Idempotent. */ stop(): void; } /** * A dispatch seam backed by the adopted daemon's session inputs. * * Inert until `activate`, a surface with no daemon adopted holds its runner and * dispatches nothing, which is the honest offline posture rather than a missing * dependency. */ export declare function createWireSessionDispatch(options: WireSessionDispatchOptions): WireSessionDispatch; //# sourceMappingURL=session-dispatch.d.ts.map