crewly 1.20.86 → 1.20.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/config/skills/agent/core/reply-channel/SKILL.md +49 -0
  2. package/config/skills/agent/core/reply-channel/execute.sh +61 -0
  3. package/dist/backend/backend/src/constants.d.ts +2 -0
  4. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  5. package/dist/backend/backend/src/constants.js +2 -0
  6. package/dist/backend/backend/src/constants.js.map +1 -1
  7. package/dist/backend/backend/src/controllers/slack/slack.controller.d.ts.map +1 -1
  8. package/dist/backend/backend/src/controllers/slack/slack.controller.js +85 -0
  9. package/dist/backend/backend/src/controllers/slack/slack.controller.js.map +1 -1
  10. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts +76 -0
  11. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
  12. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +105 -9
  13. package/dist/backend/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
  14. package/dist/backend/backend/src/services/slack/slack-initializer.d.ts.map +1 -1
  15. package/dist/backend/backend/src/services/slack/slack-initializer.js +9 -0
  16. package/dist/backend/backend/src/services/slack/slack-initializer.js.map +1 -1
  17. package/dist/backend/backend/src/services/slack/slack-instance-registry.service.d.ts +32 -0
  18. package/dist/backend/backend/src/services/slack/slack-instance-registry.service.d.ts.map +1 -1
  19. package/dist/backend/backend/src/services/slack/slack-instance-registry.service.js +28 -1
  20. package/dist/backend/backend/src/services/slack/slack-instance-registry.service.js.map +1 -1
  21. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
  22. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js +3 -2
  23. package/dist/backend/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
  24. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +141 -5
  25. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  26. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +388 -95
  27. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  28. package/dist/backend/backend/src/services/slack/slack.service.d.ts.map +1 -1
  29. package/dist/backend/backend/src/services/slack/slack.service.js +16 -1
  30. package/dist/backend/backend/src/services/slack/slack.service.js.map +1 -1
  31. package/dist/backend/backend/src/types/slack.types.d.ts +57 -0
  32. package/dist/backend/backend/src/types/slack.types.d.ts.map +1 -1
  33. package/dist/backend/backend/src/types/slack.types.js.map +1 -1
  34. package/dist/backend/build-info.json +2 -2
  35. package/dist/cli/backend/src/constants.d.ts +2 -0
  36. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  37. package/dist/cli/backend/src/constants.js +2 -0
  38. package/dist/cli/backend/src/constants.js.map +1 -1
  39. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts +76 -0
  40. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.d.ts.map +1 -1
  41. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js +105 -9
  42. package/dist/cli/backend/src/services/chat-v2/chat-v2.dispatcher.service.js.map +1 -1
  43. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.d.ts.map +1 -1
  44. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js +3 -2
  45. package/dist/cli/backend/src/services/slack/slack-orchestrator-bridge.js.map +1 -1
  46. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +141 -5
  47. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  48. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +388 -95
  49. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  50. package/dist/cli/backend/src/services/slack/slack.service.d.ts.map +1 -1
  51. package/dist/cli/backend/src/services/slack/slack.service.js +16 -1
  52. package/dist/cli/backend/src/services/slack/slack.service.js.map +1 -1
  53. package/dist/cli/backend/src/types/slack.types.d.ts +57 -0
  54. package/dist/cli/backend/src/types/slack.types.d.ts.map +1 -1
  55. package/dist/cli/backend/src/types/slack.types.js.map +1 -1
  56. package/package.json +1 -1
