/** * Slack Team Channel Service * * Makes a Crewly team a first-class Slack participant: one Slack channel per * team, backed by one chat-v2 huddle whose roster is the team's members. * * Slack #team-alpha ──inbound──▶ chat-v2 huddle ──dispatcher──▶ every member * ▲ (@'d must reply) * └──outbound, posted as the agent (username/icon)── reply-channel * * Responsibilities: * - Mapping store (`~/.crewly/slack-team-channels.json`): create/link/unlink. * - Lifecycle sync via `StorageService.onStorageEvent`: newly created team → * channel + huddle + welcome (existing teams are opted in from Settings); * member change → roster; archive/delete → archive both. * - Inbound routing for mapped channels (called by the orchestrator bridge): * persist into the huddle with Slack thread correlation, resolve `@name` * mentions, dispatch, answer typos with "did you mean". * - Outbound mirror: agent messages recorded in a mapped huddle are posted to * the Slack thread under the agent's own name and icon. * * Nothing here is orchestrator-specific — the orchestrator keeps handling DMs * and unmapped channels exactly as before. * * @module services/slack/slack-team-channel.service */ import { type SlackAutoWorkingService } from './slack-auto-working.service.js'; import type { Team, TeamMember } from '../../types/index.js'; import type { SlackIncomingMessage, SlackRoomDelivery, SlackRoomPresence, SlackOutgoingMessage, SlackTeamChannelMapping, SlackChannelInfo, SlackContextMessage, SlackThreadContext } from '../../types/slack.types.js'; import type { ChatMessageDTO } from '../chat-v2/types.js'; import type { ChatV2Service } from '../chat-v2/chat-v2.service.js'; import { type ChatV2DispatcherService, type DispatchMessageResult } from '../chat-v2/chat-v2.dispatcher.service.js'; import { type PriorRoomAnswer } from './room-responder.js'; import type { StorageEvent } from '../core/storage.service.js'; import { type OwnerMessageTrackInput } from '../messaging/owner-message-watchdog.service.js'; import type { SlackAgentIdentityService } from './slack-agent-identity.service.js'; import type { SlackTypingPlaceholderService } from './slack-typing-placeholder.service.js'; import type { ChannelTokenOps } from './slack-channel-members.js'; import { type SlackOutboundDelivery } from './slack-outbound-delivery.js'; /** The slice of SlackService this service uses. */ export interface TeamChannelSlackApi { isConnected(): boolean; createChannel(name: string): Promise; renameChannel(channelId: string, name: string): Promise; getChannelInfo(channelId: string): Promise; joinChannel(channelId: string): Promise; archiveChannel(channelId: string): Promise; setChannelPurpose(channelId: string, purpose: string): Promise; sendMessage(message: SlackOutgoingMessage): Promise; addReaction(channelId: string, messageTs: string, emoji: string, botToken?: string): Promise; inviteToChannel(channelId: string, userIds: string[]): Promise; /** Names of a Slack user (for turning `@Their Name` into a mention). Optional. */ getUserInfo?(userId: string): Promise<{ name: string; realName: string; }>; /** Member user ids of a channel, bots included — picks between same-named agents. */ listChannelMembers?(channelId: string): Promise; /** The connected (master) bot's own user id, cached after the first `auth.test`. Optional. */ getBotUserId?(): Promise; /** Remove a user (an agent's bot) from a channel as the workspace bot. Optional. */ kickFromChannel?(channelId: string, userId: string): Promise; uploadFile(options: { channelId: string; filePath: string; filename?: string; title?: string; initialComment?: string; threadTs?: string; botToken?: string; persona?: { username: string; iconEmoji?: string; iconUrl?: string; }; }): Promise<{ fileId?: string; }>; } /** The slice of SlackAgentIdentityService this service uses (optional). */ export type TeamChannelIdentityApi = Pick & Partial>; /** The slice of ChatV2Service this service uses. */ export type TeamChannelChatApi = Partial> & Pick & Partial>; /** The slice of StorageService this service uses. */ export interface TeamChannelStorageApi { getTeams(): Promise; onStorageEvent(listener: (event: StorageEvent) => Promise | void): () => void; } /** Dispatcher slice — resolved lazily because it is built after the bridge. */ export type TeamChannelDispatcherApi = Pick & Partial>; /** Constructor dependencies. */ export interface SlackTeamChannelServiceDeps { slack: TeamChannelSlackApi; chat: TeamChannelChatApi; storage: TeamChannelStorageApi; /** Returns the live dispatcher, or null before it is wired. */ getDispatcher: () => TeamChannelDispatcherApi | null; /** * Real per-agent Slack identities (Cloud-provisioned bot users). Optional: * without it agents post under the cosmetic username/icon override. */ identities?: TeamChannelIdentityApi | null; /** "Is typing…" placeholders for @-mentioned agents; optional. */ typing?: (Pick & Partial>) | null; /** * Harness-posted "working on it": watches an owner's message and posts the * placeholder for the first recipient that starts working on it; optional. */ autoWorking?: Pick | null; /** Whether an agent's runtime session exists right now (false = it must be woken first). */ isAgentAwake?: (agentSession: string) => boolean; /** Whether an agent session runs on this instance (its own Slack copy is not re-recorded). */ isLocalAgent?: (agentSession: string) => boolean; /** * Ticket id prefixes in use (project ticket counters, `TKT`): their ids * are taken out of every agent post to Slack (crewly#1340). Absent: `TKT` only. */ ticketPrefixes?: () => Promise; /** Mapping store path; defaults to `/slack-team-channels.json`. */ storePath?: string; /** * Slack user id of the workspace owner (the person who installed the * app). A channel the bot creates is invisible to everyone until they * join it, so the owner is invited right after creation. */ getOwnerUserId?: () => string | null; /** This machine's Cloud instance id — to tell whether Cloud picked this machine to wake someone. */ resolveInstanceId?: () => Promise; /** An ad-hoc room gained a member: tell Cloud soon, not at the next 5-minute heartbeat. */ onRoomsChanged?: () => void; /** * Who is in a Slack channel, read with one agent's own bot token * (`conversations.members`). Used to drop ad-hoc room members whose bot is * not actually in the channel; optional — without it nothing is pruned. */ listChannelMembers?: (channelId: string, botToken: string) => Promise<{ ok: true; members: string[]; } | { ok: false; error: string; }>; /** Re-read the agents' bot ids and tokens from Cloud (before pruning anyone); optional. */ refreshIdentities?: () => Promise; /** * Channel operations with an agent's own bot token, for rooms the * workspace bot is not in (read the name, invite or remove an agent's bot, * rename). Optional — without it only the workspace bot is used. */ channelOps?: ChannelTokenOps; /** Ask Cloud to deliver a room message to an agent on another machine. */ handoffViaCloud?: (body: { agentSession: string; event: { channel: string; ts: string; thread_ts?: string; text?: string; user?: string; }; }) => Promise; /** * The decision path's outcome for a thread reply: the card's asker, and * whether the decision path consumed the message (resolved / skipped / * snoozed it — the asker already has it). Null when the message answers * no card here. Shares one run with the decision listener * (specs/2026-10-03-one-responder-per-message.md §3). */ decisionReplyFor?: (message: SlackIncomingMessage) => Promise<{ asker: string; consumed: boolean; } | null>; /** * What Slack shows in a thread after a message, read fresh: the 90 s * fallback hands nothing over when an agent (on any machine) has already * replied or put up "working on it" there. Null when unreadable. */ slackRepliesAfter?: (slackChannelId: string, threadTs: string, afterTs: string) => Promise; /** * Read a message's Slack thread again (fresh tokens, no cached failure): * the one retry when `message.threadContext` gave nothing. */ readThreadContext?: (message: SlackIncomingMessage, options: { maxWaitMs: number; }) => Promise; /** * Whether the agent holds work delegated from this owner thread (a live * WorkItem whose owner origin is the thread). Its posts there are owed * answers, never held by the reply gate (crewly#1083, * specs/2026-10-04-tl-delegation.md §1). Absent: no exemption. */ delegatedInThread?: (agentSession: string, slackChannelId: string, threadTs: string) => Promise; /** * Hand an owner's card answer the asker has not answered yet to the * owner-message watchdog (nudge, then a note), even though the decision * path already counted the card's thread as answered. Default: the * process-wide watchdog. */ trackOwnerMessage?: (input: OwnerMessageTrackInput) => void; /** Where pending unanswered-message watches are kept across restarts; defaults next to {@link storePath}. */ unansweredStorePath?: string; /** Clock override for tests. */ now?: () => Date; } /** Result of {@link SlackTeamChannelService.setRoomMembers}. */ export interface RoomMembershipChange { /** Sessions added to the roster */ added: string[]; /** Sessions removed from the roster */ removed: string[]; /** Added agents whose bot is now in the Slack channel */ invited: string[]; /** Added agents whose bot could not be invited (no bot yet, or Slack refused) — it is invited once installed */ notInvited: string[]; /** Removed agents whose bot left the Slack channel */ removedFromSlack: string[]; /** Removed agents whose bot Slack would not take out — remove it in Slack by hand */ stillInSlack: string[]; } /** Result of {@link SlackTeamChannelService.syncRoomsFromSlack}. */ export interface RoomSyncResult { /** Channels whose Slack name changed */ renamed: Array<{ slackChannelId: string; from: string; to: string; }>; /** Ad-hoc rooms that gained members found in Slack */ joined: Array<{ slackChannelId: string; added: string[]; }>; } /** Result of {@link SlackTeamChannelService.handoffForAgent}. */ export type HandoffResult = { ok: true; agentSession: string; displayName: string; via: 'here' | 'cloud'; } | { ok: false; reason: string; candidates?: string[]; }; /** Settings half of the store, exposed over REST. */ export interface SlackTeamChannelSettings { autoCreate: boolean; channelPrefix: string; } /** Result of {@link SlackTeamChannelService.routeInbound}. */ export interface RouteInboundResult { /** The mapping the message matched. */ mapping: SlackTeamChannelMapping; /** The persisted chat-v2 message. */ message: ChatMessageDTO; /** Sessions that were `@`-mentioned. */ mentions: string[]; /** Dispatcher outcome, or null when no dispatcher is wired yet. */ dispatch: DispatchMessageResult | null; /** True when this was a repeated copy of a message already routed. */ duplicate?: boolean; } /** * Turn a team name into a Slack-legal channel name: lower-case, letters of * any script, digits, `-` and `_`; whitespace and punctuation collapse to * `-`; at most 80 characters. * * @param teamName - The team display name * @param prefix - Optional prefix (e.g. `crew-`), sanitised the same way * @returns The channel name without `#`, never empty * * @example * slackChannelNameFor('Growth Team!', 'crew-') // 'crew-growth-team' */ export declare function slackChannelNameFor(teamName: string, prefix?: string): string; /** * Whether a Slack thread's owner's machine provably received a room message: * Cloud's delivery facts name it among the machines it pushed to, and Cloud * has not marked that machine as unproven in the room. Without delivery * facts (older Cloud builds) nothing is proven: the caller keeps the room's * rules, so a message is never left with nobody (crewly#1376). * * @param room - Cloud's room presence for the message * @param owner - The thread owner's roster entry * @returns True when the owner's machine has the message */ export declare function threadOwnerMachineHasIt(room: { delivery?: Pick; }, owner: { instanceId: string; verified?: boolean; }): boolean; /** * The machine that owns an un-@'d message in a shared room, from Cloud's * presence snapshot alone — every machine computes the same answer from * the same snapshot (its own agents included, judged by the snapshot, not * by local state). The room's home machine when Cloud names it and an agent * there is awake; otherwise the lowest instance id among machines with an * awake room member. Null when nobody is awake (Cloud's fallback decides). * * Cloud's delivery facts come first (specs/2026-10-04-room-delivery-audit.md): * with `nobody-awake`, Cloud's fallback machine is the owner; otherwise * the responder Cloud names (`delivery.owner`) is the owner — Cloud pushes it * first and names another if it cannot be reached. Without one, the owner is * computed among the machines Cloud delivered to (`delivery.targets`) only, * a machine with an awake team leader first: exactly one machine takes the * message, and never one that does not have it (2026-10-03, #content-team: * nobody; review of crewly-services#32: "uncertain" must not mean everyone). * * @param room - Cloud's room presence * @returns Owning instance id, or null */ export declare function roomOwnerInstance(room: { members: Array<{ instanceId: string; awake: boolean; leader?: boolean; }>; home?: { instanceId: string; }; fallback?: { instanceId: string; }; delivery?: Pick; }): string | null; /** * The one machine that watches a shared-room message nobody on it answers: * the room owner (#1019), else Cloud's fallback machine, else — nobody awake * and no fallback named — the lowest instance id among the machines in the * room that Cloud reports live (`live`, when Cloud sends it; a member it * marks `live: false` never counts). Every machine computes the same answer * from the same snapshot. Because the last resort may still name a machine * that is gone, every other machine that received the message keeps a * note-only watch (see `NOTE_ONLY_WATCH_MS`). * * @param room - Cloud's room presence * @returns Instance id, or null for an empty roster */ export declare function roomWatcherInstance(room: SlackRoomPresence): string | null; /** * The one machine that takes over an owner message the room's owner machine * never answered: the lowest instance id among the room's machines other * than the owner (one that Cloud reports `live: false` never counts). Every * machine computes the same answer from the same snapshot, so exactly one * arms the takeover watch. * * @param room - Cloud's room presence * @param owner - The room owner machine (it runs its own fallback) * @returns Instance id, or null when no other machine is in the room */ export declare function roomTakeoverInstance(room: Pick, owner: string): string | null; /** * Whether a mapping is an ad-hoc channel (no Crewly team behind it). * * @param mapping - The mapping * @returns True for `adhoc:` mappings */ export declare function isAdhocMapping(mapping: Pick): boolean; /** * Whether a team is a runtime smoke test's temporary team * (specs/2026-10-01-runtime-fallback.md). * * @param team - The team * @returns True for `zz-runtime-smoke-` */ export declare function isRuntimeSmokeTeam(team: Pick): boolean; /** * The agents that take part in a team channel: every member except the * orchestrator role (the orc is not a huddle participant — the point of * team channels is talking to the team without it). * * An idle member has no stored `sessionName` (the controller clears it on * stop), so it is derived — otherwise a stopped agent would vanish from * the Slack roster and its bot would be pruned. * * @param team - The team * @returns Members with a (stored or derived) session name, orchestrator excluded */ /** What every machine's orchestrator is called before it needs qualifying. */ export declare const ORCHESTRATOR_APP_NAME = "Crewly Orc"; /** The team id the orchestrator's Slack app is filed under, per instance. */ export declare const ORCHESTRATOR_SYNC_TEAM_ID = "orchestrator"; /** * The pseudo-team the orchestrator's app is filed under. * * Qualified by instance for the same reason the session is, and then one * more: Cloud prunes an app whose team appears in a sync but whose session * does not. With both machines filing under a bare `orchestrator`, each * sync would see the other's orchestrator sitting in a team it just synced, * not recognise the session, and delete the app — every time, in both * directions. Their real teams have distinct ids and were never at risk; * this one was hardcoded (spotted by the agent on the Air, 2026-09-21). * * @param instanceId - This instance's id * @returns The team id to file the orchestrator's app under */ export declare function orchestratorSyncTeamId(instanceId: string): string; /** * The session the orchestrator is registered with Cloud under. * * Every machine calls its orchestrator `crewly-orc`, and Cloud keys an * agent's Slack app on `(account, agentSession)` — so two machines on one * Cloud account would collapse into a single app, a single bot and a single * DM, which is the very thing the per-machine app exists to avoid. The * instance id makes the key unique; the suffix is stripped again the moment * an event comes back, so nothing downstream has to know about it. * * @param instanceId - This instance's id from the registry * @returns The session to register with Cloud */ export declare function orchestratorSyncSession(instanceId: string): string; /** * Whether a message is a bare @-mention: only mentions — no text, no files. * * @param message - Inbound Slack message * @returns True for a bare ping */ export declare function isBareMention(message: Pick): boolean; /** * The note for a bare @-mention: what the ping means. Null for anything else. * * @param message - Inbound Slack message * @param name - Who pinged (display name) * @returns The note, or null * * @example * bareMentionNoteFor({ text: '<@U0ELLA>', ts: '2.1', threadTs: '1.1' }, 'Steve') * // 'Steve pinged you in this thread with no text — read the thread above and act on …' */ export declare function bareMentionNoteFor(message: Pick, name: string): string | null; /** * The local session name for an inbound agent session. * * Only the orchestrator is qualified, and only towards Cloud. Everything on * this side — dispatch, the local roster, chat channels — knows it as * `crewly-orc`. * * @param agentSession - The session as Cloud sent it * @returns The local session name */ export declare function localAgentSession(agentSession: string): string; /** * The orchestrator as an entry for the agent-app roster. * * The Orchestrator Team is assembled by the teams API for display; it is * not stored, so `storage.getTeams()` — what the registry syncs from — never * contains it and {@link agentAppMembers} alone would never see an orc. * The registry appends this instead. * * @param deviceName - This machine's name; without one there is nothing to * tell two machines' orchestrators apart, so no app is asked for * @param instanceId - This instance's id, which keys the app per machine * @returns The roster entry, or null when either is not known yet */ export declare function orchestratorSyncEntry(deviceName?: string, instanceId?: string): { teamId: string; name: string; agentSession: string; displayName: string; } | null; export declare function teamChannelMembers(team: Team): TeamMember[]; /** * The team channel's lead: the team lead by the one rule * (`utils/team.utils`), else the first channel member. * * @param team - Team * @param members - Its channel members (default: {@link teamChannelMembers}) * @returns The lead member, or undefined for an empty team */ export declare function teamChannelLeader(team: Team, members?: TeamMember[]): TeamMember | undefined; /** * Pick the Slack identity an agent posts under. * * `username` is the member's display name. The icon is the member avatar * when it is a Slack emoji name (`:rocket:`) or a URL; otherwise a per-role * emoji, then the default robot. * * @param member - The team member, or undefined when the session is unknown * @param sessionName - Fallback display name * @returns The identity fields for SlackOutgoingMessage */ export declare function slackIdentityFor(member: TeamMember | undefined, sessionName: string): Pick; /** * Service — see module docs. */ /** * Whether a message is worded as a direct request to an assistant: "帮我…", * "请…", "麻烦…", "can you…", "please…", or an imperative such as "set up…". * Deterministic and deliberately small: it only decides whether an un-@'d * message inside a person-to-person exchange is a continuation of it * (specs/slack-room-presence.md "Direct requests inside an exchange"). * * @param text - Message text * @returns True when it reads as a request */ export declare function isDirectRequest(text: string): boolean; /** * Whether a room belongs to an assistant or support team, where a request is * what the team is there for: its name, template or Slack channel says so. * * @param team - The room's team, when known * @param slackChannelName - The room's Slack channel name * @returns True for an assistant or support room */ export declare function isAssistantRoom(team: Pick | null, slackChannelName: string | undefined): boolean; export declare class SlackTeamChannelService { private readonly logger; private readonly deps; private readonly storePath; private store; private pruneTimer; private pruneInterval; private loading; private unsubscribeStorage; private unsubscribeIdentity; /** Slack `channel:ts` of messages already routed → the persisted turn. */ private readonly seenInbound; /** Room messages already handed back to the orchestrator. */ private readonly orcHandedBack; /** * Inbound messages being handled now (`channel:ts`). The two event types of * one @ arrive milliseconds apart, and the first is still awaiting teams and * identities when the second checks `seenInbound` — both were recorded and * dispatched (~24 doubled rows a week, 2026-10-22 audit). */ private readonly inboundInFlight; /** The last room presence Cloud sent per Slack channel — names for a hand-off. */ private readonly lastRooms; /** People (not agents) by lower-cased name → Slack user id, for `@Name` in agent replies. */ private readonly humanNames; /** Whether the owner's names were looked up yet. */ private ownerNamesLoaded; /** The owner's display name for posts made on their behalf. */ private ownerName; /** channelId → members, with fetch time; decides between same-named agents. */ private readonly channelMembers; /** * Owner messages that reached nobody on this machine, keyed * `slackChannel:threadTs`, each waiting for any agent to take it before * {@link runUnansweredFallback} steps in. */ private readonly unanswered; private readonly onChatMessage; private started; /** Per-team serialisation so two team-saved events cannot create two channels. */ private readonly inflight; /** * Top-level agent posts still on their way to Slack, by chat message id. * A thread reply to one of them (a report's detail under its summary, sent * a few hundred ms later) waits for the root's Slack ts instead of * guessing. */ private readonly rootPosts; /** Near-identical agent-to-agent repeats (crewly#1340). */ private readonly coordinationDedupe; /** * `chatChannelId:helper` → the agent that last asked it something here, and * when — so a reply the helper cannot post in Slack goes back to the asker. */ private readonly askedBy; /** `slackChannelId:YYYY-MM-DD` → Slack ts of that day's cross-machine coordination thread. */ private readonly coordinationThreads; /** Ticket prefixes, cached. */ private ticketPrefixCache; constructor(deps: SlackTeamChannelServiceDeps); /** * Subscribe to team lifecycle events and chat-v2 agent messages. Idempotent. * Safe to call before Slack is connected — every Slack call is guarded by * `isConnected()` at the time it runs. */ start(): Promise; /** * Give every mapping a chat channel that exists. A mapping whose * `chatChannelId` is not in the chat store (a team moved here without its * room, a wiped chat.db) made every Slack turn reach its agents with no * local thread history and `GET …/messages` answer 404 (crewly#1344, * #air-steamfun). A fresh huddle with the known roster takes its place. * * @returns Mappings re-pointed */ healMissingChatChannels(): Promise; /** * Drop ad-hoc room members whose bot is not in the Slack channel. * * Each room is read with its members' own bot tokens, one after another, * until one can read it (`conversations.members`): members whose bot user * is not in that list are dropped. A bot that gets `not_in_channel` / * `channel_not_found` is itself not in the room and is dropped. A member * with no bot of its own, or a read that fails otherwise (rate limit, * network), is kept — nothing is dropped on a guess. A dropped agent comes * back on its own the next time a copy arrives through its app. * * @returns Per room changed: the channel and the sessions dropped */ pruneAdhocMembers(): Promise>; /** * Make sure the master bot is still a member of every channel we map. * * Reinstalling the workspace app drops the bot out of every channel it * had joined, and nothing put it back: joining only ever happened when a * channel was first linked. A channel the bot has left delivers no * events at all, so a message there reached neither Cloud nor any * instance — the owner saw no reaction, no reply and nothing in any log, * because from Slack's side nothing had happened (`#rednote-team`, * 2026-09-21). * * Best-effort and idempotent: `conversations.join` on a channel we are * already in is a no-op, and a private channel cannot be joined at all — * that one needs a human to invite the bot, so say so. A channel archived * in Slack (`is_archived`) is remembered on the mapping and mentioned once; * it is still tried on later boots so unarchiving it brings it back. * * @returns When every mapping has been checked */ private rejoinMappedChannels; /** * Invite the workspace owner into a channel. * * @param channelId - Slack channel id * @param channelName - For the log * @returns True when the owner is now in it (invited, or already there) */ private inviteOwner; /** * Remember that Slack reported a mapped channel archived; said once. * * @param channelId - Slack channel id * @param channelName - For the log * @returns When the store is saved (no-op when already known or unmapped) */ private markSlackArchived; /** * Put the owner into every channel Crewly created that they never got into. * * The invite used to happen once, when the channel was created. At boot the * owner's Slack id arrives with the Cloud config a moment after the first * channels can already be created, and the first one of a batch was made * with no one to invite: `#crewly-marketing` existed for four days with * only bots in it, invisible in the owner's sidebar (2026-09-19 → 09-23). * Retried on every (re)connect until it lands; once it has, never again. * * @returns When every such channel has been tried */ inviteOwnerWhereMissing(): Promise; /** * Give every team with members a Slack channel when auto-create is on. * Existing mappings are left alone; teams without a (non-orchestrator) * member are skipped so the workspace is not carpeted with empty rooms. * Safe to call any time Slack (re)connects. * * @returns Which teams got a channel this pass */ reconcileAllTeams(): Promise<{ created: string[]; skipped: number; }>; /** * Whether Slack is connected right now (creating a channel needs it). * * @returns True when connected */ isConnected(): boolean; /** Undo {@link start}. */ stop(): void; /** Read the store from disk once; later calls return the cached copy. */ private load; private save; /** * Current settings (auto-create + prefix). * * @returns The settings half of the store */ getSettings(): Promise; /** * Update settings. Only the keys present in `patch` change. * * @param patch - Partial settings * @returns The settings after the update */ updateSettings(patch: Partial): Promise; /** * Rename every auto-created channel to match the current prefix. * * Each one goes through {@link syncChannelName}, so the protections hold: * a channel Crewly did not create is left alone, and so is one the owner * has renamed by hand. A failure on one channel does not stop the rest — * a half-renamed workspace is still better than stopping at the first * channel Slack refuses. * * @returns The channels renamed, old name → new name */ applyPrefixToExistingChannels(): Promise>; /** * All mappings, in creation order. * * @returns Copies of every mapping */ listMappings(): Promise; /** * Mapping for a team, if any. Synchronous read of the cached store — the * bridge calls this on every inbound Slack message. * * @param teamId - Crewly team id * @returns The mapping, or null */ findByTeamId(teamId: string): SlackTeamChannelMapping | null; /** * Mapping for a Slack channel, if any (sync, cached store). * * @param slackChannelId - Slack channel id * @returns The mapping, or null */ findBySlackChannelId(slackChannelId: string): SlackTeamChannelMapping | null; /** * Mapping for a Slack channel name (sync, cached store). * * @param name - Channel name without `#`, case-insensitive * @returns The mapping, or null */ findBySlackChannelName(name: string): SlackTeamChannelMapping | null; /** * Mapping for a chat-v2 huddle, if any (sync, cached store). * * @param chatChannelId - chat-v2 channel id * @returns The mapping, or null */ findByChatChannelId(chatChannelId: string): SlackTeamChannelMapping | null; /** * Every non-archived team with its mapping (or null). One call for the * Settings UI. * * @returns Teams in storage order */ listTeamsWithMappings(): Promise>; /** * Look a team up by id. * * @param teamId - Crewly team id * @returns The team, or null */ getTeam(teamId: string): Promise; /** * Make sure `team` has a Slack channel and a huddle. Creates the Slack * channel (or reuses one with the same name) unless `slackChannelId` * names an existing channel to link. Idempotent: an existing mapping is * returned after a roster sync. * * @param team - The team * @param options - `slackChannelId` to link an existing channel instead of creating * @returns The mapping * @throws Error when Slack is not connected or the channel cannot be created/found */ ensureTeamChannel(team: Team, options?: { slackChannelId?: string; }): Promise; /** * Make sure an extra channel for a set of agents exists (solution bundles: * e.g. an approvals channel with the lead and the writer). It is stored * like an ad-hoc channel (`adhoc:` with a member roster), so * routing, outbound mirroring and room presence treat it the same way. * * Idempotent: `existingChannelId`, or an auto-created ad-hoc channel with * the same derived name, is reused; its roster gains the given agents. * The owner is invited on creation, and agents whose Slack bot is already * installed are invited into it. * * @param input - Channel name, purpose, agent sessions, a known channel id * @returns The mapping * @throws Error when Slack is not connected or the channel cannot be created */ ensureAgentChannel(input: { name: string; purpose: string; memberSessions: string[]; existingChannelId?: string; /** Prepend the configured channel prefix (default true). A name the owner typed is used as is. */ applyPrefix?: boolean; /** Link this existing huddle instead of creating one (a Crewly channel made while Slack was off). */ chatChannelId?: string; }): Promise; /** * Take over a team's mapping from another machine (team move, crewly#1189). * Adds it unless this machine already maps the team or the Slack channel, so * the arriving team reuses its channel instead of getting a second one. * * @param mapping - The mapping as the source machine had it * @returns True when the mapping was added */ adoptMapping(mapping: SlackTeamChannelMapping): Promise; /** * Remove a team's mapping. Optionally archives the Slack channel; the * huddle is always archived (it is Crewly's own object). * * @param teamId - Crewly team id * @param options - `archiveSlackChannel` (default false — leave the humans' channel alone) * @returns True when a mapping existed */ unlinkTeam(teamId: string, options?: { archiveSlackChannel?: boolean; }): Promise; /** * Keep an auto-created channel's name in step with its team's. * * Renaming a team used to leave its Slack channel on the old name for good, * so #strategy stayed #strategy after the team became "crewly-strategy-team" * and the owner had to rename it by hand (2026-09-20). * * Three things it will not do. It never touches a channel Crewly did not * create — that one is the owner's, linked deliberately. It never touches a * channel whose live name has drifted from what Crewly last derived, because * that means the owner renamed it themselves and their choice outranks the * team name. And it never fails the team update: a rename Slack refuses * (the name is taken, the bot lacks the scope) is logged and dropped. * * Costs nothing on the common path: the derived name is compared against the * stored one first, and team-saved events fire on every status write, so * asking Slack each time would hammer the API for nothing. * * @param team - The team, after the change * @param mapping - Its mapping * @returns The new channel name, or null when nothing was renamed */ syncChannelName(team: Team, mapping: SlackTeamChannelMapping): Promise; /** * Reconcile the huddle roster with the team's current members. * * @param team - The team * @param mapping - Its mapping (looked up when omitted) * @returns The roster diff, or null when the team is unmapped */ syncTeamMembers(team: Team, mapping?: SlackTeamChannelMapping): Promise<{ added: string[]; removed: string[]; } | null>; /** * React to a storage event. Public so the bridge/tests can drive it * directly; `start()` subscribes it. * * @param event - The storage event */ handleStorageEvent(event: StorageEvent): Promise; /** * Post the polite decline for every @'d agent that is dedicated to someone * other than the sender (issue #968). * * @param message - Inbound channel message (from a human) * @param mentions - Sessions it addressed * @returns The sessions that declined */ private declineDedicatedMentions; /** * Take every @'d agent of a paused team off the list * (specs/2026-10-04-team-pause.md): it is not woken; the thread gets one * short auto-reply from that agent's bot, at most once per thread. * * @param message - Inbound channel message * @param mentions - Sessions it addressed * @param nameOf - Display name of a session * @returns The sessions taken off */ private declinePausedMentions; /** * Route an inbound Slack message that arrived in a mapped channel. * * 1. Persist into the huddle: a top-level Slack message becomes a chat-v2 * root carrying `slackThreadTs = ts`; a Slack thread reply is filed * under the root that carries `slackThreadTs = thread_ts` (or becomes * a root itself when that root predates the mapping). * 2. Resolve `@name` tokens against the team's members → `mentions`. * 3. Dispatch through the chat-v2 dispatcher (`huddle-broadcast`) with * the thread id and the `reply-channel` hint. * 4. Answer unknown `@names` in-thread with suggestions. * * A person's message that @'s only people (Slack users that are not Crewly * agents) is recorded as context and dispatched to nobody: no 👀, no * placeholder, no ticket, no unanswered-message watch, no suggestion hint. * * @param message - The inbound Slack message * @returns The routing result, or null when the channel is not mapped */ routeInbound(message: SlackIncomingMessage): Promise; /** * Who answers a person's room message, from data only this layer has * (specs/2026-10-03-one-responder-per-message.md §1): the decision path's * outcome (a), else — for an un-@'d thread reply — the Slack thread's * owner (c). @-mentions (b) and the top-level room rule (d) are applied by * the dispatcher. Never throws. * * @param message - Inbound message * @param mapping - Its room * @param members - Agents the room resolves names against (local) * @param team - The room's team, or null for an ad-hoc room * @param mentions - Local agents @'d * @param handoffTo - Local agent the message was handed to * @returns One-responder options for the dispatcher */ private oneResponderFor; /** * What this machine's chat log can say about a thread's last speaker when * Slack cannot be read. Cloud never forwards other machines' bot posts, so * with colleagues on other machines in the room the log is trusted only * when its latest agent turn in the thread is local and recent. * * @param chatChannelId - The huddle * @param threadId - The thread root here, when known * @param messageId - The message being routed (not counted) * @param room - Cloud's room presence * @param me - This machine's instance id, when known * @returns `trusted` when local rules may answer from the log; `remote-last` * when the log shows a colleague on another machine spoke (or was asked) * last; `stale` / `unknown` when the log cannot say */ private localThreadLog; /** * Record on the owner's row who was chosen to answer it — the reply gate * holds only a post that repeats what one of them already said. * * @param persisted - The owner's huddle row * @param dispatch - What was delivered here * @param one - The one-responder options used */ private recordChosenResponders; /** * The owner of the Slack thread a message replies in, read from Slack * (`message.threadContext`), so every machine names the same agent. Null * owner when no agent has spoken in it; `readable: false` when the thread * could not be read in time. * * @param message - Inbound thread reply * @param members - Local agents the room resolves names against * @param roomSessions - This machine's room members * @param isLocal - Whether a session runs here * @returns The thread owner, or null */ private slackThreadOwner; /** * The owner thread an agent is about to post into has gone quiet: the * owner last wrote in it longer ago than STALE_OWNER_THREAD_MS. A post * there reads with no context (2026-10-19, Ella's Reddit summary). * * @param input - Conversation and the thread the agent named (message id or Slack thread key) * @param now - Current time (epoch ms) * @returns When the owner last wrote there, or null when the thread is fresh, not a room thread, or the owner never wrote in it */ staleOwnerThread(input: { conversationId: string; thread?: string | null; }, now?: number): Promise<{ lastOwnerAt: number; } | null>; /** * The reply gate (specs/2026-10-03-one-responder-per-message.md §2): an * agent is about to post in a room thread where a different agent already * answered the owner's latest message. Reads this machine's huddle thread * only — no Slack call. * * @param input - Conversation (huddle id), the thread named (huddle message id or Slack thread key), and the agent * @returns The earlier answer when the post should be held, else null */ heldReplyFor(input: { conversationId: string; thread?: string | null; agentSession: string; }): Promise; /** * Hold an owner's room message that reached nobody here until an agent * takes it — on this machine or another — or the fallback runs. * * @param message - The inbound Slack message * @param mapping - Its channel mapping * @param persisted - The huddle row it was recorded as * @param threadId - Huddle thread the replies land in */ private watchUnanswered; /** Stop waiting on a message: someone has it. */ private settleUnanswered; /** @returns Where pending watches are kept across restarts */ private unansweredStorePath; private unansweredWrite; /** * Write the pending watches (those with a timer) to disk, serialised and * best-effort: a watch armed seconds before a restart must not vanish * with its timer. */ private persistUnanswered; /** * Re-arm the watches pending when the process last stopped. Each fires at * its original due time, but no sooner than a grace period after boot; * one older than the restore limit is dropped (the owner-message * watchdog, also persisted, owns long waits). */ private restoreUnanswered; /** * Arm the route guard for an owner's room message that was just recorded * (crewly#1015 §7). `dispatchStarting` right before dispatch: it stops the * timer and answers false when a rescue already ran (the caller must not * dispatch then). `fail` when routing threw — a rescue only when dispatch * had not started. `disarm` when routing finished. Not at dispatch within * ROUTE_STALL_MS → the message is rescued. * * @param message - The inbound Slack message * @param mapping - Its room * @param persisted - The huddle row it was recorded as * @param threadId - Huddle thread the replies land in * @returns The guard */ private armRouteGuard; /** * Wait for a routing step at most ROUTE_STEP_TIMEOUT_MS; on timeout (or a * rejection) go on with `fallback` and say so. Only for steps before * dispatch that routing can do without (crewly#1015 §7). * * @param step - The step * @param fallback - Value used when it does not settle in time * @param label - For the log * @param message - The message being routed (for the log) * @returns The step's value, or the fallback */ /** * Record the Slack thread's posts this machine never received into the * local chat thread, as their real authors (`source: 'slack'`, * `slackBackfill: true`): a person's post under their name, a colleague * agent on another machine as ` (agent)` with its session — the * same shape a forwarded post has. This machine's own agents (already in * the thread as agent rows), foreign bots and the workspace bot are left * out, and so is anything already recorded (by Slack ts). Rows are added * after the message being delivered (the store orders by arrival); their * `slackTs` keeps the real order. Never throws, never dispatches. * * 2026-10-08, #content-team: three of Rex's posts (the Air) never reached * the Mac. Ella, pinged with no text, read the local thread, found nothing * open and answered another topic. * * @param ctx - The thread as Slack has it (before the message) * @param chatChannelId - The room's chat channel * @param threadId - The local thread (root message id); nothing is recorded without one * @param message - The message being delivered * @returns How many posts were recorded */ backfillFromSlack(ctx: SlackThreadContext, chatChannelId: string, threadId: string | undefined, message: SlackIncomingMessage): number; /** * Who sent a message, by name, for a one-line note. Never throws. * * @param message - Inbound Slack message * @returns Their name, or "The owner" / "Someone" when it cannot be read */ private pingerName; private bounded; /** @returns How long routing may take before the guard rescues the message (env override for tests). */ private routeStallMs; /** * Rescue an owner's room message whose routing stalled or failed: the * unanswered-message fallback runs at once (the room lead here is handed * the message). A message that was itself such a hand-off is not handed * over again: the owner is told in the thread. * * @param message - The inbound Slack message * @param mapping - Its room * @param persisted - The huddle row * @param threadId - Huddle thread the replies land in */ private rescueUnroutedMessage; /** * An agent spoke in a room — any message we were waiting on in that * thread (or the room, for a top-level post) has been taken. * * @param channelId - Huddle id (`here`) or Slack channel id (`slack`) * @param thread - Huddle thread id (`here`) or Slack thread ts (`slack`); null for top level * @param via - Where the activity was seen */ private noteAgentActivity; /** * Nobody took an owner's room message in time: wake the room's lead on * this machine and hand it the message; with no lead here, or when that * fails too, tell the owner in the thread. * * @param key - `slackChannel:threadTs` of the waiting message */ private runUnansweredFallback; /** * The owner answered a card in its thread, the asker (here) got the * answer as a note, and nothing has been posted in the thread since. No * hand-off to the lead — the answer is the asker's to act on; the * owner-message watchdog now follows it up with the asker (a reminder at * its first deadline, then a note to the owner), and it survives a restart. * * @param pending - The watch that fell due */ private followUpCardAnswer; /** * The member of a room that should pick up a message nobody took, among * the agents that run on this machine: whoever of them spoke there last * (the owner is most likely continuing that conversation), else the team * lead, else the first local member. * * @param mapping - The room * @returns A local session, or null when none of the room runs here */ private localRoomLead; /** * Tell the owner, in the thread, that nobody could take the message. Posts * as a room member's own bot when one is installed — the workspace bot is * usually not in a private room. * * @param pending - The waiting message * @param mapping - Its room, when still mapped */ private postUnansweredNote; /** * Ticket intake for a team-channel / shared-room message. * * Only the owner's messages count (a colleague agent's post never files a * ticket). In a room shared across machines every machine sees the * message, so only the machine that owns it files the ticket: the one with * the team, or — in an ad-hoc room — the one whose agent was @'d or handed * the message. Never throws; null when no ticket applies. * * @param message - Inbound Slack message * @param mapping - Its channel mapping * @param mentions - Local sessions @'d * @param planned - Dispatch plan (session → required/optional), when known * @param handoffTo - Local agent the room's router handed it to * @param remoteAgent - Authoring agent, when an agent wrote it * @returns The ticket the message belongs to, or null */ private intakeTicket; /** * Who in this room is awake, from this machine's point of view. * * Local members are judged by what is running here now; members on other * machines by what Cloud last heard from them. Cloud names the one machine * that wakes someone when nobody is awake. One case it cannot see: it may * believe an agent of ours is awake when that agent has just stopped. * Then nobody would be woken anywhere — every other machine thinks we have * it — so this machine, the only one that knows better, wakes the router. * * @param message - Inbound message (carries Cloud's presence, when any) * @param mapping - Its channel mapping * @param team - The channel's team, or null for an ad-hoc room * @returns State for the dispatcher and a line for the prompt; null without an awake check */ private roomStateFor; /** * Whether a channel message that has no room mapping on this machine (it * would fall through to the orchestrator) belongs to another machine's * room. The orchestrator must not pick it up and forward it to a local * agent (2026-10-03: the Air's orc routed the Mac's room message to the * Air's Ella). * * @param message - Inbound message * @returns True when another machine owns it */ sharedRoomOwnedElsewhere(message: SlackIncomingMessage): Promise; /** * Hand a room message to one agent, wherever it runs. * * Used by the room's router — the orchestrator of a private room, woken * because nobody in it was awake. Its own bot is usually not in the room, * so it cannot @ the agent there the way a member would. An agent on this * machine gets the message delivered again as if @'d (👀 and a "waking * up…" placeholder from its own bot); one on another machine gets it * through Cloud, which does the same over there. * * @param input - Chat channel, the message to pass on, and who should answer it * @returns Who it went to, or why it could not */ handoffForAgent(input: { chatChannelId: string; messageId?: string; threadId?: string; name: string; }): Promise; /** * A room-join request from another machine (via Cloud): link the existing * Slack channel here with the given LOCAL agents as members, so they are in * the room and show up in this machine's heartbeat `rooms`. * * Sessions that are not agents of this machine are rejected and reported, * never silently dropped. A channel that is a team's channel is refused. * * @param input - Slack channel and the sessions Cloud addressed to this machine * @returns Which sessions joined and which were rejected */ joinRoomFromCloud(input: { slackChannelId: string; slackChannelName: string; sessions: string[]; }): Promise<{ ok: boolean; joined: string[]; rejected: string[]; error?: string; }>; /** * Every Slack channel this machine maps — team channels and ad-hoc rooms — * with the local agents in it. What the delivery audit reads Slack for. * * @returns Channel id, name and local member sessions */ listMappedRooms(): Promise>; /** * The ad-hoc rooms this machine's agents are in, for the Cloud heartbeat — * Cloud builds each room's cross-machine roster from these. * * @returns Slack channel → local member sessions */ listRooms(): Promise>; /** * Local agents Cloud's envelope names in a channel: @-mentioned by name * (`mentionedAgentSessions`) or listed as this machine's members of the * room (`room.members`). The orchestrator is never a room member. * * @param message - The inbound message (a Cloud-routed copy) * @param local - Sessions of this machine's team members * @returns Named local sessions, in envelope order */ private localNamedByCloud; /** * The local agent whose own app delivered this copy, if any. * * A direct message is never a room: an agent's DM is handled by the DM * service, and anything that falls through from there belongs to the * orchestrator path, not to an ad-hoc huddle. * * @param message - The inbound message * @returns The agent's session name, or null */ private localReceivingAgent; /** * Put 👀 on the message — one per agent that will receive it. * * It used to be a single reaction meaning "Crewly got this". The owner * reads it as "how many agents saw this", and with several agents in a * room that is the more useful answer: three agents who will each weigh a * message should show three eyes, and an agent that was not handed the * message should show none, so the count is honest rather than "everyone * in the channel". Each agent reacts with its own bot. * * When nobody in particular receives it, or no receiving agent has a bot * of its own, one reaction still goes on so the owner can see the message * arrived — the 2026-09-21 lesson: no eyes reads as nothing arrived. * * @param message - The inbound message * @param mapping - Its channel mapping * @param mentions - Agents @'d in it * @param planned - Who will receive it, from the dispatcher; null when unknown */ private acknowledgeSeen; /** * Any Slack channel becomes routable the moment a local agent's bot is * @'d in it, or a message reaches us through a local agent's own app * (Slack only delivers to apps that are members, so that proves the bot * is in the channel). The owner invited agents from different teams into * a private channel, say: a huddle is created for the channel with those * agents as members and mapped under the synthetic team id * `adhoc:`. Anything else is left to the caller (orchestrator * path). * * @param message - The inbound message * @returns The new mapping, or null when no local agent was @'d */ private ensureAdhocChannel; /** * Post an agent's huddle message to the mapped Slack channel under the * agent's identity. Called for every chat-v2 `chat_message`; ignores * anything that is not an agent message in a mapped huddle. * * @param dto - The chat-v2 message * @returns True when a Slack post was attempted */ mirrorOutbound(dto: ChatMessageDTO): Promise; /** * {@link mirrorOutbound} with the outcome: the Slack channel + ts the reply * landed at (also stored on the chat message), or Slack's error. * * @param dto - The chat-v2 message * @returns Whether a post was attempted, and its delivery (null when not meant for Slack) */ mirrorOutboundDetailed(dto: ChatMessageDTO): Promise<{ attempted: boolean; delivery: SlackOutboundDelivery | null; }>; /** * Decide what happens to an agent's room post that addresses other agents. * * The model is a company group chat: whoever the room asked is its one * voice; it asks its helpers privately and posts one update when their * result is in. So a post that @'s only agents on this machine — no * person, no name nobody knows, not inside a thread a person is in — is * delivered inside Crewly (the same dispatcher prompt the Slack round-trip * produced) and never posted to Slack. A reply in such an internal thread * stays internal too, and goes to the thread's other agents when it @'s * nobody. Everything else is left to the Slack path; posts that reach an * agent on another machine are threaded there. * * Until now a local colleague heard such a post only through the Slack * round-trip (its bot copy came back in and was dispatched from a * transient `slack-echo-…` turn), so every one of them was a Slack post — * and a colleague whose bot is not in the channel could not answer: its * reply named that transient thread and was refused. * * @param mapping - The room * @param dto - The agent's post * @returns Handled here (with the reason it is not posted), or the addressing for the Slack path */ private coordinate; /** * Whom an agent post @'s: agents on this machine, agents on other machines * (Cloud's room roster and the directory), and anything else — a person, * a broadcast, a name nobody knows (each of which keeps the post in Slack). * * @param dto - The agent's post * @returns The resolved names and their class */ private agentAddressing; /** * Agents of this account on other machines that may be in a room, by * (lower-case) name and bot id: Cloud's last room roster for the channel, * then the directory (cached). Never throws. * * @param chatChannelId - The room's chat channel * @returns Name → session, bot id → session */ private agentsElsewhere; /** * Whether a person started a chat thread or spoke in it. * * @param chatChannelId - The room's chat channel * @param threadId - Thread root id * @param root - The root row, when known * @returns True when a person is part of the thread */ private personInThread; /** * The agents of an internal coordination thread other than the sender: * whoever wrote in it and whoever it @'d. * * @param root - The thread root * @param sender - The agent posting now * @returns Local agent sessions */ private internalThreadAgents; /** * Make sure every recipient is on the room's roster, so the dispatcher * delivers to it: an ad-hoc room grows by an explicit @ (as it does for a * Slack message); a team channel does not, so a colleague of another team * keeps today's path (its own bot hears the Slack post). * * @param mapping - The room * @param recipients - Agents to deliver to * @returns True when all of them are on the roster */ private roomHears; /** * Remember on the chat row that it stayed inside Crewly, so replies in its * thread stay inside too (and a later reader knows why Slack never had it). * * @param dto - The agent's post * @param reason - Why */ private markInternal; /** * Deliver an agent's post to colleagues on this machine without Slack: * the dispatcher gets it as a colleague's turn — exactly what the Slack * round-trip produced — but threaded on the real chat row, so their * replies find it (the transient `slack-echo-…` id of the round-trip did * not exist, and those replies were refused). * * @param mapping - The room * @param dto - The agent's post * @param recipients - Local agents it is for * @param reason - Logged and stored on the row * @param note - A line put before the text (a hand-back) * @returns False when there is no dispatcher to deliver with */ private deliverInternally; /** * Remember who asked a helper something in a room. * * @param chatChannelId - The room * @param helper - The agent asked * @param asker - The agent that asked * @param now - Time (ms) */ private noteAskedBy; /** * A helper's reply its bot could not post in the room (not a member of the * Slack channel): handed to the agent that asked it — the thread root's * author, else the agent that last @'d it here, else the room's lead — * who is in the room and posts the update. Logged either way. * * @param mapping - The room * @param dto - The helper's reply * @param code - Slack's error code * @returns True when someone here got it */ private handBackUnpostable; /** * The Slack ts of the room's cross-machine coordination thread today * (UTC), if one was started. * * @param mapping - The room * @returns Thread ts, or undefined */ private coordinationThreadTs; /** * Make a top-level cross-machine post the room's coordination thread for * today: later ones go under it. * * @param mapping - The room * @param dto - The post (its Slack ts was recorded by recordSlackRoot) */ private rememberCoordinationThread; /** `slackChannelId:YYYY-MM-DD` (UTC). */ private coordinationKey; /** * An agent's text without internal ticket ids (tickets are the harness's * bookkeeping; the owner never sees their ids). Never throws. * * @param text - The agent's text * @returns The text for Slack */ private withoutTicketIds; /** * Store where a mirrored reply landed on its chat message, in the shape * inbound messages use, so the Slack side can be audited from our store. * * @param dto - The mirrored message * @param slackChannelId - Slack channel posted to * @param ts - Slack ts of the message that carries the reply ('' when unknown) * @param threadTs - Thread the reply is in * @returns The delivery */ private recordDelivery; /** * Post a message the owner typed in Crewly into the room's Slack channel, * so people and agents on other machines see it. Posted by the workspace * (Crewly) bot as `** (from Crewly): …`; in a private room the * workspace bot is not in, a member's bot posts it instead. Threads follow * the chat thread's Slack root. The Slack copy is remembered as already * routed, so its echo is never dispatched a second time (the chat-v2 * dispatcher already delivered the original). * * @param mapping - The room (ad-hoc mapping) * @param dto - The owner's chat row * @returns True when Slack took it */ private mirrorOwnerPost; /** The owner's Slack name (cached), or "Owner". */ private ownerDisplayName; /** * Post an agent message that belongs to no thread as a new top-level * Slack post. * * It used to go into the channel's latest Slack thread — whatever the owner * last started — and through the "working on it" placeholder there: every * nightly "Crewly 日报" from Dana landed inside an unrelated owner thread * (`threaded:true, via:typing-placeholder`, 2026-09-26 → 10-03), as did * other scheduled reports in #pro-ce, #pro-think-tank and the ad-hoc rooms. * A top-level post never settles or replaces a placeholder: placeholders * live in the thread of the message they answer, and only a reply in that * thread may take one down. * * The Slack ts is recorded on the chat row, so a later reply in its chat * thread (a report's detail under its summary) goes under this post. * * @param mapping - Channel mapping * @param dto - The agent message (no thread) * @returns The attempt and its delivery (Slack's ts is stored on the chat row) */ private postTopLevel; /** * Remember the Slack ts of an agent's top-level post on its chat row, so * chat replies under it and Slack replies to it find each other. * * @param dto - The chat row that was posted * @param mapping - Its channel mapping * @param ts - The Slack message ts */ private recordSlackRoot; /** * The team member an agent posts as in a channel. * * A team channel looks in its own team. An ad-hoc room (`adhoc:`) * belongs to no team, so the agent is looked up across every local team: * without that it posted under its session name, which Cloud could not * match to a roster display name, so other agents never heard it. * * @param mapping - Channel mapping the post goes to * @param sessionName - Posting agent's session * @returns The member, or undefined when the session is not on a local team */ private findPostingMember; /** * Turn `@Name` in an agent's reply into a real Slack mention of that * agent's bot user, for every agent of the account with an installed bot * — including agents on other machines (the identity cache holds the * whole account). Names nobody owns are left as typed. * * @param text - Reply text * @param channelId - Slack channel the text goes to; when two agents share * a first name, the one in this channel is meant * @returns Text with `<@Uxxx>` mentions */ linkAgentMentions(text: string, channelId?: string): Promise; /** * Agent sessions on the Crewly room mapped to a Slack channel — the agents * that are in that channel. Empty when the channel isn't mapped (or the * store hasn't loaded yet). * * @param slackChannelId - Slack channel id * @returns Agent session names */ rosterSessions(slackChannelId: string): string[]; /** * Bot user ids of the agents on the Crewly room mapped to a Slack channel. * * @param slackChannelId - Slack channel id * @param identities - The account's agent identities * @returns Bot user ids (empty when the channel isn't mapped) */ private rosterBotIds; /** * Members of a Slack channel, cached briefly. Null when Slack can't say. * * @param channelId - Slack channel id * @returns Member user ids, or null */ private membersOf; /** * The people (not Crewly agents) a message @'s. * * A native `<@U…>` mention is not a person when the id is a local agent's * own bot (any team), the orchestrator's bot, the connected master bot, or * a bot the directory lists (agents on other machines, other accounts' * agents, other bots in the channel). The directory is the same cached * list the roster line reads, so no extra Slack call is made per message. * Every other id is a person — someone the agents were not asked to answer * for. * * A plain `@Name` that matched no agent but is a person who has spoken in * a mapped channel is a person too, and is taken out of * `resolved.unknown` so nobody is offered a "did you mean" for it. * * @param message - The inbound message * @param resolved - Agent mentions already resolved (its `unknown` is pruned) * @param teams - Every local team * @returns People's user ids and plain names */ private peopleMentions; /** * Whether a message with no addressee of its own continues a * person-to-person exchange (specs/slack-room-presence.md "Follow-ups of a * person-to-person exchange"). Reads the huddle rows already recorded, so it * holds across restarts. * * - In a thread: walking back from the newest row, agents' posts and human * rows with no addressee (or an inherited one) are skipped; the first * human row with an addressee decides. An agent (here or on another * machine) or `@here` → no inheritance; the most recent people-only row * older than that is returned as `recent`, for the prompt backstop. * People only → inherit them (or `recent`, for a direct request by * someone other than those people). Either way only while that @ is * within the person-exchange window: an exchange that went quiet is over, * and nothing is returned. * - At the top level: the channel's previous top-level row, when it came * within the follow-up window and was addressed to people only — inherited * when the same person wrote it, `recent` otherwise. * * `request` marks a `recent` exchange whose new message reads as a direct * request ({@link isDirectRequest}) from someone the exchange did not address. * * @param message - The inbound message (no addressee of its own) * @param chatChannelId - Its huddle * @param threadId - Its huddle thread root, when the Slack thread is known here * @returns Inherited people (with why), people of a recent exchange, and whether the message is a request */ private personExchangeOf; /** * A time window, from its env override when that is a non-negative number, * else the default. * * @param envVar - Env var holding milliseconds * @param fallbackMs - Default * @returns Milliseconds */ private windowMs; /** * How to name people in an agent's prompt: `Name (<@U…>)` when a name is * known (someone who spoke in a mapped channel, the owner, or a channel * member the cached directory lists), else the bare mention. Plain typed * names are kept as `@Name`. * * @param people - Slack user ids and/or typed names * @param slackChannelId - Channel, for the directory lookup * @returns One label per person */ private personLabels; /** * Remember a person's names so an agent writing `@Their Name` reaches them. * * @param userId - Slack user id * @param names - Real name, handle, … (blanks ignored) */ rememberHuman(userId: string, names: ReadonlyArray): void; /** * Turn `@Steve Huang` (a person, possibly a multi-word name) into a real * Slack mention. Agents wrote the owner's name and Slack showed plain text — * no notification — because only agent bots were ever linked, and only * single-word names (2026-09-24). The owner's names are looked up once; any * other person is known once they have spoken in a mapped channel. * * @param text - Reply text * @returns Text with `<@Uxxx>` for known people */ private linkHumanMentions; /** * Show " is working on it…" in the thread, at the agent's request. * * Agents that must answer get this placeholder the moment the message * arrives. Agents that were only *told* — a message nobody addressed to * them, passed along so they can judge whether it concerns them — get none, * because a placeholder promises a reply and most of them will not send * one. When one of them does decide to answer, it calls this, so the owner * sees who has taken the message on: two agents deciding to answer show two * placeholders, which is exactly the signal the owner asked for. * * The key matches the one the reply is mirrored under, so the agent's * actual answer replaces the placeholder rather than landing beside it. * * @param input - Chat channel and thread the agent is answering in * @returns Whether a placeholder is now showing, or why not */ beginWorkingForAgent(input: { chatChannelId: string; agentSession: string; threadId?: string; }): Promise<{ ok: true; slackChannelId: string; threadTs?: string; } | { ok: false; reason: string; }>; /** * Put a file into the Slack channel an agent is replying in. * * Agents reply through `reply-channel`, which posts a chat-v2 message that * the outbound mirror turns into Slack text. That path carries words and * nothing else, so an agent asked for a PDF had no way to hand one over: * it uploaded to Drive and pasted a link, and when asked why, correctly * reported that the interface it was told to use only sends text. * * This gives it the other half. The agent names the chat channel it * already knows — it has no reason to know Slack channel ids — and we * resolve the Slack channel, the thread its reply belongs in, and its own * bot token, so the file arrives from the same identity as its words * rather than from the workspace app. * * @param input - Chat channel, agent, file and optional caption/thread * @returns What was uploaded, or why it could not be */ attachFileForAgent(input: { chatChannelId: string; agentSession: string; filePath: string; filename?: string; title?: string; comment?: string; threadId?: string; /** * Where the agent's current work answers (the `reply` resolver, * work-item-destination.wiring). Used when no thread is named; null/absent * = no known place → top level, never "the latest thread in the channel". */ destination?: { slackChannelId: string; threadTs?: string; topic?: string; } | null; }): Promise<{ ok: true; slackChannelId: string; threadTs?: string; fileId?: string; asAgentBot: boolean; } | { ok: false; reason: string; }>; /** * The Slack thread an outbound agent message belongs to. * * Only what the message itself names counts: a Slack thread key, or its * chat thread root's `slackThreadTs`. A message with no thread is a * top-level post. There is no "latest Slack thread in the channel" * fallback any more — it put every scheduled report into whatever thread * the owner last started (specs/2026-10-02-harness-owned-routing.md: * "Never: … latest root"). * * @param mapping - Channel mapping * @param dto - The outbound message (thread reference / metadata) * @returns Slack thread ts, undefined for top level, null when the named thread key is another channel's (do not post) */ private resolveOutboundThreadTs; /** * Give every member of a mapped team a real Slack identity: ask Cloud for * one where missing, announce the owner's install links in the team's * channel (once per agent per channel), and invite installed bots into the * channel (once). Silent no-op when identities are unavailable (no Cloud * login) or the owner has not stored a config token yet. * * @param team - The team * @param mapping - Its channel mapping * @returns Counts, for logging and the REST surface */ ensureIdentities(team: Team, mapping: SlackTeamChannelMapping): Promise<{ provisioned: number; announced: number; invited: number; skipped: string | null; }>; /** * Invite a newly installed agent bot into every channel of every team it * belongs to. Runs from the identity service's `onInstalled` hook. * * @param agentSession - The agent that just got its bot user */ inviteInstalledEverywhere(agentSession: string): Promise; /** Invite one bot user into a mapped channel and remember it. */ private inviteBot; /** * Invite a bot with another room member's own bot token (the workspace bot * is not in the room). Tries each member that has a bot until one works. * * @returns True when the bot is now in the channel */ private inviteViaMemberBot; /** Installed bot tokens of an ad-hoc room's members, minus `except`. */ private memberTokens; /** * Take an agent's bot out of a room's Slack channel: the workspace bot * removes it (`conversations.kick`), else the agent's own bot leaves. * * @returns True when the bot is out (or was never in) */ private removeBot; /** * Set an ad-hoc room's members from Crewly. Added agents' bots are invited * into the Slack channel; removed agents' bots are taken out. Removed * agents are remembered (`excluded`) so they are not added back * automatically if Slack refused to take their bot out. * * @param slackChannelId - The room's Slack channel * @param sessions - The complete desired roster * @returns The diff and what Slack did with it * @throws Error when the channel is not an ad-hoc room */ setRoomMembers(slackChannelId: string, sessions: string[]): Promise; /** * Rename an ad-hoc room's Slack channel from Crewly. The name is * normalised the way Slack wants it (lower case, no spaces, at most 80 * characters); the name Slack applied is stored and returned, so Crewly * shows exactly what Slack shows. * * @param slackChannelId - The room's Slack channel * @param name - The name the owner typed * @returns The applied channel name (without `#`) * @throws Error when the room is unknown or Slack refused (name taken, no permission) */ renameRoom(slackChannelId: string, name: string): Promise; /** * Archive an ad-hoc room from Crewly: the mapping and huddle go; the Slack * channel is archived when `archiveSlackChannel`, otherwise the members' * bots leave it (so it is not found again as a room). * * @param slackChannelId - The room's Slack channel * @param options - `archiveSlackChannel` for a channel Crewly created * @returns True when a room existed */ archiveRoom(slackChannelId: string, options?: { archiveSlackChannel?: boolean; }): Promise; /** * Bring names and ad-hoc rosters in from Slack. * * - Every mapped channel (team and ad-hoc): a rename in Slack is shown in * Crewly (mapping name and huddle name). A team channel's `derivedName` * is left alone, so the owner's Slack name is not renamed back on the * next team save. * - Ad-hoc rooms: local agents whose bot is in the Slack channel join the * roster, unless the owner removed them in Crewly. (Leaving is handled by * {@link pruneAdhocMembers}.) * * Reads with the room members' own bots first (the workspace bot is * usually not in a private room), then the workspace bot. Nothing changes * on a read that fails. * * @returns What changed */ syncRoomsFromSlack(): Promise; /** * A room whose name nobody can read (agent bots have no `*:read` scopes; * the workspace bot is not in the private channel) shows in Crewly as its * id, and renames / member changes cannot reach Slack. Ask once, in the * channel, as a member agent's bot, for the Crewly app to be added — after * that the workspace bot reads and manages it. * * @returns True when the ask was posted (the mapping changed) */ private askForCrewlyApp; /** Record a channel's Slack name on its mapping and huddle (`#name`). */ private applyRoomName; private postWelcome; private postUnknownMentionHint; /** Run `fn` after any in-flight operation for the same team finishes. */ private serialised; } /** * A row the owner typed in Crewly (dashboard, phone, Talk) — not a Slack * message recorded here and not an agent writing as a user turn. * * @param dto - A `user` chat row * @returns True when it should be mirrored to the room's Slack channel */ export declare function isOwnerTypedInCrewly(dto: ChatMessageDTO): boolean; /** * Install the process-wide instance (composition root). * * @param service - The configured service */ export declare function setSlackTeamChannelService(service: SlackTeamChannelService | null): void; /** * The process-wide instance, or null before the composition root wires it * (e.g. Slack not configured). * * @returns The service or null */ export declare function getSlackTeamChannelService(): SlackTeamChannelService | null; //# sourceMappingURL=slack-team-channel.service.d.ts.map