crewly 1.20.248 → 1.20.249

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 (80) hide show
  1. package/config/skills/agent/core/list-channels/SKILL.md +61 -0
  2. package/config/skills/agent/core/list-channels/execute.sh +36 -0
  3. package/config/skills/agent/core/list-channels/execute.test.sh +17 -0
  4. package/config/skills/agent/core/reply-channel/SKILL.md +10 -0
  5. package/config/skills/agent/core/reply-channel/execute.sh +23 -1
  6. package/config/skills/agent/core/reply-channel/execute.test.sh +25 -0
  7. package/dist/backend/backend/src/constants.d.ts +25 -0
  8. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  9. package/dist/backend/backend/src/constants.js +25 -0
  10. package/dist/backend/backend/src/constants.js.map +1 -1
  11. package/dist/backend/backend/src/controllers/channels/channels.routes.d.ts +27 -0
  12. package/dist/backend/backend/src/controllers/channels/channels.routes.d.ts.map +1 -0
  13. package/dist/backend/backend/src/controllers/channels/channels.routes.js +99 -0
  14. package/dist/backend/backend/src/controllers/channels/channels.routes.js.map +1 -0
  15. package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
  16. package/dist/backend/backend/src/routes/api.routes.js +18 -0
  17. package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
  18. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
  19. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -0
  20. package/dist/backend/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  21. package/dist/backend/backend/src/services/channels/crewly-channel.service.d.ts +247 -0
  22. package/dist/backend/backend/src/services/channels/crewly-channel.service.d.ts.map +1 -0
  23. package/dist/backend/backend/src/services/channels/crewly-channel.service.js +631 -0
  24. package/dist/backend/backend/src/services/channels/crewly-channel.service.js.map +1 -0
  25. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts +26 -0
  26. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
  27. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js +40 -2
  28. package/dist/backend/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
  29. package/dist/backend/backend/src/services/slack/slack-channel-members.d.ts +60 -0
  30. package/dist/backend/backend/src/services/slack/slack-channel-members.d.ts.map +1 -1
  31. package/dist/backend/backend/src/services/slack/slack-channel-members.js +70 -0
  32. package/dist/backend/backend/src/services/slack/slack-channel-members.js.map +1 -1
  33. package/dist/backend/backend/src/services/slack/slack-initializer.d.ts.map +1 -1
  34. package/dist/backend/backend/src/services/slack/slack-initializer.js +2 -1
  35. package/dist/backend/backend/src/services/slack/slack-initializer.js.map +1 -1
  36. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +147 -1
  37. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  38. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +423 -12
  39. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  40. package/dist/backend/backend/src/services/slack/slack.service.d.ts +10 -0
  41. package/dist/backend/backend/src/services/slack/slack.service.d.ts.map +1 -1
  42. package/dist/backend/backend/src/services/slack/slack.service.js +15 -0
  43. package/dist/backend/backend/src/services/slack/slack.service.js.map +1 -1
  44. package/dist/backend/backend/src/types/crewly-channel.types.d.ts +77 -0
  45. package/dist/backend/backend/src/types/crewly-channel.types.d.ts.map +1 -0
  46. package/dist/backend/backend/src/types/crewly-channel.types.js +35 -0
  47. package/dist/backend/backend/src/types/crewly-channel.types.js.map +1 -0
  48. package/dist/backend/backend/src/types/slack.types.d.ts +7 -0
  49. package/dist/backend/backend/src/types/slack.types.d.ts.map +1 -1
  50. package/dist/backend/backend/src/types/slack.types.js.map +1 -1
  51. package/dist/backend/build-info.json +2 -2
  52. package/dist/cli/backend/src/constants.d.ts +25 -0
  53. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  54. package/dist/cli/backend/src/constants.js +25 -0
  55. package/dist/cli/backend/src/constants.js.map +1 -1
  56. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.d.ts.map +1 -1
  57. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js +1 -0
  58. package/dist/cli/backend/src/services/ai/prompt-modules/skills-reference.module.js.map +1 -1
  59. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts +26 -0
  60. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.d.ts.map +1 -1
  61. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js +40 -2
  62. package/dist/cli/backend/src/services/chat-v2/chat-v2.service.js.map +1 -1
  63. package/dist/cli/backend/src/services/slack/slack-channel-members.d.ts +87 -0
  64. package/dist/cli/backend/src/services/slack/slack-channel-members.d.ts.map +1 -0
  65. package/dist/cli/backend/src/services/slack/slack-channel-members.js +124 -0
  66. package/dist/cli/backend/src/services/slack/slack-channel-members.js.map +1 -0
  67. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +147 -1
  68. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  69. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +423 -12
  70. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  71. package/dist/cli/backend/src/services/slack/slack.service.d.ts +10 -0
  72. package/dist/cli/backend/src/services/slack/slack.service.d.ts.map +1 -1
  73. package/dist/cli/backend/src/services/slack/slack.service.js +15 -0
  74. package/dist/cli/backend/src/services/slack/slack.service.js.map +1 -1
  75. package/dist/cli/backend/src/types/slack.types.d.ts +7 -0
  76. package/dist/cli/backend/src/types/slack.types.d.ts.map +1 -1
  77. package/dist/cli/backend/src/types/slack.types.js.map +1 -1
  78. package/frontend/dist/assets/{index-8f4d482e.js → index-2db17f1e.js} +291 -291
  79. package/frontend/dist/index.html +1 -1
  80. package/package.json +1 -1
