import type { ConfigManager } from '../config/manager.js'; import type { SecretsManager } from '../config/secrets.js'; import type { ServiceRegistry } from '../config/service-registry.js'; import type { AgentManager } from '../tools/agent/index.js'; import type { SharedSessionBroker } from '../control-plane/index.js'; import type { ChannelPluginRegistry, ChannelReplyPipeline, RouteBindingManager } from '../channels/index.js'; import type { DeliveredChannelReply, UndeliveredChannelReply } from '../channels/reply-pipeline.js'; import type { SharedSessionSurfaceReplyBinding } from '../control-plane/session-intents.js'; import type { SharedApprovalRecord } from '../control-plane/index.js'; import type { PendingSurfaceReply, SurfaceNoticeDelivery } from './types.js'; import type { StructuredNotice } from '../email/inbound-notice.js'; type DeliverySurface = 'slack' | 'discord' | 'ntfy' | 'webhook' | 'homeassistant' | 'telegram' | 'google-chat' | 'signal' | 'whatsapp' | 'telephony' | 'imessage' | 'msteams' | 'bluebubbles' | 'mattermost' | 'matrix'; type RouteBinding = import('../automation/routes.js').AutomationRouteBinding; interface SurfaceReplyInput { readonly agentId: string; readonly task: string; readonly agentTask?: string | undefined; readonly workflowChainId?: string | undefined; readonly sessionId?: string | undefined; } interface WebhookReplyInput extends SurfaceReplyInput { readonly routeId?: string | undefined; readonly callbackUrl?: string | undefined; readonly callbackCorrelationId?: string | undefined; readonly callbackSignature?: PendingSurfaceReply['callbackSignature'] | undefined; } interface DaemonSurfaceDeliveryContext { readonly pendingSurfaceReplies: Map; readonly channelReplyPipeline: ChannelReplyPipeline; readonly configManager: ConfigManager; /** Required: surface credentials that are `goodvibes://secrets/...` references resolve through this. */ readonly secretsManager: Pick; readonly serviceRegistry: ServiceRegistry; readonly agentManager: AgentManager; readonly sessionBroker: SharedSessionBroker; readonly routeBindings: RouteBindingManager; readonly channelPlugins: ChannelPluginRegistry; readonly authToken: () => string | null; readonly surfaceDeliveryEnabled: (surface: DeliverySurface) => boolean; /** Records surface reply attempts in the shared delivery ledger. */ readonly recordDeliveryAttempt?: SurfaceDeliveryLedgerRecorder | undefined; } /** One entry the delivery ledger should show for a surface reply. */ export interface SurfaceDeliveryLedgerEntry { readonly deliveryId: string; readonly agentId: string; readonly sessionId?: string | undefined; readonly routeId?: string | undefined; readonly surfaceKind: string; readonly targetId: string; readonly phase: 'queued' | 'started' | 'succeeded' | 'failed'; readonly error?: string | undefined; } export type SurfaceDeliveryLedgerRecorder = (entry: SurfaceDeliveryLedgerEntry) => void; export declare class DaemonSurfaceDeliveryHelper { private readonly context; constructor(context: DaemonSurfaceDeliveryContext); /** * Track the reply an agent owes a conversation. * * Idempotent by agent id. Several paths legitimately reach here for the same * agent, the broker announces the pairing centrally and the adapter that * spawned it also asks, and re-tracking would reset the pipeline's event * buffer and republish everything already sent. First writer wins. */ queueSurfaceReplyFromBinding(binding: RouteBinding | undefined, input: SurfaceReplyInput): boolean; /** * The shared reply-routing point: an agent is going to answer a message that * arrived over a channel, so make sure its answer has somewhere to go. * * Wired to SharedSessionBroker.setSurfaceReplyBinder, which announces the * pairing from inside the broker, the one place every ingress converges on. * Fixing it here rather than in an adapter is what makes a message landing in * an EXISTING live session get an answer, on all fifteen delivery surfaces, * without each adapter having to remember. * * Never silent: when no delivery can be created for a message that demonstrably * came from a channel, that is a produced answer with nowhere to go, and it is * reported at error with the surface, session, binding and reason, plus a * failed ledger entry so "should have sent, did not" is visible rather than * indistinguishable from "nothing happened". */ ensureSurfaceReply(binding: SharedSessionSurfaceReplyBinding): boolean; /** Record a reply that reached its conversation. */ recordDeliveredReply(reply: DeliveredChannelReply): void; /** Record a reply that reached the pipeline but never reached the conversation. */ recordUndeliveredReply(reply: UndeliveredChannelReply): void; private describeRefusal; /** * Fall back to the session's own route bindings when the announcement did not * carry one, a follow-up typed into a session that a channel is attached to * still belongs to that channel. */ private resolveBindingFromSession; private recordLedger; /** * Send a single short message to whatever surface a binding points at, * without creating a tracked agent reply. * * This is what the conversation-first gate uses to put a work proposal (and * its accept/decline acknowledgement) on the channel the message arrived on. * * It sends through the SAME channel render path a conversational reply takes *, `channelPlugins.render` -> the surface's renderEvent -> the channel * delivery router, which is why a proposal is deliverable on every surface * the platform can already talk to. Telegram is the case that proves it: the * bot could always answer a chat message, because that answer went through * the router's `sendMessage` strategy, while the gate's notice went through * `deliverSurfaceProgress`, which is implemented for slack/discord/ntfy only. * A gated surface with no notice path is a black hole, the owner is asked * nothing and the daemon waits for an answer to a question never posed. * * `deliverSurfaceProgress` remains only as the fallback for a surface whose * plugin is not registered at all (an embedder with its own registry). */ /** * Deliver a notice that is still STRUCTURE, escaping it for whatever surface * the binding points at. * * This is the entry point anything holding a `StructuredNotice` must use, * and the reason it exists rather than leaving callers to render first is * that the destination is not knowable at the call site. The binding is * resolved here, so the escaper is chosen from the surface the message will * actually land on, a caller cannot pick the wrong one, and cannot skip * escaping, because it never holds a string to pass. * * That is the same structural-over-conventional rule the inbound-mail path * runs on elsewhere: the producer (`renderInboundMailNotice`) cannot emit a * channel-formatted string, and this is the only place one is made. * * A surface with no verified escaper gets fully-neutralized plain text, not * the raw span concatenation, see `noticeChannelForSurface`. */ deliverStructuredNotice(binding: RouteBinding | undefined, notice: StructuredNotice): Promise; deliverSurfaceNotice(binding: RouteBinding | undefined, text: string): Promise; queueWebhookReply(input: WebhookReplyInput): void; /** * A surface that ran the turn in ITS OWN process reports the answer. * * `pollPendingSurfaceReplies` below is the same three steps, write the * answer into the shared session, push it down the reply pipeline, drop the * pending entry, driven by this daemon's own AgentManager. That loop can * only ever see agents THIS process spawned, so an answer produced by a TUI * or agent process that collected the input over `sessions.inputs.list` was * invisible to it: the pending reply sat until the pipeline's own retention * dropped it, and the conversation got silence. * * Returns whether a channel delivery was attempted. `false` is the ordinary * outcome for a session with no channel behind it (a local terminal), and is * not an error, the session message is still written. */ completeSurfaceReplyFromSurface(input: { readonly agentId: string; readonly sessionId?: string | undefined; readonly body: string; readonly status?: 'completed' | 'failed' | 'cancelled' | undefined; }): Promise; pollPendingSurfaceReplies(syncFinishedAgentTask: (record: import('../tools/agent/index.js').AgentRecord) => void): Promise; /** * Collaborators for the direct per-surface senders in * surface-direct-delivery.ts (this file was over the line cap). These stay as * methods because facade-composition wires them by name into the daemon route * context and the builtin channel plugins. */ private directDeliveryDeps; /** * Push a one-line status to a surface directly. slack/discord/ntfy only, * every other surface throws by name. This is the FALLBACK; the general path * is the channel delivery router. See surface-direct-delivery.ts. */ deliverSurfaceProgress(pending: PendingSurfaceReply, progress: string): Promise; deliverSlackAgentReply(pending: PendingSurfaceReply, message: string): Promise; deliverDiscordAgentReply(pending: PendingSurfaceReply, message: string): Promise; deliverNtfyAgentReply(pending: PendingSurfaceReply, message: string): Promise; deliverWebhookAgentReply(pending: PendingSurfaceReply, message: string): Promise; notifyApprovalUpdate(approval: SharedApprovalRecord): Promise; controlPlaneWebUrl(input: { readonly approvalId?: string; readonly sessionId?: string | undefined; }): string | undefined; signWebhookPayload(body: string, secret: string): string; private buildPendingSurfaceReply; /** * Per-surface approval rendering lives in surface-approval-delivery.ts (this * file was over the 800-line cap). These stay as methods because * facade-composition wires them by name into the daemon route context. */ private approvalDeliveryDeps; deliverSlackApprovalUpdate(approval: SharedApprovalRecord, binding: RouteBinding): Promise; deliverDiscordApprovalUpdate(approval: SharedApprovalRecord, binding: RouteBinding): Promise; deliverNtfyApprovalUpdate(approval: SharedApprovalRecord, binding: RouteBinding): Promise; deliverWebhookApprovalUpdate(approval: SharedApprovalRecord, binding: RouteBinding): Promise; private resolveSlackWebhookUrl; private resolveSlackBotToken; private resolveConfigSecret; private renderAgentCompletionForSurface; } export {}; //# sourceMappingURL=surface-delivery.d.ts.map