@@ -215,6 +215,8 @@ export class SlackTeamChannelService {
215
215
  unsubscribeIdentity = null;
216
216
  /** Slack `channel:ts` of messages already routed → the persisted turn. */
217
217
  seenInbound = new Map();
218
+ /** The last room presence Cloud sent per Slack channel — names for a hand-off. */
219
+ lastRooms = new Map();
218
220
  onChatMessage = (dto) => {
219
221
  void this.mirrorOutbound(dto);
220
222
  };
@@ -792,12 +794,37 @@ export class SlackTeamChannelService {
792
794
  mapping = await this.ensureAdhocChannel(message);
793
795
  if (!mapping)
794
796
  return null;
797
+ // Slack delivers a channel message to every app in the channel, so a copy
798
+ // that arrived through one of our agents' own apps proves that agent is
799
+ // in the room — whether or not anyone has ever @'d it there. An ad-hoc
800
+ // roster used to be "agents that were @'d here", which left an agent the
801
+ // owner had added to a private channel out of it until someone happened
802
+ // to address it by name.
803
+ const receiving = await this.localReceivingAgent(message);
804
+ if (receiving && isAdhocMapping(mapping) && !(mapping.members ?? []).includes(receiving)) {
805
+ mapping.members = [...(mapping.members ?? []), receiving];
806
+ this.deps.chat.setHuddleMembers(mapping.chatChannelId, mapping.members);
807
+ await this.save();
808
+ this.deps.onRoomsChanged?.();
809
+ }
810
+ if (message.room) {
811
+ this.lastRooms.set(message.channelId, message.room);
812
+ if (this.lastRooms.size > SLACK_TEAM_CHANNEL_CONSTANTS.SEEN_INBOUND_MAX) {
813
+ const oldest = this.lastRooms.keys().next().value;
814
+ if (oldest !== undefined)
815
+ this.lastRooms.delete(oldest);
816
+ }
817
+ }
818
+ // A hand-off: the room's router picked one of our agents to answer a
819
+ // message this machine already has. Deliver it again, addressed to that
820
+ // agent, instead of dropping it as a repeat.
821
+ const handoffTo = message.handoffTo && (this.deps.isLocalAgent?.(message.handoffTo) ?? true) ? message.handoffTo : null;
795
822
  // One Slack message can reach us twice with different event types
796
823
  // (`app_mention` for an @'d agent's app plus `message.channels`); the
797
824
  // team must see it once — a second copy is acknowledged, not dispatched.
798
825
  const seenKey = `${message.channelId}:${message.ts}`;
799
826
  const seen = this.seenInbound.get(seenKey);
800
- if (seen) {
827
+ if (seen && !handoffTo) {
801
828
  this.logger.debug('Repeated copy of a team channel message — not dispatched again', { key: seenKey });
802
829
  return { mapping, message: seen, mentions: [], dispatch: null, duplicate: true };
803
830
  }
@@ -823,6 +850,8 @@ export class SlackTeamChannelService {
823
850
  botUserId: this.deps.identities?.get(m.sessionName)?.botUserId,
824
851
  }));
825
852
  const resolved = resolveSlackMentions(message.text ?? '', candidates);
853
+ if (handoffTo && !resolved.mentions.includes(handoffTo))
854
+ resolved.mentions.push(handoffTo);
826
855
  // Thread correlation.
827
856
  const slackThreadTs = message.threadTs || message.ts;
828
857
  let threadId;
@@ -858,80 +887,30 @@ export class SlackTeamChannelService {
858
887
  ...(remoteAgent ? { remoteAgentSession: remoteAgent } : {}),
859
888
  },
860
889
  };
861
- const persisted = localAuthor
862
- ? {
863
- id: `slack-echo-${message.channelId}-${message.ts}`,
864
- channelId: mapping.chatChannelId,
865
- seq: 0,
866
- senderType: 'user',
867
- senderId,
868
- content: turn.content,
869
- contentType: 'markdown',
870
- createdAt: Date.now(),
871
- attachments: [],
872
- mentions: resolved.mentions,
873
- threadId,
874
- metadata: turn.metadata,
875
- }
876
- : this.deps.chat.recordTurn(turn).message;
890
+ const persisted = handoffTo && seen
891
+ ? { ...seen, mentions: [...new Set([...(seen.mentions ?? []), handoffTo])] }
892
+ : localAuthor
893
+ ? {
894
+ id: `slack-echo-${message.channelId}-${message.ts}`,
895
+ channelId: mapping.chatChannelId,
896
+ seq: 0,
897
+ senderType: 'user',
898
+ senderId,
899
+ content: turn.content,
900
+ contentType: 'markdown',
901
+ createdAt: Date.now(),
902
+ attachments: [],
903
+ mentions: resolved.mentions,
904
+ threadId,
905
+ metadata: turn.metadata,
906
+ }
907
+ : this.deps.chat.recordTurn(turn).message;
877
908
  this.seenInbound.set(seenKey, persisted);
878
909
  if (this.seenInbound.size > SLACK_TEAM_CHANNEL_CONSTANTS.SEEN_INBOUND_MAX) {
879
910
  const oldest = this.seenInbound.keys().next().value;
880
911
  if (oldest !== undefined)
881
912
  this.seenInbound.delete(oldest);
882
913
  }
