/** * "Agent is working on it…" placeholders in Slack. * * Slack offers bots no typing indicator, so the moment a Slack message is * handed to an agent, the agent's own bot posts a placeholder ("⚙️ Ella is * working on it…") where the reply will appear. When the reply arrives the * placeholder is edited into it — no extra message. An agent that stays * silent past the timeout has its placeholder edited to a "still working" * note so nothing dangles forever. * * Only agents with an installed bot get placeholders (editing needs the * token that posted the message). * * @module services/slack/slack-typing-placeholder.service */ import { type OwnerMessageEntry } from '../messaging/owner-message-watchdog.service.js'; /** * Whether an agent message is an interim note ("got it — here is my plan") * rather than the answer: the mirror posts it and puts the placeholder back. * * @param message - Anything carrying chat-v2 metadata * @returns True when flagged interim */ export declare function isInterim(message: { metadata?: Record; }): boolean; /** The slice of SlackService this service uses. */ export interface TypingSlackApi { isConnected(): boolean; sendMessage(message: { channelId: string; text: string; threadTs?: string; botToken?: string; username?: string; iconEmoji?: string; iconUrl?: string; skipChatV2Mirror?: boolean; notAnAnswer?: boolean; /** Agent session the post is on behalf of (log attribution only). */ senderSession?: string; }): Promise; updateMessage(channelId: string, messageTs: string, text: string, blocks?: undefined, botToken?: string): Promise; deleteMessage?(channelId: string, messageTs: string, botToken?: string): Promise; addReaction?(channelId: string, messageTs: string, emoji: string, botToken?: string): Promise; } /** What the agent is doing while the reply is owed. */ export type TypingPhase = 'waking' | 'queued' | 'typing'; /** * Why a placeholder came down without its agent posting the answer: * - `answered`: an answer was posted in its thread after it went up; * - `not-owed`: the turn ended and the message is nothing the watchdog * tracks for this agent (an "ok", or another agent's message); * - `no-reply-needed`: the agent said so (`reply --none`). */ export type PlaceholderSettleReason = 'answered' | 'not-owed' | 'no-reply-needed'; /** Told when a placeholder is settled (see SlackTypingPlaceholderService.onThreadSettled). */ export type ThreadSettledListener = (slackChannelId: string, threadTs: string | undefined, info: { agentSession: string; why: PlaceholderSettleReason; }) => void; /** * Who posts the placeholder: the agent's own bot (`botToken`) or, for an * agent without an installed bot, the master bot wearing the agent's * name/icon (`username` / `iconEmoji` / `iconUrl`). Either way the * message can later be edited into the reply by the same principal. */ export interface TypingIdentity { displayName: string; botToken?: string; username?: string; iconEmoji?: string; iconUrl?: string; } /** Where a placeholder lives and which bot posted it. */ export interface TypingPlaceholder { slackChannelId: string; ts: string; threadTs?: string; /** Undefined when the master bot posted it. */ botToken?: string; displayName: string; phase: TypingPhase; /** The person's message this placeholder answers (gets ✅ when the agent settles without replying) */ sourceTs?: string; /** When it was posted (ms) — the order replies are owed in */ postedAt?: number; } /** Identifies one pending reply: this agent, in this Slack conversation/thread. */ export interface TypingKeyParts { agentSession: string; slackChannelId: string; threadTs?: string; } /** Constructor dependencies. */ export interface SlackTypingPlaceholderDeps { slack: TypingSlackApi; /** * Whether the owner-message watchdog tracks this placeholder's message as * an answer the agent still owes (not an acknowledgement). Such a * placeholder stays up at turn end unless its thread was answered * (specs/2026-10-02-harness-owned-routing.md §5). Absent = nothing is owed. */ isOwed?: (agentSession: string, placeholder: TypingPlaceholder) => boolean; timeoutMs?: number; setTimer?: (fn: () => void, ms: number) => ReturnType; clearTimer?: (t: ReturnType) => void; /** * Where outstanding placeholders are kept across restarts. Without it a * restart forgot them and nothing ever took them down (Atlas's 17:02 * "working on it" stayed after the 17:40 restart, 2026-09-25). */ storePath?: string; /** * Whether the agent is in the middle of a turn right now. Consulted when a * placeholder that was too young to take down at turn end is looked at * again; without it the second look takes it down unconditionally. */ isAgentMidTurn?: (agentSession: string) => boolean; /** Override for {@link SLACK_TYPING_CONSTANTS.REPLACE_BY_EDIT} (tests). */ replaceByEdit?: boolean; } /** * Posts, resolves, and expires typing placeholders. */ /** * Never show a raw session id ("think-tank-atlas-b4e166f6") in a placeholder: * callers that could not resolve the member (ad-hoc rooms, other teams) pass * the session. `--<8 hex>` → "Atlas"; anything else unchanged. * * @param name - Display name or session id * @returns Readable name */ export declare function readableAgentName(name: string): string; /** * Whether a Slack message is a typing placeholder that was turned into the * "couldn't take this message" notice. The agent never got the message, so * it must not count as the message having been answered. (The other phases — * waking, queued, working, timed out — mean the agent holds it.) * * @param text - Slack message text * @returns True when the text is the failed-placeholder notice */ export declare function isFailedPlaceholderText(text: string): boolean; export declare class SlackTypingPlaceholderService { private readonly deps; private readonly logger; private readonly pending; /** * Placeholders that timed out into "still working on this". The reply that * finally arrives must still remove them: forgetting them at timeout left * the owner a "⏱ still working" line under every slow answer (2026-09-25). */ private readonly expired; /** Placeholders being posted right now (two copies of one message must not post two). */ private readonly inFlight; /** * One operation at a time per thread. An interim note (take the * placeholder down, put a fresh one back) racing the final answer let the * answer find nothing to replace and then the fresh placeholder appear * under it, where it stayed (2026-09-28). */ private readonly locks; /** Agents with a turn-end second look already scheduled. */ private readonly recheckScheduled; /** * When an answer was last posted in a thread (`:`), by * anyone — a placeholder is only ✅-settled at turn end when its thread got * one (specs/2026-10-02-harness-owned-routing.md §5). */ private readonly answeredAt; /** * Keys of placeholders whose agent was stopped while it still owed the * answer. They stay up (in the 'queued' phase) through the stop's turn-end * settle, until the answer is posted or the agent runs again. */ private readonly heldForRestart; /** * `:` → until (epoch ms): Slack said this agent's bot cannot * post there (channel_not_found / not_in_channel — e.g. its bot was never * invited to a private channel). No placeholder is tried there until then, * so every new message does not repeat the same failing call and warning. */ private readonly unreachable; /** Told when a thread gets a placeholder or an answer (see {@link onThreadActivity}). */ private readonly threadListeners; /** Told when a thread got its answer (see {@link onThreadAnswered}). */ private readonly answeredListeners; /** Told when a placeholder was settled without a reply (see {@link onThreadSettled}). */ private readonly settledListeners; /** * @param deps - Slack slice plus optional timer overrides for tests */ constructor(deps: SlackTypingPlaceholderDeps); /** * Post a placeholder for a reply that is now owed. Replaces a still-pending * placeholder for the same key (a second message before the first reply) * by leaving the old one alone and tracking the new one only. * * @param key - Agent + conversation (+ thread) * @param identity - The agent's bot token and display name * @returns The placeholder, or null when Slack is disconnected or the post failed */ begin(key: TypingKeyParts, identity: TypingIdentity, phase?: TypingPhase, sourceTs?: string): Promise; /** * Be told whenever a thread gets a placeholder (posted by anyone — the * agent's `--working`, the router, the harness) or an answer. The * harness's own "working on it" watch uses this to stand down. * * @param listener - Called with the Slack channel and thread * @returns Unsubscribe function */ onThreadActivity(listener: (slackChannelId: string, threadTs?: string) => void): () => void; /** * Be told when a thread got its answer: a placeholder was edited into the * reply (or replaced by it), or taken down because the agent answered with * a file. An interim note (the placeholder comes back) does not count. * * @param listener - Called with the Slack channel and thread * @returns Unsubscribe function */ onThreadAnswered(listener: (slackChannelId: string, threadTs?: string) => void): () => void; /** * Be told when an agent's placeholder was taken down without the agent * posting its own answer (the owner's message gets ✅). `info.why` says * whether that settles the message: only `answered` means the thread got * an answer (crewly#1015 §3) — `not-owed` and `no-reply-needed` say * nothing about other agents' obligations in the thread. * * @param listener - Called with the Slack channel, thread, agent and why * @returns Unsubscribe function */ onThreadSettled(listener: ThreadSettledListener): () => void; private notifyListeners; private notifyThread; /** * Run `fn` after every earlier operation on the same thread has finished. * * @param k - Thread key * @param fn - The operation * @returns Its result */ private withLock; /** * Move a pending placeholder to another phase (e.g. the agent finished * waking and now holds the message → "is working on it…"; its start is * deferred and the message is held on its queue → "will pick this up shortly…"). No-op when nothing * is pending or the phase is unchanged. * * @param key - Agent + conversation (+ thread) * @param phase - New phase */ setPhase(key: TypingKeyParts, phase: TypingPhase): Promise; /** * The reply will not come (agent could not be started / delivery * failed): say so in place of the placeholder and stop tracking it. * * @param key - Agent + conversation (+ thread) */ fail(key: TypingKeyParts): Promise; /** * The agent was stopped (slot eviction, memory, runtime exit) while it owes * the answer: its placeholders must not be taken down as "answered" by the * turn-end settle the stop triggers. They go to the 'queued' phase ("will * pick this up shortly") and stay until the answer is posted or the agent * runs again ({@link releaseHold}). The hold is set before any Slack call so * a settle racing the stop already sees it. * * @param agentSession - The stopped agent * @returns How many placeholders are held */ holdForRestart(agentSession: string): Promise; /** * The agent runs again: its held placeholders are settled by its own turns. * * @param agentSession - The agent */ releaseHold(agentSession: string): void; private textFor; private edit; private post; /** * Take the placeholder for a reply that just arrived (cancels its timeout). * * @param key - Agent + conversation (+ thread) * @returns The placeholder to edit, or null when none is pending */ take(key: TypingKeyParts): TypingPlaceholder | null; /** * Every placeholder outstanding in one thread (pending and timed out), * oldest first, no longer tracked. Timers are cancelled. * * @param key - Agent + conversation (+ thread) * @returns The placeholders, oldest first */ private takeAll; /** * Take one placeholder off Slack: deleted, or — where this bot cannot * delete — edited to the settled note so it no longer reads as pending. * * @param placeholder - The placeholder */ private remove; /** * The answer for a thread arrived: it takes the place of the thread's * placeholder, and no other placeholder is left in that thread. * * With REPLACE_BY_EDIT (default) the OLDEST placeholder in the thread is * edited into the answer and every other one there is deleted — one * answer covering two messages sent back to back leaves nothing behind * (2026-09-28). Otherwise (the 2026-09-23 behaviour: an edit raises no * notification) the answer is posted as a new message and the * placeholders are deleted after it, so the thread never goes without * either. A failed edit falls back to posting. * * Waits for a placeholder still being posted in that thread, and for any * other operation there, so a placeholder can never go up after the * answer that should have replaced it. * * @param key - Agent + conversation (+ thread) * @param text - The reply * @param identity - The agent's bot token * @param opts - `reopen`: the reply is an interim note — put a fresh placeholder back under it, in the same step; * `onMessageTs`: called with the ts of the Slack message that now carries the reply (the edited placeholder, or the new post) * @returns 'edited' when a placeholder became the reply, 'replaced' when the reply was posted and a placeholder removed, 'posted' when there was none */ resolve(key: TypingKeyParts, text: string, identity: TypingIdentity, opts?: { reopen?: TypingPhase; onMessageTs?: (ts: string) => void; }): Promise<'replaced' | 'edited' | 'posted'>; /** * The agent answered in this thread some other way (a file, a post with * its own thread): take every placeholder in the thread down. * * @param key - Agent + conversation (+ thread) * @returns How many were taken down */ dropThread(key: TypingKeyParts): Promise; /** * Take an agent's placeholder in a thread down quietly — no "answered", * no ✅, no listeners: the routing that posted it was replaced by a rescue * hand-off (crewly#1015 follow-up L2). * * @param key - Agent + conversation (+ thread) * @returns How many were taken down */ withdraw(key: TypingKeyParts): Promise; /** * Whether a placeholder (pending or timed out) is outstanding in this thread. * * @param key - Agent + conversation (+ thread) * @returns True when the agent owes an answer there */ owes(key: TypingKeyParts): boolean; /** * Whether any agent's placeholder (pending, being posted, or timed out into * "still working") shows in this Slack thread. * * @param slackChannelId - Slack channel or DM * @param threadTs - Thread root * @returns True when a "working on it" is visible there */ owesThread(slackChannelId: string, threadTs: string): boolean; /** * The conversation an agent still owes an answer in, on one Slack channel * or DM: its pending (or timed-out) placeholder, OLDEST first. An agent that * answers with the `slack-post` skill instead of its reply skill names no * thread; this is where that answer belongs. Oldest, because answers come * in the order questions were asked: newest-first put the answer owed in * an earlier thread under the latest question (2026-09-28). * * `maxAgeMs` limits this to placeholders posted that recently. An answer * comes within its turn; a post hours later is a new topic, and capturing * it into an old thread hid scheduled output there and returned no ts * (#808: a 16:00 email triage landed in a 13:34 thread). * * @param agentSession - The agent * @param slackChannelId - Channel or DM it is posting to * @param opts - `maxAgeMs`: ignore placeholders posted longer ago; `now`: clock (tests) * @returns The key of the owed reply, or null */ findOwed(agentSession: string, slackChannelId: string, opts?: { maxAgeMs?: number; now?: number; }): TypingKeyParts | null; /** * An answer was posted in this thread (any path: a reply, a file, a post * naming the thread). Lets {@link settleTurnWithoutReply} take the * thread's leftover placeholders down. * * @param slackChannelId - Channel / DM * @param threadTs - Thread (undefined = top level) * @param at - When (epoch ms; default now) */ noteAnswerPosted(slackChannelId: string, threadTs: string | undefined, at?: number): void; /** * Forget answers older than {@link SLACK_TYPING_CONSTANTS.ANSWERED_KEEP_MS}. * * @param now - Clock (epoch ms) */ private pruneAnswered; /** @returns How many answered threads are remembered (tests) */ get answeredCount(): number; /** * Whether an answer was posted in the placeholder's thread after it went up. * * @param placeholder - Placeholder * @param since - When it started (epoch ms) * @returns True when the thread was answered */ private answeredSince; /** * Whether a placeholder must stay up when the agent's turn ends: its * message is one the watchdog tracks as owed (not an "ok"/"好") and no * answer was posted in its thread since it went up. * * @param agentSession - Agent * @param placeholder - Placeholder * @param since - When it went up (epoch ms) * @returns True to keep it */ private keepAtTurnEnd; /** * The agent finished its turn. A placeholder it still owes (pending, or * timed out into "still working") is taken down — with ✅ on the person's * message — when its thread was answered, or when its message needs no * answer (an acknowledgement, or nothing the watchdog tracks: the agent * chose not to reply to an "ok"/"好", 1.20.136). A message the watchdog * tracks as owed, with no answer in its thread, keeps its placeholder and * the watchdog chases the agent (specs/2026-10-02-harness-owned-routing.md * §5): ✅ on a message that was never answered (TKT-187) is gone. An agent * that decides an owed message needs no answer says so with `reply --none` * ({@link settleNoReplyNeeded}). * * @param agentSession - Agent whose turn ended * @param now - Clock (tests) * @returns How many placeholders were removed */ settleTurnWithoutReply(agentSession: string, now?: number): Promise; /** * `reply --none`: the agent says this thread needs no answer. Its * placeholders there come down with ✅ on the person's message. * * @param agentSession - Agent * @param slackChannelId - Channel / DM * @param threadTs - Thread (undefined = top level) * @returns How many placeholders were removed */ settleNoReplyNeeded(agentSession: string, slackChannelId: string, threadTs?: string): Promise; /** * Take settled placeholders down (delete, else edit to "no reply needed"), * mark the person's message ✅ and tell listeners. * * @param agentSession - Agent * @param victims - Placeholders already removed from the maps * @param whyOf - Why each one was settled (told to the settled listeners) */ private settleVictims; /** * A placeholder too young to take down when the turn ended (it may belong * to a message delivered just before the idle signal) gets a second look * once it is old enough: gone then unless the agent is mid-turn — in which * case that turn's own end settles it. Without this it waited for the * agent's next turn, which might be hours away (2026-09-28). * * @param agentSession - Agent whose turn ended * @param at - When (epoch ms) the youngest skipped placeholder is old enough, margin included */ private scheduleSettleRecheck; /** Save outstanding placeholders (pending + timed out) so a restart can still take them down. */ private persist; /** * Placeholders outstanding at the last shutdown come back as timed out: * a reply that still arrives replaces them, the agent's next turn end * takes them down, and whatever nobody claims within BOOT_ORPHAN_MS (the * agent was never woken again) is taken down then. */ private loadPersisted; /** * Take down restored placeholders nobody answered or settled since boot. * * @param keys - Keys restored at boot * @returns How many were taken down */ dropOrphans(keys: string[]): Promise; /** Forget timed-out placeholders older than EXPIRED_KEEP_MS. */ private pruneExpired; /** Number of placeholders still waiting for a reply (tests / diagnostics). */ get pendingCount(): number; private expire; } /** * Whether a placeholder's message is one the owner-message watchdog tracks * as owed by the agent: same Slack conversation and message (or thread), * and not a bare acknowledgement. * * @param owed - The agent's open watchdog entries (`owedBy`) * @param placeholder - Placeholder * @returns True when the agent still owes that message an answer */ export declare function isPlaceholderOwed(owed: ReadonlyArray>, placeholder: Pick): boolean; /** @returns The wired service, or null before Slack started. */ export declare function getSlackTypingPlaceholderService(): SlackTypingPlaceholderService | null; /** @param service - The service to expose (null to clear, for tests) */ export declare function setSlackTypingPlaceholderService(service: SlackTypingPlaceholderService | null): void; //# sourceMappingURL=slack-typing-placeholder.service.d.ts.map