/** * ChatV2DispatcherService — deliver user-origin chat messages to the * bound agent session. * * Flow: * HTTP POST /api/chat/channels/:id/messages * → ChatV2Service.sendMessage (persist + dedupe) * → controller broadcasts WS message frame (user bubble) * → controller calls ChatV2Dispatcher.dispatchToAgent(channel, messageDTO) * → sendMessageToAgent(agent_session, "[CHAT:] \n") * → agent processes + calls `reply-chat` skill * → skill POSTs back to /api/chat/channels/:id/messages as the agent * → same controller path → WS message frame (agent bubble) * * This service is intentionally thin — a couple of dozen lines of glue * around the AgentRegistrationService's `sendMessageToAgent` primitive. * The dispatch prompt is extracted so tests can assert the format, which * is the contract the `reply-chat` skill relies on. * * @module services/chat-v2/chat-v2.dispatcher.service */ import type { ChatChannelDTO, ChatMessageDTO } from './types.js'; import type { ChatV2MentionResolver, MentionTarget } from './chat-v2.mention-resolver.js'; import { RoomContextBacklog } from './room-context-backlog.js'; import { type QueueMessageMeta } from '../messaging/queue-priority.js'; /** Pause before the one retry of a failed activate-on-send (a deferred start is not retried: it is queued) */ export declare const ACTIVATE_RETRY_DELAY_MS = 3000; /** * Minimal surface the dispatcher needs from the agent-registration service. * Typed explicitly so tests can pass a stub without pulling in the full * AgentRegistrationService class. */ export interface AgentMessageSink { sendMessageToAgent(sessionName: string, message: string): Promise<{ success: boolean; error?: string; message?: string; queued?: boolean; }>; } /** Result returned by `dispatchToAgent`. */ export interface DispatchResult { /** True when the runtime accepted the message (or queued it for delivery). */ dispatched: boolean; /** * The agent could not be started right now (running-agent cap) and the * message waits on its queue for the start. Delivered, not failed. */ deferred?: boolean; /** Filled when `dispatched === false`. */ error?: string; } /** One per-recipient outcome produced by `dispatchMessage`. */ export interface MentionDispatchOutcome { /** The mention target the dispatcher attempted to deliver to. */ target: MentionTarget; /** Underlying delivery result from `AgentMessageSink.sendMessageToAgent`. */ dispatched: boolean; /** Error message, if delivery failed. */ error?: string; } /** * Phase B-2 (2026-05-17) — one per-member outcome for huddle dispatch. * * Huddles don't go through `MentionResolver` — their roster is read * directly from `chat_channel_members`. The outcome records whether * the member was @-mentioned (which controls `responseMode` in the * prompt) and whether the underlying sink call succeeded. */ export interface HuddleDispatchOutcome { /** Member's agent session name. */ sessionName: string; /** * `'required'` when the member appeared in `message.mentions[]` * (they MUST reply); `'optional'` otherwise (they MAY reply if it * makes sense). Surfaced in the formatted prompt. */ responseMode: 'required' | 'optional'; /** Whether the prompt was successfully queued for the member. */ dispatched: boolean; /** * The member's start is deferred (running-agent cap): the prompt waits on * its persisted queue and is delivered once the agent starts. Delivered, * not failed — {@link dispatched} is true. */ deferred?: boolean; /** * The agent was busy: the prompt waits on its queue (`[AGENT_BUSY]` hold) * and reaches it when it is idle. It holds the message — nobody else should * be handed it meanwhile. */ queued?: boolean; /** Error message, if delivery failed. */ error?: string; /** * Set when this member's prompt told it to stay silent by default * ({@link isSilentByDefault}). Nobody is waiting on such a member, so the * owner-message watchdog does not track it. */ silentByDefault?: boolean; } /** * Aggregate result of `dispatchMessage`. Reports the chosen routing * strategy and per-recipient outcomes so callers (and tests) can assert * exactly what fan-out happened without re-running the resolver. */ export interface DispatchMessageResult { /** * - `'dm'`: legacy 1:1 path — used by `type='dm'` channels. * - `'channel-mentions'`: Phase C BE.3 fan-out — used by `type='channel'` * channels with at least one resolved mention. * - `'skip'`: nothing was dispatched (non-user message, no recipients, * archived/unbound channel, etc.). `reason` is filled. */ strategy: 'dm' | 'channel-mentions' | 'huddle-broadcast' | 'skip'; /** True when at least one underlying delivery succeeded. */ dispatched: boolean; /** DM only: the agent's start is deferred and the message waits on its queue. */ deferred?: boolean; /** Filled when `strategy === 'skip'`. */ reason?: string; /** Populated for `strategy === 'dm'`. */ dmResult?: DispatchResult; /** * Populated for `strategy === 'channel-mentions'`. One entry per * resolved target. Empty when no mentions resolved. */ mentionOutcomes?: MentionDispatchOutcome[]; /** * Phase B-2 — populated for `strategy === 'huddle-broadcast'`. One * entry per huddle member. Mentioned members carry * `responseMode: 'required'`; the rest are `'optional'`. */ huddleOutcomes?: HuddleDispatchOutcome[]; /** * One-responder dispatches (Slack rooms): local members that were not * delivered the message but got it queued as context only, for their * next prompt from this room (specs/2026-10-03-one-responder-per-message.md). */ contextOnly?: string[]; } /** * A responder fixed by the caller from data only it has * (specs/2026-10-03-one-responder-per-message.md §1 a / c). */ export interface PinnedResponder { /** * The local member that answers. Null: nobody here answers — the * decision path already delivered it, or the responder runs on another * machine. */ session: string | null; /** The responder's display name, for the context-only line */ name?: string; /** Why this responder */ reason: 'decision' | 'decision-consumed' | 'thread-owner' | 'deferred'; /** Who answers it, wherever it runs (local session or Cloud's name); recorded for the reply gate */ answeredBy?: string; /** * Nobody here answers, but this machine runs the 90 s unanswered watch * (it is the room's owner machine): an owner message is never left * without a watcher (specs/2026-10-03-one-responder-per-message.md §4). */ watchHere?: boolean; /** * With `watchHere`: this machine only tells the owner if nobody has * answered by `NOTE_ONLY_WATCH_MS`, and never hands the message over — it * is not the room's watcher, or cannot tell whether it is. */ watchNoteOnly?: boolean; /** A local member that already holds the message (the decision's asker): no context entry for it */ alreadyHas?: string; } /** One responder per owner message (Slack rooms). */ export interface OneResponderOptions { /** Set when the caller already knows the responder (decision card, Slack thread owner) */ pinned?: PinnedResponder; /** Display name of a local session, for the context-only line */ nameFor?: (agentSession: string) => string | undefined; } /** Constructor options for {@link ChatV2DispatcherService}. */ export interface ChatV2DispatcherOptions { agentSink: AgentMessageSink; /** * Told after every dispatch of a user message, with its outcome — the * unanswered-owner-message watchdog tracks what reached an agent * (specs/2026-09-30-owner-message-guarantee.md). Must not throw; errors are * logged and ignored. */ onDispatched?: (channel: ChatChannelDTO, message: ChatMessageDTO, result: DispatchMessageResult) => void | Promise; /** * Whether a message must not reach an agent at all (issue #968: an agent * dedicated to one person never gets — or is woken by — anyone else's * message). Checked before every delivery. Must not throw. */ refuseDelivery?: (sessionName: string, message: ChatMessageDTO) => Promise; /** * Whether the owner wrote a message. An owner message that has to wait * for a busy agent goes to the front of its queue, and is dropped there * only as an exact duplicate (see queue-priority.ts). * Must not throw. Without it every message keeps the plain queue order. */ isOwnerMessage?: (message: ChatMessageDTO) => boolean; /** * Override the prompt formatter for tests / future customization. The * default matches the `reply-chat` skill's parser exactly. */ formatPrompt?: (args: FormatPromptArgs) => string; /** * Phase C BE.3 — resolver used when dispatching a `type='channel'` * message to its mentioned recipients. When omitted, channel-typed * messages skip dispatch entirely (preserves the BE.1+BE.2 contract * for callers that haven't wired the resolver yet, e.g. legacy unit * tests). The production composition root injects a resolver backed * by `StorageService.getTeams()`. */ mentionResolver?: ChatV2MentionResolver; /** * Phase B-2 (2026-05-17) — roster lookup used when dispatching a * `type='huddle'` message to every member. Synchronous because the * production implementation is a direct SQLite read against * `chat_channel_members`. When omitted, huddle messages skip * dispatch (mirrors how `mentionResolver` gates `'channel'` rows). * * Returns the list of `agentSession` ids for the huddle's members. * Order doesn't matter — the dispatcher loops over the whole list. */ huddleMembersFor?: (channelId: string) => readonly string[]; /** * Agents already engaged in a thread (posted in it, or @-mentioned in * it). A human's follow-up inside that thread is delivered to them * without another @. When omitted, thread follow-ups need an @. */ threadParticipantsFor?: (channelId: string, threadId: string) => readonly string[]; /** The agent that spoke last in a thread — a bare follow-up addresses it. */ lastThreadSpeakerFor?: (channelId: string, threadId: string) => string | null; /** * The team leader behind a huddle (team channel). A message that @'s * nobody and sits in no agent thread goes to the leader alone, marked * optional — the leader judges relevance (it may be humans talking to * each other). When omitted such messages are delivered to nobody. */ huddleLeaderFor?: (channelId: string) => Promise; /** * Activate-on-send hook. When a DM dispatch fails because the agent's * session doesn't exist (inactive), the dispatcher calls this to start the * agent, then retries delivery once. Returns true when the agent is now * active. User-initiated, so it bypasses the orchestrator wake-gate by * design. When omitted, inactive sends just queue (legacy behavior). */ activateAgent?: (agentSession: string) => Promise; /** * Holds a prompt on the agent's persisted queue. Used when activate-on-send * answers `'deferred'` (the start waits for a running-agent slot): the * message is not lost and not failed, it is delivered when the agent starts. * When omitted a deferred start is a failure, as before. */ holdForStart?: (agentSession: string, prompt: string) => void; /** * Puts a prompt on the agent's persisted queue before activate-on-send * starts the agent, without scheduling anything. Starting an agent takes * seconds to minutes; a restart in that window used to lose the message, * which lived only in the awaiting call (steamfun-ops 2026-10-21: the * owner's DM to Avery died with the auto-update restart and was never * answered). Held first, it survives the restart: the boot restore brings * the agent back for its queued messages and register-self delivers them. * Needs {@link releaseHeld}; when either is omitted nothing is held early. */ holdDuringActivation?: (agentSession: string, prompt: string) => void; /** * Takes the copy {@link holdDuringActivation} put on the queue back off it. * * @returns True when the copy was still queued and is now removed; false * when it is gone (the agent registered during the start and its queue * flush already took it) */ releaseHeld?: (agentSession: string, prompt: string) => boolean; /** * Pause before the one retry of a failed (not deferred) activation, ms. * Defaults to {@link ACTIVATE_RETRY_DELAY_MS}. */ activateRetryDelayMs?: number; /** * The messages that came before this one, newest last. * * Injected rather than read here so the dispatcher keeps no opinion about * storage, and so a caller can decline to provide any — an install that * would rather not spend the tokens simply leaves it out. */ recentTurnsFor?: (channelId: string, threadId?: string) => readonly ChatContextTurn[]; /** * Last step before a message reaches an agent: files a colleague on another * machine sent (`crewly-file:` markers) are downloaded and the text is * rewritten with their local paths. Must not throw; omitted = text as is. */ prepareInbound?: (channel: ChatChannelDTO, message: ChatMessageDTO) => Promise; /** Context-only queue (one-responder rooms); a private one when omitted */ contextBacklog?: RoomContextBacklog; } /** One earlier message, as the prompt will show it. */ export interface ChatContextTurn { /** Who said it, already resolved to a display name */ senderId: string; /** What they said */ content: string; /** When, ISO-8601 */ createdAt: string; /** Whether this one is in the same thread as the message being dispatched */ inThread?: boolean; } /** Inputs to the prompt formatter. */ export interface FormatPromptArgs { /** One line naming the channel's members and how to @ them. */ channelRoster?: string; /** * Whether this agent was named — an `@` in a channel, or a DM to it. * * A thread follow-up reaches the last speaker without an `@`, so it is * `required` yet unaddressed; that is exactly when an agent should answer * but not act (2026-09-21). Left undefined by callers that only ever * address one agent. */ addressedDirectly?: boolean; channelId: string; channelName: string; agentSession: string; senderId: string; content: string; clientMessageId?: string; /** * Phase B-2 — in a huddle, every member receives the prompt but * only the @-mentioned ones MUST reply. The default formatter * adapts the reply instruction to mandatory or optional * (the agent decides whether to reply) so the agent's prompt-following heuristics can branch. Defaults * to `'required'` for DMs and `'channel'` (single-mention) dispatches * where every recipient was explicitly addressed. */ responseMode?: 'required' | 'optional'; /** * Slack team channels — the chat-v2 thread the reply must land in * (root message id). When set, the hint tells the agent to pass * `--thread ` so the outbound mirror can post into the same Slack * thread. */ threadId?: string; /** * Which skill the hint names. `'reply-chat'` (default, legacy) posts via * the orchestrator-side chat endpoint; `'reply-channel'` writes straight * into the chat-v2 channel as the agent and supports `--thread`. Slack * team channels use `'reply-channel'`. */ replyVia?: 'reply-chat' | 'reply-channel'; /** What was said before, oldest first. Omitted when there is nothing to show. */ context?: readonly ChatContextTurn[]; /** * What the Slack thread (or channel, for a top-level @-mention) actually * says before this message, already rendered for this agent — including * posts by agents on other machines, which never reach the local log. * When set it replaces {@link context}: Slack is the whole conversation, * the local log only the part this machine saw. */ slackContext?: string; /** * Said right after the message when it is a bare @-mention (no text): what * the ping means — act on what is open for you in the thread above, do not * switch topics (2026-10-08, #content-team). */ bareMentionNote?: string; /** Who in the room is awake (Slack rooms with Cloud presence). */ roomPresence?: string; /** The message being delivered — named in a hand-off command. */ messageId?: string; /** * Set when this agent was woken because nobody in the room was awake: it * routes the message rather than simply deciding whether to answer it. */ wakeRole?: 'team-leader' | 'orchestrator'; /** * Ticket loop: `[TICKET:TKT-123 ] …` when the message opened (or * belongs to) a ticket. Rendered under the header so the agent knows which * `--request-id` to pass, and so `addToPool` can link its WorkItems. */ ticketLine?: string; /** * Set when the message came from a Slack DM (bridged into this chat-v2 * channel). The hint then names the reply target explicitly and says it * holds for everything the message leads to, so the agent never has to * remember or guess one (2026-09-26: the orc's follow-up to a Slack-DM * question went to #pro-think-tank and to the master-bot DM). */ slackDmChannelId?: string; /** * The Slack thread the message came from, as a key (`:`). * Rendered as `[SLACK-THREAD:]` under the header and named in the * reply command, so an agent holding messages from several threads of one * DM answers each in its own (2026-09-28: an answer owed in one thread was * posted in the thread the owner wrote in last, bundled with another). */ slackThreadKey?: string; /** See {@link DispatchMessageOptions.peopleAddressing}. */ peopleAddressing?: PeopleAddressing; /** * Set when this agent is the one responder the harness chose for the * room; the others only got the message as context. */ soleResponder?: boolean; /** Context-only room messages queued since this agent's last prompt from the room, rendered */ contextOnlyBlock?: string; /** The message came from a colleague inside Crewly and never reached Slack (crewly#1340) */ internalCoordination?: boolean; } /** * Who a Slack room message was meant for, when people are involved — the * prompt-level backstop to the routing rule that a message to people wakes * no agent (specs/slack-room-presence.md "Prompt backstop"). */ export interface PeopleAddressing { /** * `named-in-message`: the message itself @'d these people. * `recent-exchange`: it @'d nobody, and it came inside the * person-exchange window of human messages addressing these people. * `recent-exchange-request`: the same, but the message reads as a direct * request ("帮我…", "please…", "can you…"), so it is not treated as a * continuation: the agent is told to treat it as a request. */ kind: 'named-in-message' | 'recent-exchange' | 'recent-exchange-request'; /** How to name each person, e.g. `Info (<@U0AMU9APG9E>)`. */ people: readonly string[]; } /** * Per-call routing options for {@link ChatV2DispatcherService.dispatchMessage}. * Forwarded verbatim into {@link FormatPromptArgs}. */ export interface DispatchMessageOptions { /** * Sessions that must not hear this message — the agent that wrote it, * when an agent-authored Slack message is fanned out to the colleagues * it @'d (it is a thread participant itself). */ excludeSessions?: readonly string[]; /** One line naming the channel's members and how to @ them (Slack team channels). */ channelRoster?: string; /** See {@link FormatPromptArgs.threadId}. */ threadId?: string; /** See {@link FormatPromptArgs.replyVia}. */ replyVia?: 'reply-chat' | 'reply-channel'; /** * Who in the room is awake, here and on other machines. With it, a message * that addresses nobody goes to every agent awake on this machine — each * reads it and decides — and nobody asleep is woken, unless nobody in the * room is awake at all; then exactly one machine wakes the room's router. * Without it, such a message goes to the team leader alone (the 1.18.4 rule). */ room?: HuddleRoomState; /** One line saying who in the room is awake, for the prompt. */ roomPresence?: string; /** * The Slack context block for one recipient (see * {@link FormatPromptArgs.slackContext}); a function because lines the * recipient wrote itself are marked. Returns '' for no block. */ slackContextFor?: (agentSession: string) => string; /** See {@link FormatPromptArgs.bareMentionNote}. */ bareMentionNote?: string; /** People the message (or the exchange it may continue) was addressed to; rendered as an `Addressed to:` line. */ peopleAddressing?: PeopleAddressing; /** * Choose exactly one responder; every other member gets the message as * context only (specs/2026-10-03-one-responder-per-message.md). Omitted: * the older fan-out (chat-UI huddles, agent-authored messages). */ oneResponder?: OneResponderOptions; } /** What the Slack bridge knows about a room's presence, from this machine's point of view. */ export interface HuddleRoomState { /** Huddle members running on this machine right now */ awakeHere: readonly string[]; /** Whether any member on another machine is awake */ awakeElsewhere: boolean; /** * Whom this machine wakes when nobody in the room is awake. `null` = that * is another machine's job; `undefined` = unknown (no Cloud presence), so * fall back to the team-leader rule. */ wakeWhenAllAsleep?: { agentSession: string; kind: 'team-leader' | 'orchestrator'; } | null; } /** * The Slack DM a chat-v2 message was bridged from, if it was. * * @param message - Persisted chat-v2 message * @returns The Slack `D…` channel id, or undefined */ export declare function slackDmChannelOf(message: Pick): string | undefined; /** * The Slack thread key of a chat-v2 message bridged from Slack. * * @param message - Persisted chat-v2 message * @returns `:`, or undefined when not from Slack */ export declare function slackThreadKeyOf(message: Pick): string | undefined; /** * Default prompt formatter the agent sees when a user sends in their * chat channel. * * Format is intentionally stable and tag-prefixed so the `reply-chat` * skill (and future tooling) can reliably extract the channelId without * regex ambiguity. The "Reply to this channel" hint line gives the agent a one-step * instruction on how to reply. */ /** * Render the preceding messages as a short, clearly-bounded block. * * Marked as background and explicitly not an instruction. An agent handed a * transcript will otherwise mine it for something that reads like * permission — which is the opposite of the reason this exists. The point is * that it can *check* what was said, not that it gains new authority from * having read it. * * Times are the machine's (the owner's) local time and the header says so: * bare UTC clock times were repeated to the owner as if local (#1427). * * @param turns - Earlier messages, oldest first * @param timeZone - Zone for the clock times (default: this machine's) * @returns The block, or an empty string when there is nothing to show */ export declare function renderChatContext(turns: readonly ChatContextTurn[], timeZone?: string): string; /** * Keep the newest turns whose rendered size fits a character budget. * * @param turns - Turns, oldest first * @param budget - Most characters for the rendered lines * @returns The newest turns that fit (always at least the newest one), oldest first */ export declare function fitTurnsToBudget(turns: readonly ChatContextTurn[], budget: number): readonly ChatContextTurn[]; /** * Whether a message is a top-level owner message in a room or channel (the * case that gets the wider look back). * * @param channel - Where it was sent * @param threadId - Thread it sits in, when it does * @param owner - Whether the owner wrote it * @returns True for the wider history window */ export declare function isTopLevelOwnerRoomMessage(channel: Pick, threadId: string | undefined | null, owner: boolean): boolean; /** * Whether the `Addressed to:` line tells this recipient to stay silent by * default. The single source of that decision: the Slack bridge uses it to * leave such recipients without a "working on it" placeholder, and the * owner-message watchdog does not wait on them. A recipient that must reply * (`required`) is never told to stay silent: a placeholder and a watchdog are * a promise of an answer (2026-10-02, #personal-assistant-team: Aria was * woken, a placeholder was posted, and the prompt told her to stay silent). * * @param addressing - Who the message (or the exchange it may continue) was for * @param mode - The recipient's response mode * @param addressedDirectly - Whether the recipient was named * @returns True when the prompt will say "By default, stay silent." */ export declare function isSilentByDefault(addressing: PeopleAddressing | undefined | null, mode: 'required' | 'optional', addressedDirectly: boolean | undefined): boolean; /** * The `Addressed to:` line: who a message was meant for when people are in * the picture. Routing already keeps a message to people away from agents; * this tells an agent that hears one anyway — @'d together with a person, or * told about a follow-up inside a person-to-person exchange — that the * default is to stay out of it (2026-10-02, #personal-assistant-team). A * message that reads as a direct request gets a neutral note instead. * * @param args - Prompt inputs * @param mode - The effective response mode * @returns The line, or null when there is nothing to say */ export declare function peopleAddressingLine(args: FormatPromptArgs, mode: 'required' | 'optional'): string | null; /** * Told to the one agent the harness picked to answer a room message * (specs/2026-10-03-one-responder-per-message.md). */ export declare const SOLE_RESPONDER_LINE = "Responder: you are the one agent answering this for the room; the others only see it as context and will not reply. If a colleague should answer instead, @ them in your reply."; export declare function defaultFormatPrompt(args: FormatPromptArgs): string; /** * Queue metadata of an owner's message: owner priority, its identity (the * same key the owner-message watchdog uses, so its reminder is recognised as * the same message) and the thread its answer belongs in. * * @param channel - Channel it was recorded in * @param message - The owner's message * @param chatThreadId - Chat thread the answer belongs in, when it has one * @returns The metadata */ export declare function ownerQueueMeta(channel: Pick, message: Pick, chatThreadId: string | undefined): QueueMessageMeta; /** * Dispatches user-origin chat messages to the agent session bound to the * channel. Invoked from the chat controller after `ChatV2Service.sendMessage` * returns. */ export declare class ChatV2DispatcherService { private readonly agentSink; private readonly formatPrompt; private readonly mentionResolver?; private readonly huddleMembersFor?; private readonly threadParticipantsFor?; private readonly lastThreadSpeakerFor?; private readonly huddleLeaderFor?; private readonly activateAgent?; private readonly holdForStart?; private readonly holdDuringActivation?; private readonly releaseHeld?; private readonly activateRetryDelayMs; private readonly recentTurnsFor?; private readonly prepareInbound?; private readonly onDispatched?; private readonly refuseDelivery?; private readonly isOwnerMessage?; /** Room messages queued as context only, shown on each agent's next prompt from the room */ readonly contextBacklog: RoomContextBacklog; private readonly logger; constructor(options: ChatV2DispatcherOptions); /** * Deliver an agent's text straight to another agent, outside any channel * (R6 of the message-routing audit 2026-10-18: an agent-to-agent message * written into an owner thread goes to the agent privately instead). * * @param sessionName - Receiving agent * @param text - Text, already framed with its sender header * @returns The sink's result */ deliverToAgentPrivately(sessionName: string, text: string): ReturnType; /** * Deliver a dispatch prompt to one agent with this message's queue metadata. * * @param channel - Channel * @param message - Message * @param sessionName - Agent * @param prompt - Prompt text * @param chatThreadId - Thread root, if any * @returns The sink's result */ private sendToAgent; /** * Run a delivery (or a queue hold) with the queue metadata this message * deserves: owner priority for the owner's message, staleness for a * colleague's room message in a thread. */ private withDeliveryMeta; /** * Activate-on-send for an agent whose session is down. A start that is * deferred (running-agent cap) is final for this attempt — the caller holds * the message on the queue. Any other failure is retried once after a short * pause before it counts. * * @param sessionName - Agent to start * @returns `true` started, `'deferred'` waiting for a slot, `false` could not be started */ private activateWithRetry; /** * Put a prompt on the agent's queue for its deferred start. * * @returns True when the prompt is held */ private hold; /** * Make a message durable before activate-on-send starts its agent: it goes * on the agent's persisted queue first, so a restart while the agent is * starting cannot lose it (the awaiting call dies with the process; the * queue is restored at boot and register-self delivers it). * * @param channel - Where the message was sent * @param message - The message * @param sessionName - Agent about to be started * @param prompt - Prompt that would be delivered * @param chatThreadId - Chat thread of the message, when it has one * @returns True when the prompt is now on the queue */ private holdBeforeActivation; /** * Take back the copy {@link holdBeforeActivation} queued, before the * message is delivered (or failed) another way, so it never arrives twice. * * @param sessionName - The agent * @param prompt - The held prompt * @returns 'released' when it was still queued and is now removed; * 'taken' when the agent's queue flush already took it (the agent * registered during the start): the queue delivers it, nothing to send */ private releaseHeldCopy; /** * Whether this message gets the wider history window (top-level owner * message in a room or channel). * * @param channel - Where it was sent * @param message - The message * @param threadId - Thread override from the dispatch options * @returns True for the wider window */ private wideHistoryFor; /** * The preceding messages to show, already capped. * * Thread messages get the larger allowance because everything in a thread * is by construction about the same subject. A top-level channel message * gets less: recent channel traffic is often several unrelated * conversations, and most of it is noise to the agent being asked. * * @param channelId - Channel being dispatched from * @param threadId - Thread the message sits in, when it does * @param ownerTopLevel - True for a top-level owner message in a room or channel: wider window (see TOP_LEVEL_OWNER_*) * @returns Messages oldest first, or empty when context is off or absent */ private contextFor; /** * Phase C BE.3 entry point — choose the routing strategy from * `channel.type` and the message's mentions, then fan out. * * Routing rules (SEALED §2.1): * - `type='dm'` → existing 1:1 path via `dispatchToAgent`. Mentions * in DMs are advisory only (the channel already binds to one * agent); the dispatcher does not currently re-route them. * - `type='channel'` → resolve `message.mentions` via the injected * `mentionResolver`, then dispatch the formatted prompt to each * resolved sessionName. No mentions → no dispatch (the message * still persists; nobody is paged). * - Agent-origin messages always skip dispatch (no self-loopback). * * Always resolves; per-recipient errors are captured in * {@link DispatchMessageResult.mentionOutcomes} and never thrown. * * @param channel - The channel DTO (provides `type`, name, agentSession). * @param message - The persisted message DTO (provides senderType, mentions). * @returns Aggregate result describing the strategy + outcomes. */ dispatchMessage(channel: ChatChannelDTO, message: ChatMessageDTO, options?: DispatchMessageOptions): Promise; /** * Whether a message must not reach an agent (dedicated to another person). * * @param sessionName - Recipient * @param message - The message * @returns True to skip it (never on an error) */ private isRefused; /** * Record whom the agent now acts for (issue #968): the Slack sender, or the * owner for a dashboard message. A row an agent wrote (`authorAgentSession` * / `remoteAgentSession`) carries that agent's person instead — its * `slackUserId` is the agent's bot, never a person. Done before delivery, * so the agent's first connector call already carries the right person. * * @param sessionName - Recipient * @param message - The message being delivered */ private noteActingFor; /** * Tell the reply router which chat thread an agent was just asked in, so * its `reply` lands there (specs/2026-09-30-owner-message-guarantee.md §B). * * @param sessionName - Recipient * @param channelId - Channel of the message * @param threadId - Thread the answer belongs in (undefined for a DM) */ private noteOriginThread; private route; /** * Phase B-2 — fan a user message out to every member of a huddle. * * Resolution path: * 1. Look up the roster via the injected `huddleMembersFor`. No * resolver wired → return `strategy: 'skip'` (the message still * persists; nobody is paged). * 2. Empty roster → same skip path. Should not happen since * `createHuddle` rejects empty membership, but defensive. * 3. For each member, derive `responseMode` from whether their * session appears in `message.mentions[]`. Format the prompt * accordingly and call `sendMessageToAgent`. * * Always resolves; per-member errors are captured in * {@link DispatchMessageResult.huddleOutcomes} and never thrown. * * @param channel - The huddle channel. * @param message - The persisted user message. * @returns Aggregate result with one outcome per member. */ private dispatchHuddleBroadcast; /** * Queue a room message as context only for the members that are not * answering it — no turn, no wake-up. Each sees it at the top of its next * prompt from this room (specs/2026-10-03-one-responder-per-message.md). * * @param channel - The room * @param message - The message * @param options - Dispatch options (thread id) * @param sessions - Members that only listen * @param responderName - Who answers it, when anyone does */ private queueContextOnly; /** * Who a huddle message will be delivered to, and which of them owe a reply — * without delivering it. * * The Slack bridge needs this *before* delivery: it puts one 👀 on the * message per agent that will receive it, and a "working on it" placeholder * under each agent that must answer. Delivery can take a minute or two when * an agent has to be cold-started, and the owner should not stare at an * unacknowledged message for that long. Planning from the same rules as * delivery is what keeps the eyes honest: one per agent that actually gets * the message, not one per agent in the room. * * @param channel - The huddle * @param message - The message * @param options - Same options {@link dispatchMessage} would receive * @returns Session → whether a reply is required; empty when nobody hears it */ planHuddleTargets(channel: ChatChannelDTO, message: ChatMessageDTO, options?: DispatchMessageOptions): Promise>; /** * The targeting rules for a huddle message, shared by planning and delivery. * * @param channel - The huddle * @param message - The message * @param options - Dispatch options * @param members - The huddle roster * @returns Targets with their reply obligation, who was @'d, and who was woken to route it */ private computeHuddleTargets; /** * Exactly one responder (specs/2026-10-03-one-responder-per-message.md §1): * b. @-mentioned members (an explicit @ always wins) → required; * a/c. the caller's pinned responder — the decision's asker, or the * Slack thread's owner (null: nobody here) → required; * c'. no pin, a thread reply: the agent that spoke last in it here; * d. otherwise the room rules, narrowed to one: the leader when awake, * else the awake agent that spoke last in the room, else the first * awake one — optional, as the leader always was. Nobody awake: the * existing wake-up rules. * Every other member listens: context only. * * @param channel - The huddle * @param message - The message * @param options - Dispatch options * @param one - The one-responder options * @param memberSet - Members that may hear it (author excluded) * @param mentioned - Members @'d * @returns Targets, context-only members and the responder's name */ private computeOneResponder; /** * Of several awake members, the one that answers an un-@'d message: the * room's leader, else whoever of them spoke last in the room, else the * first by session name (a stable choice). * * @param channelId - The room * @param awake - Awake members (non-empty) * @returns One of them */ private pickOneAwake; /** * Deliver a huddle message to the targets {@link computeHuddleTargets} chose. * * @param channel - The huddle * @param message - The message * @param options - Dispatch options * @param members - The huddle roster (for logging) * @param targets - Who hears it, and whether they must reply * @param mentioned - Who was @'d * @param wakeRoles - Agents woken to route the message because nobody in the room was awake * @returns The dispatch result */ private deliverToHuddleTargets; /** * Dispatch a `type='channel'` message to its resolved mentions. * * Skips entirely when: * - No mention resolver is wired (return strategy=skip). * - `mentions` is empty (return strategy=skip). * - The resolver returns no targets (return strategy=channel-mentions * with empty `mentionOutcomes`; `dispatched=false`). * * @param channel - The team channel. * @param message - The persisted user message. * @returns Aggregate result with one outcome per resolved recipient. */ private dispatchChannelMentions; /** * Push a newly-persisted user message to the agent. * * Dispatch is fire-and-forget at the API-response level (the HTTP 201 has * already been serialized), but we still await the write so tests can * observe the outcome and the logger can record failures. * * Messages where `senderType !== "user"` are a no-op — we never loop an * agent's own reply back into its own PTY. * * @deprecated Issue #334 — `dispatchToAgent` is the legacy 1:1 entry * point. New callers MUST go through `dispatchMessage`, which routes * type='dm' messages here and adds the type='channel' (@mentions / * @team) path. Direct callers of `dispatchToAgent` silently bypass * the mention-resolution layer. * * Conversion: replace `dispatchToAgent(channel, message)` with * `dispatchMessage(channel, message)` and read the `dmResult` field * of the returned object. See chat-v2.dispatcher.service.ts:dispatchMessage * for the contract. * * @param channel - Channel DTO (provides `agentSession` + display name) * @param message - Persisted message DTO */ dispatchToAgent(channel: ChatChannelDTO, message: ChatMessageDTO, options?: Pick): Promise; } /** * The agent that wrote a chat row, when an agent did: a local agent posting * as a user turn (`authorAgentSession`) or a colleague's Slack post * (`remoteAgentSession`). * * @param message - Chat row * @returns Agent session, or null for a human's message */ export declare function agentAuthorOf(message: Pick): string | null; //# sourceMappingURL=chat-v2.dispatcher.service.d.ts.map