/** * Harness-posted "working on it" placeholders. * * An owner's Slack message that reaches agents who were only *told* (an * un-@'d channel message broadcast to the room) got no placeholder unless * the agent itself chose to call `reply-channel --working`. Owen worked * 3.5 minutes on such a message in #pro-ce without calling it, and the * owner, seeing nothing, thought Crewly was down (2026-09-30). * * This watches each owner delivery for a short window. The first recipient * seen starting a turn — idle → busy — gets the placeholder, posted exactly * as `/api/slack/working` posts it (same key, same bot identity), so the * existing answer-replaces-placeholder and settle-on-idle rules apply * unchanged. It stands down when the thread already has a placeholder or an * answer, and never guesses about an agent that was busy before the message * arrived (its busy state says nothing about this message). * * Nor about a turn the harness started itself: a scheduled brief (trigger or * cron work item, `[SCHEDULED]` message) delivered around the same time is * what the agent is working on, not the owner's message. Such turns get no * placeholder — the nightly report Dana posts at 22:00 is not an answer to * whatever the owner last said (2026-10-03). * * @module services/slack/slack-auto-working.service */ import type { SlackTypingPlaceholderService, TypingIdentity } from './slack-typing-placeholder.service.js'; import type { DispatchMessageResult } from '../chat-v2/chat-v2.dispatcher.service.js'; /** The slice of the placeholder service this one uses. */ export type AutoWorkingTypingApi = Pick; /** Constructor dependencies. */ export interface SlackAutoWorkingDeps { typing: AutoWorkingTypingApi; /** Whether the agent is mid-turn right now (latest observed workingStatus). */ isAgentBusy: (agentSession: string) => boolean; /** Override for {@link SLACK_TYPING_CONSTANTS.AUTO_WORKING_WINDOW_MS} (tests). */ windowMs?: number; /** Clock (tests). */ now?: () => number; } /** One owner message about to be delivered over Slack. */ export interface AutoWorkingDelivery { /** Slack channel or DM the message is in (and the reply goes to). */ slackChannelId: string; /** Thread the reply goes in — the same one `/api/slack/working` resolves. */ threadTs?: string; /** The owner's message (gets the ✅ if the agent settles without replying). */ sourceTs: string; /** Agents the message may be delivered to. */ candidates: readonly string[]; /** Who posts for an agent; null when that agent cannot post here. */ identityFor: (agentSession: string) => TypingIdentity | null; } /** Handle on one watched delivery. */ export interface AutoWorkingWatch { /** * Delivery finished: these agents hold the message. Busy transitions seen * while delivery was running count, and the window starts now. */ delivered(agentSessions: readonly string[]): void; /** Stop watching (delivery failed, or the message needs no placeholder). */ cancel(): void; } /** * Posts a "working on it" placeholder for the first recipient of an owner's * Slack message that starts working on it. */ export declare class SlackAutoWorkingService { private readonly deps; private readonly logger; private readonly watches; private nextId; /** agentSession → when a scheduled brief was last delivered to it. */ private readonly scheduledAt; /** * @param deps - Placeholder service, busy probe, optional window/clock overrides */ constructor(deps: SlackAutoWorkingDeps); private now; /** * Start watching an owner's message. Call before delivering it, so a turn * that starts while other recipients are still being delivered to counts. * * @param delivery - Where the message is, who may get it, and who posts for whom * @returns Handle to report the delivery outcome on */ watch(delivery: AutoWorkingDelivery): AutoWorkingWatch; /** * The harness just handed this agent scheduled work (a trigger or cron * work item, a `[SCHEDULED]` message). The turn it starts next is that * work, so it does not count as taking an owner's message on. * * @param agentSession - The agent */ noteScheduledDelivery(agentSession: string): void; /** * Whether the turn `agentSession` just started is scheduled work rather * than this watch's owner message. */ private isScheduledTurn; private delivered; /** * An agent was just seen starting a turn (idle → busy). * * @param agentSession - The agent */ noteBusy(agentSession: string): void; /** * A thread got a placeholder or an answer: the watches on it are done. * * @param slackChannelId - Slack channel or DM * @param threadTs - Thread (undefined = top level) */ noteThreadActivity(slackChannelId: string, threadTs?: string): void; /** Watches still open (tests / diagnostics). */ get watchCount(): number; private keyFor; /** * Post the placeholder for `agentSession` unless one is already showing * for any recipient. The watch ends either way: one placeholder per message. */ private trigger; } /** * The agents a dispatch actually handed the message to. * * @param dispatch - Dispatcher outcome (null when nothing was dispatched) * @param dmSession - The DM's agent, for the 1:1 strategy (its result names no session) * @returns Sessions that hold the message */ export declare function deliveredSessions(dispatch: DispatchMessageResult | null, dmSession?: string): string[]; /** * Whether a Slack message was written by the owner — not by an agent (on * any machine) and, when the owner is known, by the owner's user id. Same * rule as ticket intake's `isOwner`. * * @param message - Author fields of the inbound message * @param ownerUserId - The workspace owner's Slack user id, when known * @returns True for an owner-authored message */ export declare function isOwnerAuthored(message: { userId?: string; authorAgentSession?: string; }, ownerUserId: string | null | undefined): boolean; /** @returns The wired service, or null before Slack started. */ export declare function getSlackAutoWorkingService(): SlackAutoWorkingService | null; /** @param service - The service to expose (null to clear, for tests) */ export declare function setSlackAutoWorkingService(service: SlackAutoWorkingService | null): void; /** * Tell the "working on it" watch that scheduled work was just handed to an * agent, so the turn it starts is not taken for an owner message (best * effort, never throws; a no-op before Slack started). * * @param agentSession - Agent the scheduled brief went to */ export declare function noteScheduledTurn(agentSession: string): void; //# sourceMappingURL=slack-auto-working.service.d.ts.map