@@ -373,6 +373,8 @@ export class SlackTeamChannelService {
373
373
  humanNames = new Map();
374
374
  /** Whether the owner's names were looked up yet. */
375
375
  ownerNamesLoaded = false;
376
+ /** The owner's display name for posts made on their behalf. */
377
+ ownerName = null;
376
378
  /** channelId → members, with fetch time; decides between same-named agents. */
377
379
  channelMembers = new Map();
378
380
  /**
@@ -440,7 +442,10 @@ export class SlackTeamChannelService {
440
442
  // in the room (2026-10-03 / 2026-10-04, #content-team). Check against
441
443
  // Slack now and periodically.
442
444
  if (this.deps.listChannelMembers) {
443
- const run = () => void this.pruneAdhocMembers().catch((err) => this.logger.warn('Ad-hoc room prune failed (non-fatal)', { error: err instanceof Error ? err.message : String(err) }));
445
+ const run = () => void this.pruneAdhocMembers()
446
+ .catch((err) => this.logger.warn('Ad-hoc room prune failed (non-fatal)', { error: err instanceof Error ? err.message : String(err) }))
447
+ .then(() => this.syncRoomsFromSlack())
448
+ .catch((err) => this.logger.warn('Room sync from Slack failed (non-fatal)', { error: err instanceof Error ? err.message : String(err) }));
444
449
  this.pruneTimer = setTimeout(run, SLACK_TEAM_CHANNEL_CONSTANTS.ADHOC_PRUNE_FIRST_DELAY_MS);
445
450
  this.pruneInterval = setInterval(run, SLACK_TEAM_CHANNEL_CONSTANTS.ADHOC_PRUNE_INTERVAL_MS);
446
451
  this.pruneTimer.unref?.();
@@ -652,6 +657,14 @@ export class SlackTeamChannelService {
652
657
  }
653
658
  return result;
654
659
  }
660
+ /**
661
+ * Whether Slack is connected right now (creating a channel needs it).
662
+ *
663
+ * @returns True when connected
664
+ */
665
+ isConnected() {
666
+ return this.deps.slack.isConnected();
667
+ }
655
668
  /** Undo {@link start}. */
656
669
  stop() {
657
670
  if (!this.started)
@@ -952,12 +965,13 @@ export class SlackTeamChannelService {
952
965
  */
953
966
  async ensureAgentChannel(input) {
954
967
  const store = await this.load();
955
- const derived = slackChannelNameFor(input.name, store.channelPrefix);
968
+ const derived = slackChannelNameFor(input.name, input.applyPrefix === false ? '' : store.channelPrefix);
956
969
  return this.serialised(`agent-channel:${derived}`, async () => {
957
970
  if (!this.deps.slack.isConnected())
958
971
  throw new Error('Slack is not connected');
959
972
  const current = await this.load();
960
973
  let mapping = (input.existingChannelId ? current.mappings.find((m) => m.slackChannelId === input.existingChannelId) : undefined) ??
974
+ (input.chatChannelId ? current.mappings.find((m) => m.chatChannelId === input.chatChannelId) : undefined) ??
961
975
  current.mappings.find((m) => isAdhocMapping(m) && m.autoCreated && (m.derivedName ?? m.slackChannelName) === derived);
962
976
  const sessions = [...new Set(input.memberSessions.filter((s) => !!s))];
963
977
  if (mapping) {
@@ -971,18 +985,32 @@ export class SlackTeamChannelService {
971
985
  }
972
986
  else {
973
987
  const channel = await this.deps.slack.createChannel(derived);
988
+ // createChannel hands back an existing channel when the name is taken;
989
+ // a team's channel must not be turned into a second room.
990
+ const clash = current.mappings.find((m) => m.slackChannelId === channel.id);
991
+ if (clash)
992
+ throw new Error(`#${channel.name} is already linked to ${isAdhocMapping(clash) ? 'another channel' : 'a team'}`);
974
993
  const ownerInvited = await this.inviteOwner(channel.id, channel.name);
975
994
  if (input.purpose.trim()) {
976
995
  await this.deps.slack.setChannelPurpose(channel.id, input.purpose.trim()).catch((err) => {
977
996
  this.logger.debug('setPurpose failed (non-critical)', { error: err instanceof Error ? err.message : String(err) });
978
997
  });
979
998
  }
980
- const huddle = this.deps.chat.createHuddle({
981
- name: `#${channel.name}`,
982
- purpose: input.purpose.trim() || `Slack channel #${channel.name}`,
983
- memberSessions: sessions.length > 0 ? sessions : [`channel:${channel.id}`],
984
- principal: { userId: 'system', source: 'oss' },
985
- });
999
+ let huddle;
1000
+ if (input.chatChannelId) {
1001
+ huddle = { id: input.chatChannelId };
1002
+ if (sessions.length > 0)
1003
+ this.deps.chat.setHuddleMembers(input.chatChannelId, sessions);
1004
+ this.deps.chat.renameChannelForBridge?.(input.chatChannelId, `#${channel.name}`);
1005
+ }
1006
+ else {
1007
+ huddle = this.deps.chat.createHuddle({
1008
+ name: `#${channel.name}`,
1009
+ purpose: input.purpose.trim() || `Slack channel #${channel.name}`,
1010
+ memberSessions: sessions.length > 0 ? sessions : [`channel:${channel.id}`],
1011
+ principal: { userId: 'system', source: 'oss' },
1012
+ });
1013
+ }
986
1014
  mapping = {
987
1015
  teamId: `${SLACK_TEAM_CHANNEL_CONSTANTS.ADHOC_TEAM_PREFIX}${channel.id}`,
988
1016
  slackChannelId: channel.id,
@@ -1275,7 +1303,7 @@ export class SlackTeamChannelService {
1275
1303
  // owner had added to a private channel out of it until someone happened
1276
1304
  // to address it by name.
1277
1305
  const receiving = await this.localReceivingAgent(message);
1278
- if (receiving && isAdhocMapping(mapping) && !(mapping.members ?? []).includes(receiving)) {
1306
+ if (receiving && isAdhocMapping(mapping) && !(mapping.members ?? []).includes(receiving) && !(mapping.excluded ?? []).includes(receiving)) {
1279
1307
  mapping.members = [...(mapping.members ?? []), receiving];
1280
1308
  this.deps.chat.setHuddleMembers(mapping.chatChannelId, mapping.members);
1281
1309
  await this.save();
@@ -1492,6 +1520,12 @@ export class SlackTeamChannelService {
1492
1520
  const next = [...new Set([...(mapping.members ?? []), ...resolved.mentions])];
1493
1521
  if (next.length !== (mapping.members ?? []).length) {
1494
1522
  mapping.members = next;
1523
+ // An explicit @ brings back an agent the owner had removed.
1524
+ if (mapping.excluded) {
1525
+ mapping.excluded = mapping.excluded.filter((sess) => !next.includes(sess));
1526
+ if (mapping.excluded.length === 0)
1527
+ delete mapping.excluded;
1528
+ }
1495
1529
  this.deps.chat.setHuddleMembers(mapping.chatChannelId, next);
1496
1530
  await this.save();
1497
1531
  this.deps.onRoomsChanged?.();
@@ -2999,6 +3033,12 @@ export class SlackTeamChannelService {
2999
3033
  });
3000
3034
  return false;
3001
3035
  };
3036
+ if (dto.senderType === 'user' && isOwnerTypedInCrewly(dto)) {
3037
+ await this.load();
3038
+ const room = this.findByChatChannelId(dto.channelId);
3039
+ if (room && isAdhocMapping(room))
3040
+ return await this.mirrorOwnerPost(room, dto);
3041
+ }
3002
3042
  if (dto.senderType !== 'agent')
3003
3043
  return skip(`senderType=${dto.senderType}`);
3004
3044
  if (dto.metadata?.source === 'slack')
@@ -3073,6 +3113,90 @@ export class SlackTeamChannelService {
3073
3113
  return false;
3074
3114
  }
3075
3115
  }
3116
+ /**
3117
+ * Post a message the owner typed in Crewly into the room's Slack channel,
3118
+ * so people and agents on other machines see it. Posted by the workspace
3119
+ * (Crewly) bot as `*<owner>* (from Crewly): …`; in a private room the
3120
+ * workspace bot is not in, a member's bot posts it instead. Threads follow
3121
+ * the chat thread's Slack root. The Slack copy is remembered as already
3122
+ * routed, so its echo is never dispatched a second time (the chat-v2
3123
+ * dispatcher already delivered the original).
3124
+ *
3125
+ * @param mapping - The room (ad-hoc mapping)
3126
+ * @param dto - The owner's chat row
3127
+ * @returns True when Slack took it
3128
+ */
3129
+ async mirrorOwnerPost(mapping, dto) {
3130
+ if (!this.deps.slack.isConnected())
3131
+ return false;
3132
+ const pendingRoot = dto.threadId ? this.rootPosts.get(dto.threadId) : undefined;
3133
+ if (pendingRoot)
3134
+ await pendingRoot;
3135
+ const threadTs = this.resolveOutboundThreadTs(mapping, dto);
3136
+ if (threadTs === null)
3137
+ return false;
3138
+ let done = () => undefined;
3139
+ if (!threadTs)
3140
+ this.rootPosts.set(dto.id, new Promise((resolve) => (done = resolve)));
3141
+ try {
3142
+ const owner = await this.ownerDisplayName();
3143
+ const body = await this.linkAgentMentions(toSlackMrkdwn(dto.content), mapping.slackChannelId);
3144
+ const text = `*${owner}* (from Crewly): ${body}`;
3145
+ const send = (botToken) => this.deps.slack.sendMessage({
3146
+ channelId: mapping.slackChannelId,
3147
+ text,
3148
+ ...(threadTs ? { threadTs } : {}),
3149
+ skipChatV2Mirror: true,
3150
+ notAnAnswer: true,
3151
+ ...(botToken ? { botToken } : {}),
3152
+ });
3153
+ let ts = '';
3154
+ let lastError = '';
3155
+ if (this.deps.identities)
3156
+ await this.deps.identities.load();
3157
+ for (const token of [undefined, ...this.memberTokens(mapping)]) {
3158
+ try {
3159
+ ts = await send(token);
3160
+ break;
3161
+ }
3162
+ catch (err) {
3163
+ lastError = describeSlackError(err).code;
3164
+ if (lastError !== 'not_in_channel' && lastError !== 'channel_not_found')
3165
+ break;
3166
+ }
3167
+ }
3168
+ if (!ts) {
3169
+ this.logger.warn('Owner message from Crewly not posted to Slack', { slackChannel: mapping.slackChannelName, error: lastError });
3170
+ return false;
3171
+ }
3172
+ // The echo of this post (through any app in the room) is a repeat.
3173
+ this.seenInbound.set(`${mapping.slackChannelId}:${ts}`, dto);
3174
+ if (!threadTs)
3175
+ this.recordSlackRoot(dto, mapping, ts);
3176
+ this.logger.info('Owner message from Crewly posted to Slack', { slackChannel: mapping.slackChannelName, threaded: Boolean(threadTs) });
3177
+ return true;
3178
+ }
3179
+ finally {
3180
+ done();
3181
+ this.rootPosts.delete(dto.id);
3182
+ }
3183
+ }
3184
+ /** The owner's Slack name (cached), or "Owner". */
3185
+ async ownerDisplayName() {
3186
+ if (this.ownerName)
3187
+ return this.ownerName;
3188
+ const owner = this.deps.getOwnerUserId?.() ?? null;
3189
+ if (owner && this.deps.slack.getUserInfo) {
3190
+ try {
3191
+ const info = await this.deps.slack.getUserInfo(owner);
3192
+ this.ownerName = info.realName || info.name || null;
3193
+ }
3194
+ catch {
3195
+ /* fall through */
3196
+ }
3197
+ }
3198
+ return this.ownerName ?? 'Owner';
3199
+ }
3076
3200
  /**
3077
3201
  * Post an agent message that belongs to no thread as a new top-level
3078
3202
  * Slack post.
@@ -3813,9 +3937,11 @@ export class SlackTeamChannelService {
3813
3937
  continue;
3814
3938
  await this.inviteBot(mapping, agentSession, record.botUserId);
3815
3939
  }
3816
- // Channels Crewly created for a set of agents (ensureAgentChannel).
3940
+ // Rooms the agent is a member of: channels Crewly created for a set of
3941
+ // agents (ensureAgentChannel), and Slack channels the owner added it to
3942
+ // from Crewly before its bot existed.
3817
3943
  for (const mapping of this.store?.mappings ?? []) {
3818
- if (!isAdhocMapping(mapping) || !mapping.autoCreated)
3944
+ if (!isAdhocMapping(mapping))
3819
3945
  continue;
3820
3946
  if (!(mapping.members ?? []).includes(agentSession) || record.invitedTo.includes(mapping.slackChannelId))
3821
3947
  continue;
@@ -3829,7 +3955,11 @@ export class SlackTeamChannelService {
3829
3955
  }
3830
3956
  catch (err) {
3831
3957
  const message = err instanceof Error ? err.message : String(err);
3832
- if (!/already_in_channel/.test(message)) {
3958
+ const code = describeSlackError(err).code;
3959
+ const alreadyIn = /already_in_channel/.test(message) || code === 'already_in_channel';
3960
+ // The workspace bot is usually not in a private room the owner made;
3961
+ // another member's own bot can invite instead.
3962
+ if (!alreadyIn && !(await this.inviteViaMemberBot(mapping, agentSession, botUserId))) {
3833
3963
  this.logger.warn('Could not invite agent bot into channel', { agentSession, channel: mapping.slackChannelName, error: message });
3834
3964
  return false;
3835
3965
  }
@@ -3838,6 +3968,272 @@ export class SlackTeamChannelService {
3838
3968
  this.logger.info('Agent bot invited into team channel', { agentSession, channel: `#${mapping.slackChannelName}` });
3839
3969
  return true;
3840
3970
  }
3971
+ /**
3972
+ * Invite a bot with another room member's own bot token (the workspace bot
3973
+ * is not in the room). Tries each member that has a bot until one works.
3974
+ *
3975
+ * @returns True when the bot is now in the channel
3976
+ */
3977
+ async inviteViaMemberBot(mapping, agentSession, botUserId) {
3978
+ const ops = this.deps.channelOps;
3979
+ if (!ops || !isAdhocMapping(mapping))
3980
+ return false;
3981
+ for (const token of this.memberTokens(mapping, [agentSession])) {
3982
+ const res = await ops.invite(mapping.slackChannelId, botUserId, token);
3983
+ if (res.ok || res.error === 'already_in_channel')
3984
+ return true;
3985
+ }
3986
+ return false;
3987
+ }
3988
+ /** Installed bot tokens of an ad-hoc room's members, minus `except`. */
3989
+ memberTokens(mapping, except = []) {
3990
+ return [
3991
+ ...new Set((mapping.members ?? [])
3992
+ .filter((s) => !except.includes(s))
3993
+ .map((s) => this.deps.identities?.getInstalled(s)?.botToken)
3994
+ .filter((t) => !!t)),
3995
+ ];
3996
+ }
3997
+ /**
3998
+ * Take an agent's bot out of a room's Slack channel: the workspace bot
3999
+ * removes it (`conversations.kick`), else the agent's own bot leaves.
4000
+ *
4001
+ * @returns True when the bot is out (or was never in)
4002
+ */
4003
+ async removeBot(mapping, agentSession) {
4004
+ const installed = this.deps.identities?.getInstalled(agentSession) ?? null;
4005
+ if (!installed)
4006
+ return false;
4007
+ if (this.deps.slack.kickFromChannel && this.deps.slack.isConnected()) {
4008
+ try {
4009
+ await this.deps.slack.kickFromChannel(mapping.slackChannelId, installed.botUserId);
4010
+ return true;
4011
+ }
4012
+ catch (err) {
4013
+ if (describeSlackError(err).code === 'not_in_channel')
4014
+ return true;
4015
+ }
4016
+ }
4017
+ const ops = this.deps.channelOps;
4018
+ if (!ops)
4019
+ return false;
4020
+ const res = await ops.leave(mapping.slackChannelId, installed.botToken);
4021
+ return res.ok || res.error === 'not_in_channel' || res.error === 'channel_not_found';
4022
+ }
4023
+ // -------------------------------------------------------------------------
4024
+ // Crewly channels (specs/2026-10-07-crewly-channels.md): ad-hoc rooms
4025
+ // managed from Crewly — members, name, archive — and kept in step with Slack.
4026
+ // -------------------------------------------------------------------------
4027
+ /**
4028
+ * Set an ad-hoc room's members from Crewly. Added agents' bots are invited
4029
+ * into the Slack channel; removed agents' bots are taken out. Removed
4030
+ * agents are remembered (`excluded`) so they are not added back
4031
+ * automatically if Slack refused to take their bot out.
4032
+ *
4033
+ * @param slackChannelId - The room's Slack channel
4034
+ * @param sessions - The complete desired roster
4035
+ * @returns The diff and what Slack did with it
4036
+ * @throws Error when the channel is not an ad-hoc room
4037
+ */
4038
+ async setRoomMembers(slackChannelId, sessions) {
4039
+ return this.serialised(`room:${slackChannelId}`, async () => {
4040
+ await this.load();
4041
+ const mapping = this.findBySlackChannelId(slackChannelId);
4042
+ if (!mapping || !isAdhocMapping(mapping))
4043
+ throw new Error(`Not a Crewly channel: ${slackChannelId}`);
4044
+ const wanted = [...new Set(sessions.map((x) => x.trim()).filter(Boolean))];
4045
+ const before = mapping.members ?? [];
4046
+ const added = wanted.filter((x) => !before.includes(x));
4047
+ const removed = before.filter((x) => !wanted.includes(x));
4048
+ const change = { added, removed, invited: [], notInvited: [], removedFromSlack: [], stillInSlack: [] };
4049
+ if (added.length === 0 && removed.length === 0)
4050
+ return change;
4051
+ if (this.deps.identities)
4052
+ await this.deps.identities.load();
4053
+ // Remove first, with the roster as it was: a leaving bot's token can
4054
+ // still help, and the remaining members' tokens can invite.
4055
+ for (const session of removed) {
4056
+ (await this.removeBot(mapping, session) ? change.removedFromSlack : change.stillInSlack).push(session);
4057
+ }
4058
+ mapping.members = wanted;
4059
+ const excluded = new Set([...(mapping.excluded ?? []).filter((x) => !added.includes(x)), ...removed]);
4060
+ if (excluded.size > 0)
4061
+ mapping.excluded = [...excluded];
4062
+ else
4063
+ delete mapping.excluded;
4064
+ this.deps.chat.setHuddleMembers(mapping.chatChannelId, wanted);
4065
+ await this.save();
4066
+ for (const session of added) {
4067
+ const record = this.deps.identities?.get(session);
4068
+ const ok = record?.status === 'installed' && record.botUserId ? await this.inviteBot(mapping, session, record.botUserId) : false;
4069
+ (ok ? change.invited : change.notInvited).push(session);
4070
+ }
4071
+ this.deps.onRoomsChanged?.();
4072
+ this.logger.info('Channel members changed from Crewly', { slackChannel: `#${mapping.slackChannelName}`, ...change });
4073
+ return change;
4074
+ });
4075
+ }
4076
+ /**
4077
+ * Rename an ad-hoc room's Slack channel from Crewly. The name is
4078
+ * normalised the way Slack wants it (lower case, no spaces, at most 80
4079
+ * characters); the name Slack applied is stored and returned, so Crewly
4080
+ * shows exactly what Slack shows.
4081
+ *
4082
+ * @param slackChannelId - The room's Slack channel
4083
+ * @param name - The name the owner typed
4084
+ * @returns The applied channel name (without `#`)
4085
+ * @throws Error when the room is unknown or Slack refused (name taken, no permission)
4086
+ */
4087
+ async renameRoom(slackChannelId, name) {
4088
+ return this.serialised(`room:${slackChannelId}`, async () => {
4089
+ await this.load();
4090
+ const mapping = this.findBySlackChannelId(slackChannelId);
4091
+ if (!mapping || !isAdhocMapping(mapping))
4092
+ throw new Error(`Not a Crewly channel: ${slackChannelId}`);
4093
+ const desired = slackChannelNameFor(name);
4094
+ if (desired === mapping.slackChannelName)
4095
+ return desired;
4096
+ let applied = null;
4097
+ let lastError = 'rename_failed';
4098
+ if (this.deps.slack.isConnected())
4099
+ applied = await this.deps.slack.renameChannel(slackChannelId, desired).catch(() => null);
4100
+ if (!applied && this.deps.channelOps) {
4101
+ if (this.deps.identities)
4102
+ await this.deps.identities.load();
4103
+ for (const token of this.memberTokens(mapping)) {
4104
+ const res = await this.deps.channelOps.rename(slackChannelId, desired, token);
4105
+ if (res.ok) {
4106
+ applied = res.name;
4107
+ break;
4108
+ }
4109
+ lastError = res.error;
4110
+ if (res.error === 'name_taken' || res.error === 'invalid_name')
4111
+ break;
4112
+ }
4113
+ }
4114
+ if (!applied)
4115
+ throw new Error(`Slack did not rename #${mapping.slackChannelName}: ${lastError}`);
4116
+ this.applyRoomName(mapping, applied);
4117
+ await this.save();
4118
+ this.logger.info('Channel renamed from Crewly', { slackChannelId, to: applied });
4119
+ return applied;
4120
+ });
4121
+ }
4122
+ /**
4123
+ * Archive an ad-hoc room from Crewly: the mapping and huddle go; the Slack
4124
+ * channel is archived when `archiveSlackChannel`, otherwise the members'
4125
+ * bots leave it (so it is not found again as a room).
4126
+ *
4127
+ * @param slackChannelId - The room's Slack channel
4128
+ * @param options - `archiveSlackChannel` for a channel Crewly created
4129
+ * @returns True when a room existed
4130
+ */
4131
+ async archiveRoom(slackChannelId, options = {}) {
4132
+ const mapping = this.findBySlackChannelId(slackChannelId);
4133
+ if (!mapping || !isAdhocMapping(mapping))
4134
+ return false;
4135
+ if (!options.archiveSlackChannel) {
4136
+ if (this.deps.identities)
4137
+ await this.deps.identities.load();
4138
+ for (const session of mapping.members ?? [])
4139
+ await this.removeBot(mapping, session);
4140
+ }
4141
+ const done = await this.unlinkTeam(mapping.teamId, { archiveSlackChannel: !!options.archiveSlackChannel });
4142
+ if (done)
4143
+ this.deps.onRoomsChanged?.();
4144
+ return done;
4145
+ }
4146
+ /**
4147
+ * Bring names and ad-hoc rosters in from Slack.
4148
+ *
4149
+ * - Every mapped channel (team and ad-hoc): a rename in Slack is shown in
4150
+ * Crewly (mapping name and huddle name). A team channel's `derivedName`
4151
+ * is left alone, so the owner's Slack name is not renamed back on the
4152
+ * next team save.
4153
+ * - Ad-hoc rooms: local agents whose bot is in the Slack channel join the
4154
+ * roster, unless the owner removed them in Crewly. (Leaving is handled by
4155
+ * {@link pruneAdhocMembers}.)
4156
+ *
4157
+ * Reads with the room members' own bots first (the workspace bot is
4158
+ * usually not in a private room), then the workspace bot. Nothing changes
4159
+ * on a read that fails.
4160
+ *
4161
+ * @returns What changed
4162
+ */
4163
+ async syncRoomsFromSlack() {
4164
+ await this.load();
4165
+ const result = { renamed: [], joined: [] };
4166
+ if (this.deps.identities)
4167
+ await this.deps.identities.load();
4168
+ const teams = await this.deps.storage.getTeams();
4169
+ const local = teams
4170
+ .flatMap((t) => teamChannelMembers(t))
4171
+ .filter((m) => this.deps.isLocalAgent?.(m.sessionName) ?? true);
4172
+ let changed = false;
4173
+ let roomsChanged = false;
4174
+ for (const mapping of this.store?.mappings ?? []) {
4175
+ let liveName = null;
4176
+ let inChannel = null;
4177
+ if (isAdhocMapping(mapping)) {
4178
+ for (const token of this.memberTokens(mapping)) {
4179
+ if (!liveName && this.deps.channelOps) {
4180
+ const info = await this.deps.channelOps.info(mapping.slackChannelId, token);
4181
+ if (info.ok && info.name)
4182
+ liveName = info.name;
4183
+ }
4184
+ if (!inChannel && this.deps.listChannelMembers) {
4185
+ const list = await this.deps.listChannelMembers(mapping.slackChannelId, token);
4186
+ if (list.ok)
4187
+ inChannel = new Set(list.members);
4188
+ }
4189
+ if (liveName && (inChannel || !this.deps.listChannelMembers))
4190
+ break;
4191
+ }
4192
+ }
4193
+ if (!liveName && this.deps.slack.isConnected()) {
4194
+ const info = await this.deps.slack.getChannelInfo(mapping.slackChannelId).catch(() => null);
4195
+ if (info?.name)
4196
+ liveName = info.name;
4197
+ }
4198
+ if (liveName && liveName !== mapping.slackChannelName) {
4199
+ result.renamed.push({ slackChannelId: mapping.slackChannelId, from: mapping.slackChannelName, to: liveName });
4200
+ this.applyRoomName(mapping, liveName);
4201
+ changed = true;
4202
+ }
4203
+ if (inChannel && isAdhocMapping(mapping)) {
4204
+ const members = mapping.members ?? [];
4205
+ const excluded = mapping.excluded ?? [];
4206
+ const joined = local
4207
+ .filter((m) => !members.includes(m.sessionName) && !excluded.includes(m.sessionName))
4208
+ .filter((m) => {
4209
+ const botUserId = this.deps.identities?.getInstalled(m.sessionName)?.botUserId;
4210
+ return !!botUserId && inChannel.has(botUserId);
4211
+ })
4212
+ .map((m) => m.sessionName);
4213
+ if (joined.length > 0) {
4214
+ mapping.members = [...members, ...joined];
4215
+ this.deps.chat.setHuddleMembers(mapping.chatChannelId, mapping.members);
4216
+ result.joined.push({ slackChannelId: mapping.slackChannelId, added: joined });
4217
+ changed = true;
4218
+ roomsChanged = true;
4219
+ }
4220
+ }
4221
+ }
4222
+ if (changed)
4223
+ await this.save();
4224
+ if (roomsChanged)
4225
+ this.deps.onRoomsChanged?.();
4226
+ if (result.renamed.length || result.joined.length)
4227
+ this.logger.info('Rooms synced from Slack', { ...result });
4228
+ return result;
4229
+ }
4230
+ /** Record a channel's Slack name on its mapping and huddle (`#name`). */
4231
+ applyRoomName(mapping, name) {
4232
+ mapping.slackChannelName = name;
4233
+ if (isAdhocMapping(mapping) && mapping.autoCreated)
4234
+ mapping.derivedName = name;
4235
+ this.deps.chat.renameChannelForBridge?.(mapping.chatChannelId, `#${name}`);
4236
+ }
3841
4237
  // -------------------------------------------------------------------------
3842
4238
  // Slack-side messages
3843
4239
  // -------------------------------------------------------------------------
@@ -3897,6 +4293,21 @@ export class SlackTeamChannelService {
3897
4293
  return next;
3898
4294
  }
3899
4295
  }
4296
+ /**
4297
+ * A row the owner typed in Crewly (dashboard, phone, Talk) — not a Slack
4298
+ * message recorded here and not an agent writing as a user turn.
4299
+ *
4300
+ * @param dto - A `user` chat row
4301
+ * @returns True when it should be mirrored to the room's Slack channel
4302
+ */
4303
+ export function isOwnerTypedInCrewly(dto) {
4304
+ const md = dto.metadata ?? {};
4305
+ if (md.source !== undefined && md.source !== 'cloud-talk')
4306
+ return false;
4307
+ if (md[OWNER_EVIDENCE_METADATA.AUTHOR_AGENT_SESSION] || md[OWNER_EVIDENCE_METADATA.REMOTE_AGENT_SESSION])
4308
+ return false;
4309
+ return typeof md.slackTs !== 'string' && typeof md.slackChannelId !== 'string';
4310
+ }
3900
4311
  /** Type guard for persisted mapping rows. */
3901
4312
  /**
3902
4313
  * A promise's value, or `fallback` when it takes longer than `ms`.