883
- // In an ad-hoc (often private) channel the master bot may not be a
884
- // member, so an agent's own bot reacts instead.
885
- //
886
- // Which agent's, though, has to be found out rather than assumed. This
887
- // used to take the first installed bot among the @'d agents and the
888
- // huddle roster, on the reasoning that a huddle member's bot is "by
889
- // definition in the channel". It is not: the roster grows as agents are
890
- // @'d and never shrinks, and a bot can be removed from a private channel
891
- // afterwards. Pick one that is not in the channel and Slack answers
892
- // `channel_not_found` — the message is routed, dispatched and answered,
893
- // and the owner sees no eyes at all (#daily-info, 2026-09-22).
894
- //
895
- // So try them in turn, @'d agents first since one of them is about to
896
- // reply, and fall back to the master bot last.
897
- const reactorTokens = isAdhocMapping(mapping)
898
- ? [
899
- ...new Set([...resolved.mentions, ...(mapping.members ?? [])]
900
- .map((m) => this.deps.identities?.getInstalled(m)?.botToken)
901
- .filter((t) => !!t)),
902
- undefined,
903
- ]
904
- : [undefined];
905
- // Swallowing this outright cost an evening: the owner saw no eyes and no
906
- // placeholder and reasonably concluded nothing had arrived, while the
907
- // message was in fact routed, dispatched and answered (2026-09-21).
908
- // Cosmetic, so still non-fatal — but never again silent.
909
- let reacted = false;
910
- let lastError = '';
911
- for (const token of reactorTokens) {
912
- try {
913
- await this.deps.slack.addReaction(message.channelId, message.ts, SLACK_TEAM_CHANNEL_CONSTANTS.INBOUND_REACTION, token);
914
- reacted = true;
915
- break;
916
- }
917
- catch (err) {
918
- const code = describeSlackError(err).code;
919
- lastError = code;
920
- // Only a not-a-member failure is worth trying another identity for.
921
- // Anything else (already reacted, rate limit, bad message ts) will
922
- // fail identically for every bot.
923
- if (code !== 'channel_not_found')
924
- break;
925
- }
926
- }
927
- if (!reacted) {
928
- this.logger.warn('Could not acknowledge the message with a reaction', {
929
- slackChannel: mapping.slackChannelName ?? message.channelId,
930
- adhoc: isAdhocMapping(mapping),
931
- identitiesTried: reactorTokens.length,
932
- error: lastError,
933
- });
934
- }
935
914
  // Ad-hoc channels grow their huddle as new agents get @'d there.
936
915
  if (isAdhocMapping(mapping) && resolved.mentions.length > 0) {
937
916
  const next = [...new Set([...(mapping.members ?? []), ...resolved.mentions])];
@@ -939,24 +918,50 @@ export class SlackTeamChannelService {
939
918
  mapping.members = next;
940
919
  this.deps.chat.setHuddleMembers(mapping.chatChannelId, next);
941
920
  await this.save();
921
+ this.deps.onRoomsChanged?.();
942
922
  }
943
923
  }
944
- // @'d agents must reply: show the honest state in the thread for each
945
- // one that has its own bot — "waking up…" for an idle agent (a cold
946
- // start is 1–2 minutes), "is working on it…" once it holds the message.
947
- // Who will be asked to reply: the @'d members, or (nobody @'d, top-level
948
- // message) the team leader alone. Mirrors the dispatcher's targeting so
949
- // the placeholder matches who actually gets the message.
950
- const typingTargets = [];
951
- if (this.deps.typing) {
952
- let sessions = resolved.mentions;
953
- if (sessions.length === 0 && !message.threadTs && team) {
924
+ // Who will receive this, and who owes a reply — decided by the same rules
925
+ // delivery uses, but *before* delivery, which can take a minute or two
926
+ // when an agent has to be cold-started. The owner should not look at an
927
+ // unacknowledged message for that long.
928
+ const dispatcherForPlan = this.deps.getDispatcher();
929
+ const presence = await this.roomStateFor(message, mapping, team ? teamChannelMembers(team) : null);
930
+ const dispatchOptions = {
931
+ threadId: threadId ?? persisted.id,
932
+ replyVia: 'reply-channel',
933
+ // A local agent's own message (fanned out to the colleagues it @'d)
934
+ // must not come back to its author.
935
+ ...(remoteAgent ? { excludeSessions: [remoteAgent] } : {}),
936
+ ...(presence ? { room: presence.state, ...(presence.line ? { roomPresence: presence.line } : {}) } : {}),
937
+ };
938
+ const planned = dispatcherForPlan?.planHuddleTargets
939
+ ? await dispatcherForPlan.planHuddleTargets(channel, persisted, dispatchOptions).catch(() => null)
940
+ : null;
941
+ await this.acknowledgeSeen(message, mapping, resolved.mentions, planned);
942
+ // Agents that must reply get a placeholder straight away — "waking up…"
943
+ // for an idle agent (a cold start is 1–2 minutes), "is working on it…"
944
+ // once it holds the message. Agents that were only told, and may or may
945
+ // not decide to answer, get none: a placeholder is a promise of a reply,
946
+ // and they announce their own with `reply-channel --working` if they
947
+ // take it on. With a plan this covers the case the old heuristic missed —
948
+ // a bare follow-up in a thread, which the last speaker must answer.
949
+ let owing;
950
+ if (planned) {
951
+ owing = [...planned].filter(([, mode]) => mode === 'required').map(([session]) => session);
952
+ }
953
+ else {
954
+ owing = resolved.mentions;
955
+ if (owing.length === 0 && !message.threadTs && team) {
954
956
  // Same rule as the dispatcher's huddleLeaderFor: the team leader, else the first member.
955
957
  const leader = members.find((m) => String(m.role) === 'team-leader' || String(m.role) === 'tech-lead') ?? members[0];
956
958
  if (leader)
957
- sessions = [leader.sessionName];
959
+ owing = [leader.sessionName];
958
960
  }
959
- for (const session of sessions) {
961
+ }
962
+ const typingTargets = [];
963
+ if (this.deps.typing) {
964
+ for (const session of owing) {
960
965
  const member = members.find((m) => m.sessionName === session);
961
966
  const installed = this.deps.identities?.getInstalled(session);
962
967
  // Own bot when installed; otherwise the master bot wearing the agent's
@@ -984,12 +989,8 @@ export class SlackTeamChannelService {
984
989
  .join(' · ');
985
990
  }
986
991
  dispatch = await dispatcher.dispatchMessage(channel, persisted, {
987
- threadId: threadId ?? persisted.id,
988
- replyVia: 'reply-channel',
992
+ ...dispatchOptions,
989
993
  ...(roster ? { channelRoster: roster } : {}),
990
- // A local agent's own message (fanned out to the colleagues it @'d)
991
- // must not come back to its author.
992
- ...(remoteAgent ? { excludeSessions: [remoteAgent] } : {}),
993
994
  });
994
995
  }
995
996
  else {
@@ -1022,19 +1023,258 @@ export class SlackTeamChannelService {
1022
1023
  });
1023
1024
  return { mapping, message: persisted, mentions: resolved.mentions, dispatch };
1024
1025
  }
1026
+ /**
1027
+ * Who in this room is awake, from this machine's point of view.
1028
+ *
1029
+ * Local members are judged by what is running here now; members on other
1030
+ * machines by what Cloud last heard from them. Cloud names the one machine
1031
+ * that wakes someone when nobody is awake. One case it cannot see: it may
1032
+ * believe an agent of ours is awake when that agent has just stopped.
1033
+ * Then nobody would be woken anywhere — every other machine thinks we have
1034
+ * it — so this machine, the only one that knows better, wakes the router.
1035
+ *
1036
+ * @param message - Inbound message (carries Cloud's presence, when any)
1037
+ * @param mapping - Its channel mapping
1038
+ * @param teamMembers - Team channel members, or null for an ad-hoc room
1039
+ * @returns State for the dispatcher and a line for the prompt; null without an awake check
1040
+ */
1041
+ async roomStateFor(message, mapping, teamMembers) {
1042
+ const isAwake = this.deps.isAgentAwake;
1043
+ if (!isAwake)
1044
+ return null;
1045
+ const localMembers = teamMembers ? teamMembers.map((m) => m.sessionName) : (mapping.members ?? []);
1046
+ const awakeHere = localMembers.filter((m) => isAwake(m));
1047
+ const room = message.room;
1048
+ if (!room)
1049
+ return { state: { awakeHere, awakeElsewhere: false } };
1050
+ const me = this.deps.resolveInstanceId ? await this.deps.resolveInstanceId().catch(() => null) : null;
1051
+ const isHere = (m) => me ? m.instanceId === me : (this.deps.isLocalAgent?.(localAgentSession(m.agentSession)) ?? false);
1052
+ const awakeElsewhere = room.members.some((m) => !isHere(m) && m.awake);
1053
+ let wakeWhenAllAsleep = null;
1054
+ if (room.fallback) {
1055
+ const here = me ? room.fallback.instanceId === me : (this.deps.isLocalAgent?.(localAgentSession(room.fallback.agentSession)) ?? false);
1056
+ if (here)
1057
+ wakeWhenAllAsleep = { agentSession: localAgentSession(room.fallback.agentSession), kind: room.fallback.kind };
1058
+ }
1059
+ else if (!awakeElsewhere && awakeHere.length === 0 && room.members.some((m) => isHere(m) && m.awake)) {
1060
+ const leader = teamMembers
1061
+ ? (teamMembers.find((m) => String(m.role) === 'team-leader' || String(m.role) === 'tech-lead') ?? teamMembers[0])
1062
+ : undefined;
1063
+ wakeWhenAllAsleep = leader
1064
+ ? { agentSession: leader.sessionName, kind: 'team-leader' }
1065
+ : { agentSession: CREWLY_CONSTANTS.SESSIONS.ORCHESTRATOR_NAME, kind: 'orchestrator' };
1066
+ }
1067
+ const line = room.members
1068
+ .map((m) => {
1069
+ const here = isHere(m);
1070
+ const awake = here ? awakeHere.includes(localAgentSession(m.agentSession)) : m.awake;
1071
+ return `${m.displayName}(${awake ? '醒着' : '在睡'},${here ? '本机' : m.deviceName})`;
1072
+ })
1073
+ .join(' · ');
1074
+ return { state: { awakeHere, awakeElsewhere, wakeWhenAllAsleep }, line };
1075
+ }
1076
+ /**
1077
+ * Hand a room message to one agent, wherever it runs.
1078
+ *
1079
+ * Used by the room's router — the orchestrator of a private room, woken
1080
+ * because nobody in it was awake. Its own bot is usually not in the room,
1081
+ * so it cannot @ the agent there the way a member would. An agent on this
1082
+ * machine gets the message delivered again as if @'d (👀 and a "waking
1083
+ * up…" placeholder from its own bot); one on another machine gets it
1084
+ * through Cloud, which does the same over there.
1085
+ *
1086
+ * @param input - Chat channel, the message to pass on, and who should answer it
1087
+ * @returns Who it went to, or why it could not
1088
+ */
1089
+ async handoffForAgent(input) {
1090
+ await this.load();
1091
+ const mapping = (this.store?.mappings ?? []).find((m) => m.chatChannelId === input.chatChannelId);
1092
+ if (!mapping)
1093
+ return { ok: false, reason: 'not_a_slack_channel' };
1094
+ const source = input.messageId ?? input.threadId;
1095
+ const original = source ? this.deps.chat.getMessageForBridge(source) : null;
1096
+ const meta = (original?.metadata ?? {});
1097
+ const slackTs = typeof meta['slackTs'] === 'string' ? meta['slackTs'] : '';
1098
+ if (!original || meta['source'] !== 'slack' || !slackTs)
1099
+ return { ok: false, reason: 'not_a_slack_message' };
1100
+ const threadTs = typeof meta['slackThreadTs'] === 'string' && meta['slackThreadTs'] !== slackTs ? meta['slackThreadTs'] : undefined;
1101
+ const userId = typeof meta['slackUserId'] === 'string' ? meta['slackUserId'] : '';
1102
+ // Names: everyone Cloud last said is in the room, plus this machine's agents.
1103
+ const wanted = input.name.trim().replace(/^@/, '').toLowerCase();
1104
+ const candidates = [];
1105
+ for (const m of this.lastRooms.get(mapping.slackChannelId)?.members ?? []) {
1106
+ candidates.push({ session: m.agentSession, name: m.displayName });
1107
+ }
1108
+ const localSessions = new Set();
1109
+ for (const team of await this.deps.storage.getTeams()) {
1110
+ for (const m of teamChannelMembers(team)) {
1111
+ candidates.push({ session: m.sessionName, name: m.name });
1112
+ localSessions.add(m.sessionName);
1113
+ }
1114
+ }
1115
+ const hit = candidates.find((c) => c.name.toLowerCase() === wanted || c.session.toLowerCase() === wanted);
1116
+ if (!hit) {
1117
+ return { ok: false, reason: 'unknown_agent', candidates: [...new Set(candidates.map((c) => c.name))].slice(0, 20) };
1118
+ }
1119
+ const local = this.deps.isLocalAgent?.(localAgentSession(hit.session)) ?? localSessions.has(hit.session);
1120
+ if (local) {
1121
+ await this.routeInbound({
1122
+ id: slackTs,
1123
+ type: 'message',
1124
+ text: original.content,
1125
+ userId,
1126
+ channelId: mapping.slackChannelId,
1127
+ ...(threadTs ? { threadTs } : {}),
1128
+ ts: slackTs,
1129
+ teamId: '',
1130
+ eventTs: slackTs,
1131
+ source: 'cloud',
1132
+ handoffTo: localAgentSession(hit.session),
1133
+ });
1134
+ this.logger.info('Room message handed to a local agent', { agentSession: hit.session, slackChannel: mapping.slackChannelName });
1135
+ return { ok: true, agentSession: localAgentSession(hit.session), displayName: hit.name, via: 'here' };
1136
+ }
1137
+ if (!this.deps.handoffViaCloud)
1138
+ return { ok: false, reason: 'cloud_unavailable' };
1139
+ await this.deps.handoffViaCloud({
1140
+ agentSession: hit.session,
1141
+ event: {
1142
+ channel: mapping.slackChannelId,
1143
+ ts: slackTs,
1144
+ ...(threadTs ? { thread_ts: threadTs } : {}),
1145
+ text: original.content,
1146
+ ...(userId ? { user: userId } : {}),
1147
+ },
1148
+ });
1149
+ this.logger.info('Room message handed to an agent on another machine', { agentSession: hit.session, slackChannel: mapping.slackChannelName });
1150
+ return { ok: true, agentSession: hit.session, displayName: hit.name, via: 'cloud' };
1151
+ }
1152
+ /**
1153
+ * The ad-hoc rooms this machine's agents are in, for the Cloud heartbeat —
1154
+ * Cloud builds each room's cross-machine roster from these.
1155
+ *
1156
+ * @returns Slack channel → local member sessions
1157
+ */
1158
+ async listRooms() {
1159
+ await this.load();
1160
+ return (this.store?.mappings ?? [])
1161
+ .filter((m) => isAdhocMapping(m) && (m.members ?? []).length > 0)
1162
+ .map((m) => ({ channelId: m.slackChannelId, agents: [...(m.members ?? [])] }));
1163
+ }
1164
+ /**
1165
+ * The local agent whose own app delivered this copy, if any.
1166
+ *
1167
+ * A direct message is never a room: an agent's DM is handled by the DM
1168
+ * service, and anything that falls through from there belongs to the
1169
+ * orchestrator path, not to an ad-hoc huddle.
1170
+ *
1171
+ * @param message - The inbound message
1172
+ * @returns The agent's session name, or null
1173
+ */
1174
+ async localReceivingAgent(message) {
1175
+ const session = message.receivedVia ?? message.agentSession;
1176
+ if (!session || message.channelId.startsWith('D'))
1177
+ return null;
1178
+ if (this.deps.isLocalAgent)
1179
+ return this.deps.isLocalAgent(session) ? session : null;
1180
+ const teams = await this.deps.storage.getTeams();
1181
+ return teams.some((t) => teamChannelMembers(t).some((m) => m.sessionName === session)) ? session : null;
1182
+ }
1183
+ /**
1184
+ * Put 👀 on the message — one per agent that will receive it.
1185
+ *
1186
+ * It used to be a single reaction meaning "Crewly got this". The owner
1187
+ * reads it as "how many agents saw this", and with several agents in a
1188
+ * room that is the more useful answer: three agents who will each weigh a
1189
+ * message should show three eyes, and an agent that was not handed the
1190
+ * message should show none, so the count is honest rather than "everyone
1191
+ * in the channel". Each agent reacts with its own bot.
1192
+ *
1193
+ * When nobody in particular receives it, or no receiving agent has a bot
1194
+ * of its own, one reaction still goes on so the owner can see the message
1195
+ * arrived — the 2026-09-21 lesson: no eyes reads as nothing arrived.
1196
+ *
1197
+ * @param message - The inbound message
1198
+ * @param mapping - Its channel mapping
1199
+ * @param mentions - Agents @'d in it
1200
+ * @param planned - Who will receive it, from the dispatcher; null when unknown
1201
+ */
1202
+ async acknowledgeSeen(message, mapping, mentions, planned) {
1203
+ const react = (token) => this.deps.slack.addReaction(message.channelId, message.ts, SLACK_TEAM_CHANNEL_CONSTANTS.INBOUND_REACTION, token);
1204
+ let seen = 0;
1205
+ const absent = [];
1206
+ for (const session of planned?.keys() ?? []) {
1207
+ const token = this.deps.identities?.getInstalled(session)?.botToken;
1208
+ if (!token)
1209
+ continue;
1210
+ try {
1211
+ await react(token);
1212
+ seen += 1;
1213
+ }
1214
+ catch (err) {
1215
+ const code = describeSlackError(err).code;
1216
+ if (code === 'channel_not_found' || code === 'not_in_channel')
1217
+ absent.push(session);
1218
+ }
1219
+ }
1220
+ if (absent.length > 0) {
1221
+ // The agent still gets the message; its bot is just not in the room
1222
+ // (removed from a private channel after it joined the roster, say).
1223
+ this.logger.info('Agent bot not in the channel — no eyes from it', {
1224
+ slackChannel: mapping.slackChannelName ?? message.channelId,
1225
+ agents: absent,
1226
+ });
1227
+ }
1228
+ if (seen > 0)
1229
+ return;
1230
+ // Nobody in particular, or nobody with a bot of their own. In an ad-hoc
1231
+ // (often private) channel the master bot may not be a member, so try the
1232
+ // agents' bots — @'d ones first — and the master bot last. Only a
1233
+ // not-a-member failure is worth another identity: already-reacted, rate
1234
+ // limits and a bad ts fail identically for every bot.
1235
+ const fallback = isAdhocMapping(mapping)
1236
+ ? [
1237
+ ...new Set([...mentions, ...(mapping.members ?? [])]
1238
+ .map((m) => this.deps.identities?.getInstalled(m)?.botToken)
1239
+ .filter((t) => !!t)),
1240
+ undefined,
1241
+ ]
1242
+ : [undefined];
1243
+ let lastError = '';
1244
+ for (const token of fallback) {
1245
+ try {
1246
+ await react(token);
1247
+ return;
1248
+ }
1249
+ catch (err) {
1250
+ lastError = describeSlackError(err).code;
1251
+ if (lastError !== 'channel_not_found')
1252
+ break;
1253
+ }
1254
+ }
1255
+ // Swallowing this outright cost an evening (2026-09-21): cosmetic, so
1256
+ // non-fatal, but never silent.
1257
+ this.logger.warn('Could not acknowledge the message with a reaction', {
1258
+ slackChannel: mapping.slackChannelName ?? message.channelId,
1259
+ adhoc: isAdhocMapping(mapping),
1260
+ identitiesTried: fallback.length,
1261
+ error: lastError,
1262
+ });
1263
+ }
1025
1264
  /**
1026
1265
  * Any Slack channel becomes routable the moment a local agent's bot is
1027
- * @'d in it (the owner invited agents from different teams into a private
1028
- * channel, say): a huddle is created for the channel with the @'d agents
1029
- * as members and mapped under the synthetic team id `adhoc:<channel>`.
1030
- * Messages that @ nobody local are left to the caller (orchestrator path).
1266
+ * @'d in it, or a message reaches us through a local agent's own app
1267
+ * (Slack only delivers to apps that are members, so that proves the bot
1268
+ * is in the channel). The owner invited agents from different teams into
1269
+ * a private channel, say: a huddle is created for the channel with those
1270
+ * agents as members and mapped under the synthetic team id
1271
+ * `adhoc:<channel>`. Anything else is left to the caller (orchestrator
1272
+ * path).
1031
1273
  *
1032
1274
  * @param message - The inbound message
1033
1275
  * @returns The new mapping, or null when no local agent was @'d
1034
1276
  */
1035
1277
  async ensureAdhocChannel(message) {
1036
- if (!message.text || !message.text.includes('<@') && !message.text.includes('@'))
1037
- return null;
1038
1278
  const teams = await this.deps.storage.getTeams();
1039
1279
  const members = teams.flatMap((t) => teamChannelMembers(t));
1040
1280
  if (members.length === 0)
@@ -1049,10 +1289,17 @@ export class SlackTeamChannelService {
1049
1289
  // Only a real Slack mention of an agent's bot user counts here — a bare
1050
1290
  // "@name" in some unrelated channel must not hijack it.
1051
1291
  const botIds = new Set(candidates.map((c) => c.botUserId).filter((id) => !!id));
1052
- const mentionedBots = [...message.text.matchAll(/<@([A-Z0-9]+)>/g)].map((m) => m[1]).filter((id) => botIds.has(id));
1053
- if (mentionedBots.length === 0)
1054
- return null;
1292
+ const text = message.text ?? '';
1293
+ const mentionedBots = [...text.matchAll(/<@([A-Z0-9]+)>/g)].map((m) => m[1]).filter((id) => botIds.has(id));
1055
1294
  const sessions = candidates.filter((c) => c.botUserId && mentionedBots.includes(c.botUserId)).map((c) => c.sessionName);
1295
+ // The copy came through a local agent's own app: that agent's bot is in
1296
+ // this channel, which makes the channel a room it belongs to even if
1297
+ // nobody has addressed it yet.
1298
+ const receiving = await this.localReceivingAgent(message);
1299
+ if (receiving && !sessions.includes(receiving))
1300
+ sessions.push(receiving);
1301
+ if (sessions.length === 0)
1302
+ return null;
1056
1303
  // The master bot is usually not in this channel (private); the name is
1057
1304
  // best-effort and falls back to the id.
1058
1305
  const info = await this.deps.slack.getChannelInfo(message.channelId).catch(() => null);
@@ -1075,6 +1322,7 @@ export class SlackTeamChannelService {
1075
1322
  const store = await this.load();
1076
1323
  store.mappings.push(mapping);
1077
1324
  await this.save();
1325
+ this.deps.onRoomsChanged?.();
1078
1326
  this.logger.info('Ad-hoc Slack channel linked (agents @\'d outside a team channel)', {
1079
1327
  slackChannel: `#${channelName}`,
1080
1328
  huddle: huddle.id,
@@ -1190,6 +1438,51 @@ export class SlackTeamChannelService {
1190
1438
  * `slackThreadTs`; failing that, the latest Slack-origin root in the
1191
1439
  * huddle; failing that, the channel top level.
1192
1440
  */
1441
+ /**
1442
+ * Show "<agent> is working on it…" in the thread, at the agent's request.
1443
+ *
1444
+ * Agents that must answer get this placeholder the moment the message
1445
+ * arrives. Agents that were only *told* — a message nobody addressed to
1446
+ * them, passed along so they can judge whether it concerns them — get none,
1447
+ * because a placeholder promises a reply and most of them will not send
1448
+ * one. When one of them does decide to answer, it calls this, so the owner
1449
+ * sees who has taken the message on: two agents deciding to answer show two
1450
+ * placeholders, which is exactly the signal the owner asked for.
1451
+ *
1452
+ * The key matches the one the reply is mirrored under, so the agent's
1453
+ * actual answer replaces the placeholder rather than landing beside it.
1454
+ *
1455
+ * @param input - Chat channel and thread the agent is answering in
1456
+ * @returns Whether a placeholder is now showing, or why not
1457
+ */
1458
+ async beginWorkingForAgent(input) {
1459
+ const mapping = (this.store?.mappings ?? []).find((m) => m.chatChannelId === input.chatChannelId);
1460
+ if (!mapping)
1461
+ return { ok: false, reason: 'not_a_slack_channel' };
1462
+ if (!this.deps.typing)
1463
+ return { ok: false, reason: 'placeholders_unavailable' };
1464
+ if (!this.deps.slack.isConnected())
1465
+ return { ok: false, reason: 'slack_not_connected' };
1466
+ const threadTs = this.resolveOutboundThreadTs(mapping, {
1467
+ channelId: input.chatChannelId,
1468
+ senderId: input.agentSession,
1469
+ ...(input.threadId ? { threadId: input.threadId } : {}),
1470
+ });
1471
+ const team = (await this.deps.storage.getTeams()).find((t) => (t.members ?? []).some((m) => m.sessionName === input.agentSession));
1472
+ const member = team?.members.find((m) => m.sessionName === input.agentSession);
1473
+ const installed = this.deps.identities?.getInstalled(input.agentSession) ?? null;
1474
+ const displayName = member?.name ?? input.agentSession;
1475
+ const identity = installed
1476
+ ? { botToken: installed.botToken, displayName }
1477
+ : { displayName, ...slackIdentityFor(member, input.agentSession) };
1478
+ await this.deps.typing.begin({ agentSession: input.agentSession, slackChannelId: mapping.slackChannelId, ...(threadTs ? { threadTs } : {}) }, identity, 'typing');
1479
+ this.logger.info('Agent took a message on', {
1480
+ agentSession: input.agentSession,
1481
+ slackChannel: mapping.slackChannelName,
1482
+ threaded: Boolean(threadTs),
1483
+ });
1484
+ return { ok: true, slackChannelId: mapping.slackChannelId, ...(threadTs ? { threadTs } : {}) };
1485
+ }
1193
1486
  /**
1194
1487
  * Put a file into the Slack channel an agent is replying in.
1195
1488
  *