@stratusagent/channel-slack 0.11.0 → 0.11.2

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.
package/dist/index.js CHANGED
@@ -2,11 +2,40 @@ import { randomUUID } from 'node:crypto';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { createRequire } from 'node:module';
4
4
  import path from 'node:path';
5
- import { latestTurnReply } from '@stratusagent/core';
5
+ import { latestTurnReply, SENDER_TRUST_METADATA_KEY, } from '@stratusagent/core';
6
6
  import { channelSessionKey, } from '@stratusagent/channels';
7
7
  const SLACK_MAX_MESSAGE_CHARS = 4000;
8
8
  const DEFAULT_EDIT_INTERVAL_MS = 1000;
9
9
  const DEDUPE_CAPACITY = 2000;
10
+ /**
11
+ * How many threads' addressees are remembered in receipt order. Past this
12
+ * the least recently addressed is dropped, which costs its next follow-up
13
+ * one session lookup — the durable answer, which is what a restart falls
14
+ * back to anyway.
15
+ */
16
+ const THREAD_ADDRESSEE_CAPACITY = 2000;
17
+ /**
18
+ * How many cold verdicts are remembered. Far smaller than the addressee
19
+ * map: one is wanted only while the agents sharing a thread resolve the
20
+ * same message, and the addressee record answers for every message after.
21
+ */
22
+ const COLD_VERDICT_CAPACITY = 256;
23
+ /**
24
+ * How many changes of hands one thread remembers.
25
+ *
26
+ * A message older than all of them asks the sessions instead, which answer
27
+ * for the thread as it stands rather than as it stood — so a reply that old
28
+ * can be resolved to the agent holding the thread now. That is the cost of
29
+ * bounding this, and something has to bound it: the alternative is a list
30
+ * that grows with a conversation for the life of the daemon.
31
+ *
32
+ * Eight is chosen to sit far past the case it covers. This only has to
33
+ * span the lag between two sockets, which is messages inside one delivery
34
+ * window; crossing it needs a single thread to change hands eight times —
35
+ * eight mentions, alternating between agents — while one socket stays
36
+ * behind for all of them.
37
+ */
38
+ const THREAD_HANDOVER_DEPTH = 8;
10
39
  /**
11
40
  * How many files an unrendered turn may queue for its outcome. Nothing
12
41
  * drains that queue until the outcome arrives, so a session whose outcome
@@ -438,9 +467,9 @@ const truncateForSlack = (text) => text.length <= SLACK_MAX_MESSAGE_CHARS
438
467
  * point of writing a span as ``code with a ` in it`` and a fence as ````
439
468
  * when the block itself contains ```.
440
469
  */
441
- const closingBacktickRun = (text, from, length) => {
470
+ const closingBacktickRun = (text, from, length, limit) => {
442
471
  const run = '`'.repeat(length);
443
- for (let at = text.indexOf(run, from); at !== -1; at = text.indexOf(run, at + 1)) {
472
+ for (let at = text.indexOf(run, from); at !== -1 && at + length <= limit; at = text.indexOf(run, at + 1)) {
444
473
  if (text[at - 1] !== '`' && text[at + length] !== '`') {
445
474
  return at;
446
475
  }
@@ -456,12 +485,22 @@ const closingBacktickRun = (text, from, length) => {
456
485
  * the same rule for a span and for a fence and is why a longer delimiter
457
486
  * can carry a shorter one inside it.
458
487
  *
488
+ * A span may close on a later line, and is protected the whole way. That
489
+ * looks wasteful — two stray backticks paragraphs apart become one long
490
+ * "span" whose contents are left unconverted — and it was briefly changed
491
+ * to end a span at its line for exactly that reason. That was wrong, and
492
+ * the reason is the rule this whole file answers to: what Slack does with
493
+ * the message decides, not what Markdown says. Slack pairs those backticks
494
+ * too, so the text between them is what it renders as code, and rewriting
495
+ * a `**bold**` in there would alter the contents of somebody's snippet.
496
+ * Leaving prose unconverted is a cosmetic loss; changing what a reader is
497
+ * told is code is not, so the doubt resolves toward protecting more.
498
+ *
459
499
  * A fence with no closer runs to the end on purpose: a model that forgets
460
500
  * to close one, and every partially streamed block on its way to being
461
501
  * closed, would otherwise have its contents rewritten as prose. One or two
462
- * unmatched backticks are the opposite case — literal text, as they are in
463
- * Markdown, since "use the ` character" must not swallow the rest of the
464
- * message.
502
+ * unmatched backticks are the opposite case — literal text, since "use the
503
+ * ` character" must not swallow what follows.
465
504
  */
466
505
  const proseAndCode = (text) => {
467
506
  const segments = [];
@@ -476,7 +515,7 @@ const proseAndCode = (text) => {
476
515
  while (text[open + length] === '`') {
477
516
  length += 1;
478
517
  }
479
- const close = closingBacktickRun(text, open + length, length);
518
+ const close = closingBacktickRun(text, open + length, length, text.length);
480
519
  if (close === -1) {
481
520
  if (length < 3) {
482
521
  cursor = open + length;
@@ -522,26 +561,131 @@ const MRKDWN_REWRITES = [
522
561
  * The optional closing hashes have to be spaced off the text, as Markdown
523
562
  * requires: without that, `# C#` is a heading whose name loses its last
524
563
  * character.
564
+ *
565
+ * What they and any trailing spaces cover is captured rather than dropped,
566
+ * because dropping is only right when they are the heading's own syntax. A
567
+ * heading that opens a code span reaches its end inside one, and there the
568
+ * same characters are the snippet: `` # show `value # `` has a hash that
569
+ * belongs to whoever wrote the code.
525
570
  */
526
- const MARKDOWN_HEADING = /^ {0,3}#{1,6}[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/gm;
527
- /** One emphasis run and nothing else — `*Title*`, not `Some *word* here`. */
528
- const WHOLLY_BOLD = /^\*[^*]+\*$/;
529
- const convertProse = (segment) => {
571
+ const MARKDOWN_HEADING = /^ {0,3}#{1,6}[ \t]+(.+?)((?:[ \t]+#+)?[ \t]*)$/gm;
572
+ const convertInline = (segment) => {
530
573
  let converted = segment;
531
574
  for (const [pattern, replacement] of MRKDWN_REWRITES) {
532
575
  converted = converted.replace(pattern, replacement);
533
576
  }
534
- // Last, so the heading's own text is already converted. A heading a model
535
- // wrote as `## **Title**` is bold by then, and wrapping it again would
536
- // print the asterisks rather than render them. Nothing is stripped to get
537
- // there: an asterisk in a heading may be the text itself (`# glob *.ts`),
538
- // and a reply that reads oddly beats a reply that says something else.
539
- return converted.replace(MARKDOWN_HEADING, (line, body) => {
540
- const text = body.trim();
541
- if (text.length === 0) {
577
+ return converted;
578
+ };
579
+ /**
580
+ * Headings, found on the text's own lines rather than inside each prose
581
+ * fragment.
582
+ *
583
+ * A fragment is not a line: inline code cuts one in two, and the halves
584
+ * begin wherever the code ended. Matched per fragment, `^` then lied in
585
+ * both directions — the tail of `` `status` # not a heading `` looked like a
586
+ * line of its own and lost its hash, and ``## Run `npm test` now`` was only
587
+ * ever half a line, so only the half before the code was wrapped.
588
+ *
589
+ * So the whole text is matched instead, with the code spans it now contains
590
+ * passed in: a `#` whose line begins inside one is a comment in somebody's
591
+ * snippet, not a heading. A heading that merely *contains* code is a
592
+ * heading, and its code goes along untouched.
593
+ *
594
+ * Emphasis inside is already converted by here, so the line is only wrapped
595
+ * when nothing in it can pair with the wrapper. Slack has one bold
596
+ * delimiter and no way to nest it: wrapping `*Run* the thing` again yields
597
+ * `**Run* the thing*`, which it renders as literal asterisks rather than as
598
+ * anything bold. A heading that already carries emphasis therefore keeps
599
+ * exactly the emphasis it has — `## **Title**` is bold by now and needs
600
+ * nothing more, and `# glob *.ts` keeps an asterisk that was never a marker
601
+ * at all. An unbolded heading reads fine; a heading full of stray asterisks
602
+ * does not, and one with characters removed to make room is worse than both.
603
+ *
604
+ * An asterisk inside a code span is not one of those: Slack does not read
605
+ * markup there, so it cannot pair with anything, and ``## Match `*.ts` files``
606
+ * is bolded like any other heading.
607
+ *
608
+ * Both walks are cursors rather than searches: `replace` reports matches in
609
+ * increasing offset, and the spans were collected in that order too, so
610
+ * neither has to be looked at twice. The same work as a scan per heading,
611
+ * without its quadratic on a reply that is mostly headings and snippets.
612
+ */
613
+ /** What could pair with a heading's wrapper, or with something out of sight. */
614
+ const PAIRABLE_MARKER = /[*`]/g;
615
+ const convertHeadings = (text, code) => {
616
+ let span = 0;
617
+ const startsInside = (position, from) => position >= (code[from]?.[0] ?? Number.POSITIVE_INFINITY);
618
+ return text.replace(MARKDOWN_HEADING, (line, body, tail, offset) => {
619
+ while (span < code.length && (code[span]?.[1] ?? 0) <= offset) {
620
+ span += 1;
621
+ }
622
+ // A `#` whose line begins inside a span is a comment in somebody's code.
623
+ if (startsInside(offset, span)) {
624
+ return line;
625
+ }
626
+ const heading = body.trim();
627
+ if (heading.length === 0) {
542
628
  return line;
543
629
  }
544
- return WHOLLY_BOLD.test(text) ? text : `*${text}*`;
630
+ // Where everything this would drop from the line's end begins: the
631
+ // closing hashes and spaces the pattern captured, and whatever else
632
+ // `trimEnd` would take with them. The two are not the same set — the
633
+ // pattern knows only spaces and tabs, while trimming also takes a
634
+ // no-break space, an ideographic space, and the rest of what
635
+ // ECMAScript counts as whitespace — and the difference is exactly
636
+ // where a character goes missing.
637
+ //
638
+ // Dropping any of it is the heading's own syntax being removed, but
639
+ // only while it is the heading's: a line that opens a span reaches its
640
+ // end inside one, and there the same characters are somebody's
641
+ // snippet. Nothing on this line is safe to strip then, so it is left
642
+ // exactly as written. (The front needs no such care: the body starts
643
+ // where the hashes stop, which this line has already been shown to
644
+ // reach in prose.)
645
+ const dropped = tail.length + (body.length - body.trimEnd().length);
646
+ if (dropped > 0) {
647
+ const droppedAt = offset + line.length - dropped;
648
+ let over = span;
649
+ while (over < code.length && (code[over]?.[1] ?? 0) <= droppedAt) {
650
+ over += 1;
651
+ }
652
+ if (startsInside(droppedAt, over)) {
653
+ return line;
654
+ }
655
+ }
656
+ // Anything left on the line that a delimiter could pair with, walked
657
+ // from where the heading cursor already stands rather than from the
658
+ // beginning. An asterisk is the obvious one — it would pair with the
659
+ // wrapper. A backtick counts too: one this side found no partner for is
660
+ // a literal character here, but Slack parses the message itself and may
661
+ // pair it with a backtick further down, and the wrapper's closing `*`
662
+ // would then be written inside what Slack reads as code, where it is
663
+ // ignored — leaving the opening one with nothing to close it. Both are
664
+ // spent characters inside a span, which is why the ranges are consulted
665
+ // rather than the text alone.
666
+ let within = span;
667
+ for (const marker of line.matchAll(PAIRABLE_MARKER)) {
668
+ const position = offset + (marker.index ?? 0);
669
+ while (within < code.length && (code[within]?.[1] ?? 0) <= position) {
670
+ within += 1;
671
+ }
672
+ if (!startsInside(position, within)) {
673
+ return heading;
674
+ }
675
+ }
676
+ // Both markers have to land in prose, not just the opening one. A fence
677
+ // opened on this line — by a model mid-answer, or by a block still being
678
+ // streamed — runs past its end, so the closing `*` would be written
679
+ // inside the code and ignored there, leaving the opening one unpaired.
680
+ // The line began in prose or this never ran, so it is the end that is
681
+ // in question: where the marker would go, one past the line's last
682
+ // character, and not that character itself — a span that closes exactly
683
+ // at the line's end leaves the marker just outside it, which is fine.
684
+ const closer = offset + line.length;
685
+ while (within < code.length && (code[within]?.[1] ?? 0) <= closer) {
686
+ within += 1;
687
+ }
688
+ return startsInside(closer, within) ? heading : `*${heading}*`;
545
689
  });
546
690
  };
547
691
  /**
@@ -558,9 +702,30 @@ const convertProse = (segment) => {
558
702
  * already mean in mrkdwn what they mean in Markdown; rewriting them would
559
703
  * add ways to be wrong and fix nothing.
560
704
  */
561
- const toSlackMrkdwn = (text) => proseAndCode(text)
562
- .map((segment, index) => (index % 2 === 1 ? segment : convertProse(segment)))
563
- .join('');
705
+ const toSlackMrkdwn = (text) => {
706
+ // Where each code span ends up in the converted text, which is not where
707
+ // it started: the prose before it changes length as it is rewritten.
708
+ const code = [];
709
+ const segments = proseAndCode(text);
710
+ let converted = '';
711
+ segments.forEach((segment, index) => {
712
+ if (index % 2 === 1) {
713
+ // A code segment that ends the text is the unterminated fence: every
714
+ // closed run is followed by a prose slice, even an empty one. It has
715
+ // no end to be past, so nothing may be appended after it either —
716
+ // recording the end as it looks would say a marker placed at the
717
+ // text's end was safely outside.
718
+ const ends = index === segments.length - 1
719
+ ? Number.POSITIVE_INFINITY
720
+ : converted.length + segment.length;
721
+ code.push([converted.length, ends]);
722
+ converted += segment;
723
+ return;
724
+ }
725
+ converted += convertInline(segment);
726
+ });
727
+ return convertHeadings(converted, code);
728
+ };
564
729
  const splitForSlack = (text) => {
565
730
  if (text.length <= SLACK_MAX_MESSAGE_CHARS) {
566
731
  return [text];
@@ -638,6 +803,45 @@ const OUTCOME_TEXT = {
638
803
  const APPROVAL_INPUT_LIMIT = 1400;
639
804
  /** One line in a notification; the blocks carry the readable version. */
640
805
  const APPROVAL_SUMMARY_LIMIT = 160;
806
+ /**
807
+ * Subtypes that are still a person saying something, rather than Slack
808
+ * narrating the channel. Everything else a `subtype` marks — an edit, a
809
+ * deletion, a join, a pinned file — is bookkeeping, and an agent following
810
+ * a thread must not answer bookkeeping.
811
+ *
812
+ * These three are here because each is an ordinary thing a person does:
813
+ * `file_share` is a message that happens to carry an attachment (asking a
814
+ * question with the log attached is the normal way to ask it),
815
+ * `thread_broadcast` is a thread reply the author also sent to the channel,
816
+ * and `me_message` is what `/me` produces — typed by a person, in their own
817
+ * words, and marked only by how it is rendered.
818
+ */
819
+ const HUMAN_MESSAGE_SUBTYPES = new Set(['file_share', 'me_message', 'thread_broadcast']);
820
+ const isPersonSpeaking = (event) => event.subtype === undefined || HUMAN_MESSAGE_SUBTYPES.has(event.subtype);
821
+ /** At most this many attachment names are listed; the rest are counted. */
822
+ const MAX_LISTED_ATTACHMENTS = 5;
823
+ /**
824
+ * What a message's attachments are called, for a turn that cannot open
825
+ * them. Without this the agent is handed "here's the log" and no log, and
826
+ * answers as though it had read something — the note is what lets it say
827
+ * the true thing instead.
828
+ */
829
+ const attachmentNote = (files) => {
830
+ if (!files || files.length === 0) {
831
+ return '';
832
+ }
833
+ const names = files.slice(0, MAX_LISTED_ATTACHMENTS).map((file) => file.name ?? file.title ?? 'an unnamed file');
834
+ const rest = files.length - names.length;
835
+ const listed = rest > 0 ? `${names.join(', ')}, and ${rest} more` : names.join(', ');
836
+ return `\n[Attached: ${listed}. Attachment contents cannot be read here — say so rather than guessing at them.]`;
837
+ };
838
+ /**
839
+ * Whether `text` addresses a bot user by name. Slack writes a mention as
840
+ * `<@U123>` markup whoever typed it, so this is the same question for an
841
+ * `app_mention` event and for a plain channel message the app now also
842
+ * sees.
843
+ */
844
+ const mentions = (text, botUserId) => botUserId.length > 0 && text.includes(`<@${botUserId}>`);
641
845
  /**
642
846
  * Slack's three markup characters. Tool input is written by a model, so it
643
847
  * reaches this message as untrusted text: unescaped, `<@U123>` becomes a
@@ -743,6 +947,14 @@ const approvalBlocks = (agentName, toolName, risk, requestId, input, origin, one
743
947
  * (DMs: the DM channel id alone), so threads are resumable conversations
744
948
  * and two agents sharing a thread keep fully separate sessions. Replies
745
949
  * stream via placeholder-then-edit, throttled for chat.update limits.
950
+ *
951
+ * A mention starts a thread conversation; inside one, the agent keeps
952
+ * listening without being tagged again — see `couldBeFollowUp` and
953
+ * `answersFollowUp` for exactly whose reply an untagged message is. That
954
+ * needs the app to be subscribed to `message.channels` / `message.groups`
955
+ * / `message.mpim` (the shipped manifest is); an app installed before
956
+ * those were in it receives only `app_mention` and behaves as it always
957
+ * did, which makes the workspace's own grant the switch.
746
958
  */
747
959
  export const createSlackChannelAdapter = (options) => {
748
960
  const log = options.log ?? (() => { });
@@ -776,6 +988,75 @@ export const createSlackChannelAdapter = (options) => {
776
988
  // Socket Mode redelivers on missed acks; a slow turn must not run twice.
777
989
  const seenEvents = new Set();
778
990
  const seenOrder = [];
991
+ /**
992
+ * Who each thread was last handed to, in SLACK RECEIPT ORDER — written
993
+ * the moment a message is accepted for an agent, before any of the work
994
+ * that message causes.
995
+ *
996
+ * The durable answer (`answersFollowUp`) is the agents' own sessions,
997
+ * and it lags: a session row is written when its turn starts, which is a
998
+ * display-name lookup and a placeholder post after the message that
999
+ * asked for it. Inside that window the persisted order still names the
1000
+ * agent that spoke *before* the handover, so a mention of Bea followed
1001
+ * straight away by an untagged addendum would send the addendum to Ava —
1002
+ * and a follow-up typed on the heels of the mention that opened the
1003
+ * thread would find no session at all and be dropped for good.
1004
+ *
1005
+ * Keyed by thread rather than by (agent, thread): one agent has it at a
1006
+ * time, so there is nothing to reconcile. The value is that thread's
1007
+ * HANDOVERS in order, not just its latest one, because the question an
1008
+ * untagged message asks is not "who holds this thread now" but "who held
1009
+ * it when I was sent". Those differ whenever a socket runs behind
1010
+ * another: Bea's connection can reject a pre-handover reply, then take
1011
+ * the mention that moves the thread to her, and Ava's connection reach
1012
+ * that same earlier reply afterwards — which the latest holder alone
1013
+ * would reject for a second time, dropping a message that was plainly
1014
+ * Ava's when it was written.
1015
+ *
1016
+ * Keeping the handovers also makes out-of-order writes ordinary rather
1017
+ * than dangerous. A connection several messages behind, or a redelivered
1018
+ * envelope carrying an old mention, inserts into the past where it
1019
+ * belongs instead of overwriting the present.
1020
+ *
1021
+ * Bounded and lossy on purpose — evicting an entry costs one lookup,
1022
+ * since the sessions still hold the answer for every thread this map has
1023
+ * forgotten, and for every thread a restart forgot.
1024
+ *
1025
+ * One thing they cannot reconstruct: a handover made in the seconds
1026
+ * before the daemon went down. The mention that moved the thread is
1027
+ * recorded only here, and the newly named agent has not replied yet, so
1028
+ * after a restart the sessions still name the previous speaker and the
1029
+ * next untagged reply goes to them. Surviving that needs durable
1030
+ * adapter-owned state, which the channel contract deliberately does not
1031
+ * have — an adapter reads routing and translates events, and inventing a
1032
+ * private store here is the wrong place to answer it. The next mention
1033
+ * corrects it.
1034
+ */
1035
+ const threadAddressee = new Map();
1036
+ /**
1037
+ * One cold verdict per message — see `followUpWinner`. Bounded, because
1038
+ * an entry outlives its own resolution on purpose; an entry that has not
1039
+ * yet resolved is never the one evicted, since the agent still waiting on
1040
+ * it would otherwise start a second one.
1041
+ */
1042
+ const coldVerdicts = new Map();
1043
+ /**
1044
+ * `agentId` → bot user id, for every app whose token this adapter has
1045
+ * authenticated — whether or not its socket then came up, and kept for as
1046
+ * long as the adapter runs.
1047
+ *
1048
+ * Separate from `connections` because recognizing that somebody was named
1049
+ * and being able to answer are different questions. An agent whose app is
1050
+ * down cannot answer anything; a message that names it is still not the
1051
+ * other agent's to take, and treating it as untagged would have the wrong
1052
+ * agent answer a question a person deliberately handed elsewhere. Silence
1053
+ * is the correct outcome there, and it is also the visible one — the same
1054
+ * thing a mention has always done when an app is down.
1055
+ *
1056
+ * Insertion order is `options.agents` order, which is what makes
1057
+ * `agentNamedIn` deterministic across every connection.
1058
+ */
1059
+ const botIdentities = new Map();
779
1060
  // Rendered approval requests, keyed by the gateway's request id — the
780
1061
  // same id the buttons carry back.
781
1062
  const approvalPosts = new Map();
@@ -841,13 +1122,25 @@ export const createSlackChannelAdapter = (options) => {
841
1122
  }
842
1123
  };
843
1124
  // Mentions arrive as <@U123> markup; the model should read names.
1125
+ /**
1126
+ * `<@U…>` mentions become `@name` — for principals only. A display name
1127
+ * is text its owner typed into their profile, and a mention of a member
1128
+ * nobody vouched for would otherwise carry that member's words into a
1129
+ * turn labelled by its sender alone: an operator's `user` message with a
1130
+ * stranger's chosen text inside it. A principal's profile is trusted the
1131
+ * way their messages are; everyone else stays a stable id, which names
1132
+ * them without quoting them.
1133
+ */
844
1134
  const humanizeMentions = async (connection, text) => {
845
1135
  const withoutBot = text.replaceAll(`<@${connection.botUserId}>`, '').trim();
846
1136
  const mentionPattern = /<@([A-Z0-9]+)>/g;
847
1137
  const ids = [...withoutBot.matchAll(mentionPattern)].map((match) => match[1]).filter((id) => Boolean(id));
1138
+ const principals = new Set(connection.config.principals ?? []);
848
1139
  let result = withoutBot;
849
1140
  for (const id of new Set(ids)) {
850
- result = result.replaceAll(`<@${id}>`, `@${await displayNameFor(connection, id)}`);
1141
+ if (principals.has(id)) {
1142
+ result = result.replaceAll(`<@${id}>`, `@${await displayNameFor(connection, id)}`);
1143
+ }
851
1144
  }
852
1145
  return result.trim();
853
1146
  };
@@ -1348,47 +1641,432 @@ export const createSlackChannelAdapter = (options) => {
1348
1641
  warn(`slack: could not send an ephemeral notice: ${error instanceof Error ? error.message : String(error)}`);
1349
1642
  }
1350
1643
  };
1351
- const handleInbound = async (connection, args) => {
1352
- // Ack immediately: Slack redelivers unacked envelopes, and a turn can
1353
- // outlive the ack window many times over.
1354
- await args.ack();
1644
+ /** One thread, whichever agent is talking in it. */
1645
+ const threadKeyFor = (parts) => `${parts.team}:${parts.conversation}:${parts.thread}`;
1646
+ /**
1647
+ * Records that `agentId` holds the thread from the message at `ts`
1648
+ * onwards, wherever in the thread's history that message falls.
1649
+ *
1650
+ * Only actual handovers are kept: a write naming the agent that already
1651
+ * held the thread at that point adds nothing, so the list stays as short
1652
+ * as the conversation's changes of hands rather than growing per message.
1653
+ * The oldest handovers are dropped past `THREAD_HANDOVER_DEPTH`, and the
1654
+ * least recently written thread past `THREAD_ADDRESSEE_CAPACITY`; both
1655
+ * losses cost a lookup, never an answer, since the sessions still hold
1656
+ * the durable version.
1657
+ *
1658
+ * Slack timestamps compare as strings: `seconds.microseconds`, both
1659
+ * parts fixed width, so lexicographic order is chronological order.
1660
+ */
1661
+ const rememberAddressee = (key, agentId, ts) => {
1662
+ const handovers = threadAddressee.get(key) ?? [];
1663
+ let at = handovers.length;
1664
+ while (at > 0 && handovers[at - 1].ts > ts) {
1665
+ at -= 1;
1666
+ }
1667
+ const before = handovers[at - 1];
1668
+ if (before && before.agentId === agentId) {
1669
+ // Already this agent's from earlier in the thread; the same message
1670
+ // reaching a second connection lands here too.
1671
+ return;
1672
+ }
1673
+ handovers.splice(at, 0, { agentId, ts });
1674
+ // Re-inserted rather than mutated in place: a Map iterates in insertion
1675
+ // order, which is what makes the first key the one to evict.
1676
+ threadAddressee.delete(key);
1677
+ threadAddressee.set(key, handovers.slice(-THREAD_HANDOVER_DEPTH));
1678
+ if (threadAddressee.size > THREAD_ADDRESSEE_CAPACITY) {
1679
+ const oldest = threadAddressee.keys().next();
1680
+ if (!oldest.done) {
1681
+ threadAddressee.delete(oldest.value);
1682
+ }
1683
+ }
1684
+ };
1685
+ /**
1686
+ * Who held the thread when the message at `ts` was sent — the last
1687
+ * handover at or before it. Undefined when this process has no handover
1688
+ * that old, which sends the question to the sessions.
1689
+ */
1690
+ const holderAt = (key, ts) => {
1691
+ const handovers = threadAddressee.get(key);
1692
+ if (!handovers) {
1693
+ return undefined;
1694
+ }
1695
+ for (let index = handovers.length - 1; index >= 0; index -= 1) {
1696
+ const handover = handovers[index];
1697
+ if (handover && handover.ts <= ts) {
1698
+ return handover.agentId;
1699
+ }
1700
+ }
1701
+ return undefined;
1702
+ };
1703
+ /**
1704
+ * Which of this process's agents a message names, or undefined if it
1705
+ * names none of them.
1706
+ *
1707
+ * When it names several, the first that is actually serving — and any
1708
+ * of them otherwise. That determinism is the point: every connection sees every message and runs
1709
+ * this over the same text and the same array, so all of them agree on who
1710
+ * a message handed the thread to — including the connections that will go
1711
+ * on to ignore it. Which is what makes the handover record converge
1712
+ * without the sockets having to agree on anything, since each agent is
1713
+ * its own Socket Mode connection and one can run minutes behind another.
1714
+ *
1715
+ * Scoped to the message's own workspace, because a bot user id is only
1716
+ * unique within one — the same reason the display-name cache is keyed by
1717
+ * team. Two agents in two workspaces can hold the same id, and resolving
1718
+ * a mention to the wrong one would hand the thread to an agent that
1719
+ * cannot see it, leaving the agent that was actually named refusing every
1720
+ * reply after.
1721
+ *
1722
+ * Only agents this adapter has authenticated can be recognized at all.
1723
+ * One served by another daemon is invisible here, and both would answer;
1724
+ * so is one whose `auth.test` itself failed, since its bot id was never
1725
+ * learned.
1726
+ */
1727
+ const agentNamedIn = (text, team) => {
1728
+ let offline;
1729
+ for (const [agentId, identity] of botIdentities) {
1730
+ if (identity.teamId !== team || !mentions(text, identity.botUserId)) {
1731
+ continue;
1732
+ }
1733
+ if (connections.some((live) => live.config.agentId === agentId)) {
1734
+ return agentId;
1735
+ }
1736
+ // Named, recognized, and not serving. Remembered only as the answer
1737
+ // of last resort: a message that names an agent whose app is down and
1738
+ // nobody else still gets silence, which is the deliberate behavior —
1739
+ // but a message that also names one that IS serving must leave the
1740
+ // thread with the agent that can actually answer it, or the reply it
1741
+ // just posted would be the last anyone gets.
1742
+ offline ??= agentId;
1743
+ }
1744
+ return offline;
1745
+ };
1746
+ /**
1747
+ * Who an untagged reply belongs to, asked of the SESSIONS — the durable
1748
+ * answer, for a thread this process has no record of: one it has not seen
1749
+ * a message in since it started, or one evicted from `threadAddressee`.
1750
+ * Everything else is settled synchronously in `admit`, before this is
1751
+ * ever reached.
1752
+ *
1753
+ * Being in the thread is the first half: a session under a thread's key
1754
+ * exists only because someone mentioned that agent here, so an agent
1755
+ * follows up only in conversations it was invited into, and stops when
1756
+ * the thread is gone. `sessionRouting` is the durable record of that — a
1757
+ * restart forgets nothing.
1758
+ *
1759
+ * Whose turn it is, is the second. In a thread with several agents an
1760
+ * unaddressed reply belongs to **whoever spoke last**, the way it does
1761
+ * between people: answer the voice that just answered you, and tag
1762
+ * someone by name to change that. `lastSpokeAt` orders them — when each
1763
+ * agent last *replied*, not when its session last changed, because a turn
1764
+ * saves on tool results and approval checkpoints without having said
1765
+ * anything, and a recovery resuming after a restart lands in exactly this
1766
+ * window.
1767
+ *
1768
+ * Nobody is the answer only when the agents cannot be *ordered* — nothing
1769
+ * in the thread has spoken (a host whose routing carries no `lastSpokeAt`
1770
+ * at all reads this way, and so does a thread where every agent is still
1771
+ * on its first turn), or the newest two tie — because guessing there
1772
+ * would put two answers under one message. An agent that simply has not
1773
+ * spoken is a different thing and is skipped rather than fatal: it cannot
1774
+ * have spoken last, and letting it silence the agent that did would leave
1775
+ * a reply nobody answers. The agent alone in its thread, which is nearly
1776
+ * every thread, is answered without the question arising.
1777
+ */
1778
+ const resolveFollowUpWinner = async (parts) => {
1355
1779
  const gateway = gatewayRef;
1780
+ if (!gateway?.sessionRouting || !parts.thread) {
1781
+ return undefined;
1782
+ }
1783
+ const engaged = [];
1784
+ // Live connections in this message's OWN workspace. Live, because an
1785
+ // agent whose app failed to connect cannot answer anything, and leaving
1786
+ // a thread silent because its last speaker is offline is the bug this
1787
+ // whole change exists to remove. In-workspace, because a session key
1788
+ // carries a team: an agent whose app has moved workspaces still has its
1789
+ // old sessions there, and letting those contend for a thread it can no
1790
+ // longer receive a message in would leave nobody to answer.
1791
+ for (const candidate of connections) {
1792
+ if (candidate.teamId !== parts.team) {
1793
+ continue;
1794
+ }
1795
+ const routing = await gateway.sessionRouting(channelSessionKey({
1796
+ channel: 'slack',
1797
+ agentId: candidate.config.agentId,
1798
+ team: parts.team,
1799
+ conversation: parts.conversation,
1800
+ thread: parts.thread,
1801
+ }));
1802
+ if (!routing) {
1803
+ continue;
1804
+ }
1805
+ engaged.push({
1806
+ agentId: candidate.config.agentId,
1807
+ ...(routing.lastSpokeAt !== undefined ? { lastSpokeAt: routing.lastSpokeAt } : {}),
1808
+ });
1809
+ }
1810
+ const first = engaged[0];
1811
+ if (!first) {
1812
+ return undefined;
1813
+ }
1814
+ if (engaged.length === 1) {
1815
+ return first.agentId;
1816
+ }
1817
+ let latest;
1818
+ let tied = false;
1819
+ for (const entry of engaged) {
1820
+ if (entry.lastSpokeAt === undefined) {
1821
+ // In the thread, but has never said anything in it — mentioned and
1822
+ // still on its first turn, or a turn that produced no text. It
1823
+ // cannot be the one who spoke last, and it must not stop the agent
1824
+ // that did from answering.
1825
+ continue;
1826
+ }
1827
+ if (!latest || entry.lastSpokeAt > latest.lastSpokeAt) {
1828
+ latest = { agentId: entry.agentId, lastSpokeAt: entry.lastSpokeAt };
1829
+ tied = false;
1830
+ }
1831
+ else if (entry.lastSpokeAt === latest.lastSpokeAt) {
1832
+ tied = true;
1833
+ }
1834
+ }
1835
+ return tied ? undefined : latest?.agentId;
1836
+ };
1837
+ /**
1838
+ * `resolveFollowUpWinner`, resolved ONCE PER MESSAGE and shared by every
1839
+ * agent asking about that message.
1840
+ *
1841
+ * The sharing is the point. Each agent's own copy of the question is
1842
+ * several independent session reads, and turns finishing in between can
1843
+ * make two agents' reads disagree: both concluding they are the most
1844
+ * recent speaker, so one message is answered twice, or both concluding
1845
+ * they are not, so it is answered by nobody. One resolution cannot
1846
+ * disagree with itself, which is a stronger guarantee than arbitrating
1847
+ * between two that already have.
1848
+ *
1849
+ * Keyed on the message, so a later reply in the same thread asks again.
1850
+ * Bounded, and deliberately not deleted when it settles: an entry has to
1851
+ * outlive its own resolution, because an agent it beats may only reach
1852
+ * this after the winner has finished, and recomputing for that agent is
1853
+ * precisely the disagreement the memo exists to prevent.
1854
+ */
1855
+ const followUpWinner = (parts, ts) => {
1856
+ const key = `${threadKeyFor(parts)}:${ts}`;
1857
+ const existing = coldVerdicts.get(key);
1858
+ if (existing) {
1859
+ return existing.verdict;
1860
+ }
1861
+ const entry = { verdict: resolveFollowUpWinner(parts), settled: false };
1862
+ coldVerdicts.set(key, entry);
1863
+ void entry.verdict.then(() => {
1864
+ entry.settled = true;
1865
+ }, () => {
1866
+ entry.settled = true;
1867
+ });
1868
+ if (coldVerdicts.size > COLD_VERDICT_CAPACITY) {
1869
+ // The oldest SETTLED entry, never simply the oldest. Evicting one
1870
+ // still resolving is the one eviction that costs an answer rather
1871
+ // than a lookup: the agent it has not reached yet would start its own
1872
+ // resolution, which is the disagreement this memo exists to prevent.
1873
+ // With none settled there is nothing safe to drop, and the map runs
1874
+ // over its cap by however many resolutions are genuinely in flight.
1875
+ for (const [candidate, held] of coldVerdicts) {
1876
+ if (held.settled) {
1877
+ coldVerdicts.delete(candidate);
1878
+ break;
1879
+ }
1880
+ }
1881
+ }
1882
+ return entry.verdict;
1883
+ };
1884
+ /**
1885
+ * Everything about who a message is for that can be decided WITHOUT
1886
+ * AWAITING, run straight off the socket's own event emission — so for the
1887
+ * whole of it, this process is exactly as far through the conversation as
1888
+ * Slack is.
1889
+ *
1890
+ * That is the point of it being one synchronous block. The Socket Mode
1891
+ * client emits envelopes in order, but `ack()` resolves from a WebSocket
1892
+ * send callback, so two handlers that ack before deciding anything can
1893
+ * come back in either order: a follow-up could then overtake the mention
1894
+ * that opened its thread, find neither a record nor a session, and be
1895
+ * dropped for good. Nothing here awaits, so nothing can be overtaken —
1896
+ * the dedupe and the handover record included, which are the two pieces a
1897
+ * later message reads.
1898
+ *
1899
+ * `undefined` means the message is not this agent's. `settled: false`
1900
+ * means only the sessions can still decide it, which is the one case that
1901
+ * has to wait — and it is the cold case, where by definition no message
1902
+ * of this thread has been seen in this process.
1903
+ */
1904
+ const admit = (connection, args) => {
1356
1905
  const event = args.event;
1357
- if (!gateway || !event || !event.user || event.bot_id || event.subtype) {
1358
- return;
1906
+ if (!gatewayRef || !event || !event.user || event.bot_id || !isPersonSpeaking(event)) {
1907
+ return undefined;
1359
1908
  }
1360
- const isDm = event.channel_type === 'im';
1361
- if (event.type !== 'app_mention' && !(event.type === 'message' && isDm)) {
1362
- // Mention-only outside DMs.
1363
- return;
1909
+ if (event.type !== 'app_mention' && event.type !== 'message') {
1910
+ return undefined;
1364
1911
  }
1365
1912
  if (event.user === connection.botUserId) {
1366
- return;
1367
- }
1368
- const eventKey = `${connection.config.agentId}:${args.body?.event_id ?? args.envelope_id ?? `${event.channel}:${event.ts}`}`;
1369
- if (alreadySeen(eventKey)) {
1370
- return;
1913
+ return undefined;
1371
1914
  }
1915
+ const isDm = event.channel_type === 'im';
1372
1916
  const team = args.body?.team_id ?? connection.teamId;
1373
- const userId = event.user;
1374
1917
  // A top-level mention has no thread_ts; its own ts roots the reply
1375
1918
  // thread that becomes the conversation. DMs are one conversation per
1376
1919
  // peer, unkeyed by thread.
1377
1920
  const thread = isDm ? undefined : event.thread_ts ?? event.ts;
1378
- const sessionId = channelSessionKey({
1379
- channel: 'slack',
1380
- agentId: connection.config.agentId,
1921
+ // Threads only: a DM has one agent by construction.
1922
+ const threadKey = thread !== undefined && !isDm
1923
+ ? threadKeyFor({ team, conversation: event.channel, thread })
1924
+ : undefined;
1925
+ const text = event.text ?? '';
1926
+ const addressed = isDm || mentions(text, connection.botUserId);
1927
+ // Who this message hands the thread to — worked out by EVERY connection
1928
+ // that sees it, not only by the one being named, and recorded before any
1929
+ // of the rejections below. Each agent is its own Socket Mode connection,
1930
+ // so the message naming Bea reaches Ava's socket and Bea's socket
1931
+ // independently and in no fixed order; if only Bea recorded the
1932
+ // handover, Ava could take the next untagged reply while Bea's socket
1933
+ // was still behind, and answer a question that had just been handed
1934
+ // away. The message Ava must record is exactly the one Ava ignores.
1935
+ const named = agentNamedIn(text, team);
1936
+ if (threadKey && named) {
1937
+ rememberAddressee(threadKey, named, event.ts);
1938
+ }
1939
+ if (!addressed) {
1940
+ if (event.thread_ts === undefined) {
1941
+ // The room is not a conversation: a message that starts a thread,
1942
+ // or stands alone, is a follow-up to nothing.
1943
+ return undefined;
1944
+ }
1945
+ if (named) {
1946
+ // Somebody else was asked. Tagging an agent hands it the question,
1947
+ // and the agent that had it is no longer being asked — two answers
1948
+ // to one message is what a thread with a roster in it must never
1949
+ // produce.
1950
+ return undefined;
1951
+ }
1952
+ }
1953
+ // One Slack MESSAGE, not one delivery. An app subscribed to both
1954
+ // `app_mention` and `message.channels` is told about a mention twice —
1955
+ // two envelopes carrying two event ids — so keying this on the event
1956
+ // id would run the turn twice the moment thread follow-through was
1957
+ // switched on. `(channel, ts)` names the message itself, and a
1958
+ // redelivery (what the event id was here for) repeats both.
1959
+ //
1960
+ // After the handover record and not before it: a redelivery re-records
1961
+ // the same agent, which costs nothing, while deduping first would drop
1962
+ // the record whenever the other copy of a doubly-delivered mention is
1963
+ // the one a connection sees second.
1964
+ const eventKey = `${connection.config.agentId}:${event.channel}:${event.ts}`;
1965
+ if (alreadySeen(eventKey)) {
1966
+ return undefined;
1967
+ }
1968
+ const admission = {
1969
+ event,
1970
+ isDm,
1381
1971
  team,
1382
- conversation: event.channel,
1972
+ userId: event.user,
1973
+ sessionId: channelSessionKey({
1974
+ channel: 'slack',
1975
+ agentId: connection.config.agentId,
1976
+ team,
1977
+ conversation: event.channel,
1978
+ ...(thread ? { thread } : {}),
1979
+ }),
1383
1980
  ...(thread ? { thread } : {}),
1981
+ ...(threadKey ? { threadKey } : {}),
1982
+ settled: true,
1983
+ };
1984
+ if (addressed) {
1985
+ // A message that named this agent and others is recorded above under
1986
+ // whichever of them comes first in connection order — every agent
1987
+ // named answers it, and the thread it leaves behind has one holder
1988
+ // that all the sockets agree on, which is the only self-consistent
1989
+ // reading of a message that asked several.
1990
+ return admission;
1991
+ }
1992
+ // An untagged reply. The record is what this process has actually seen,
1993
+ // in the order it saw it — right even inside the window before a
1994
+ // handover reaches the store.
1995
+ const holder = threadKey === undefined ? undefined : holderAt(threadKey, event.ts);
1996
+ if (holder !== undefined && threadKey !== undefined) {
1997
+ if (holder !== connection.config.agentId) {
1998
+ // Standing down for the holder, whether or not the holder can still
1999
+ // hear it. An app removed from this channel stops receiving the
2000
+ // thread, and its untagged replies then reach nobody until somebody
2001
+ // names an agent — which is the same answer this adapter gives
2002
+ // whenever the agent being addressed cannot answer, and one mention
2003
+ // reopens the conversation. Doing better means per-channel
2004
+ // membership the adapter does not track, and would have to be
2005
+ // tracked in the durable path too, since a removed app is still a
2006
+ // live connection with the newest session there.
2007
+ return undefined;
2008
+ }
2009
+ rememberAddressee(threadKey, connection.config.agentId, event.ts);
2010
+ return admission;
2011
+ }
2012
+ // Nothing seen for this thread since startup: only the sessions can
2013
+ // answer, and that answer has to be awaited.
2014
+ return { ...admission, settled: false };
2015
+ };
2016
+ const handleInbound = async (connection, args) => {
2017
+ // Decided before the ack, never after it — see `admit`. That block is
2018
+ // map and string work measured in microseconds, against an ack window
2019
+ // Slack measures in seconds, and the envelope is acked below whatever
2020
+ // the decision was.
2021
+ const admitted = admit(connection, args);
2022
+ // Acked without awaiting it. The ack still leaves as promptly as it ever
2023
+ // did — Slack redelivers unacked envelopes, and a turn outlives the ack
2024
+ // window many times over — but nothing here needs its send callback, and
2025
+ // awaiting one was the single await between receiving a message and
2026
+ // taking its place in the queue below. Two handlers waiting on their
2027
+ // acks can come back in either order; two handlers that never wait
2028
+ // cannot. A failed ack costs a redelivery, which the dedupe absorbs.
2029
+ void Promise.resolve(args.ack()).catch((error) => {
2030
+ warn(`slack: could not ack an envelope: ${error instanceof Error ? error.message : String(error)}`);
1384
2031
  });
2032
+ if (!admitted) {
2033
+ return;
2034
+ }
2035
+ // Re-read rather than carried through the admission: `stop()` can clear
2036
+ // it while the ack above is in flight, and a dispatch into a gateway
2037
+ // this adapter has let go of is not one to start.
2038
+ const gateway = gatewayRef;
2039
+ if (!gateway) {
2040
+ return;
2041
+ }
2042
+ const { event, isDm, team, userId, thread, sessionId, threadKey } = admitted;
1385
2043
  // Everything up to (and including) the dispatch call is serialized per
1386
2044
  // session in Slack receipt order: the user lookups and placeholder
1387
2045
  // post below await network I/O, and without this chain a later message
1388
2046
  // could reach gateway.dispatch first — processing the conversation in
1389
- // reverse order in durable history.
2047
+ // reverse order in durable history. The chain is claimed here,
2048
+ // synchronously, so a message's place in it is fixed by when Slack
2049
+ // delivered it and not by how long anything it does takes.
1390
2050
  const previousIntake = intakeChains.get(sessionId) ?? Promise.resolve();
1391
2051
  const intake = previousIntake.then(async () => {
2052
+ // The durable half of addressing runs INSIDE the chain, for the same
2053
+ // reason: it awaits, and a message that had to ask the sessions must
2054
+ // not lose its place to one that did not. A message that turns out
2055
+ // not to be this agent's leaves an empty link behind, which is all it
2056
+ // should cost.
2057
+ if (!admitted.settled) {
2058
+ // One verdict for this message, shared with whichever other agents
2059
+ // are asking about it — see `followUpWinner`.
2060
+ const winner = await followUpWinner({ team, conversation: event.channel, thread: event.thread_ts ?? '' }, event.ts);
2061
+ if (winner !== connection.config.agentId) {
2062
+ return undefined;
2063
+ }
2064
+ // Answered from the store rather than from memory: hold it, so the
2065
+ // rest of the thread is ordered by receipt like any other.
2066
+ if (threadKey) {
2067
+ rememberAddressee(threadKey, connection.config.agentId, event.ts);
2068
+ }
2069
+ }
1392
2070
  const cleaned = await humanizeMentions(connection, event.text ?? '');
1393
2071
  if (cleaned.length === 0) {
1394
2072
  return undefined;
@@ -1396,7 +2074,8 @@ export const createSlackChannelAdapter = (options) => {
1396
2074
  const author = await displayNameFor(connection, userId);
1397
2075
  // In shared channels the model should know who is speaking; a DM is
1398
2076
  // unambiguous.
1399
- const userMessage = isDm ? cleaned : `${author}: ${cleaned}`;
2077
+ const spoken = isDm ? cleaned : `${author}: ${cleaned}`;
2078
+ const userMessage = `${spoken}${attachmentNote(event.files)}`;
1400
2079
  const renderer = new ReplyRenderer(connection.web, event.channel, thread, editIntervalMs, warn);
1401
2080
  try {
1402
2081
  await renderer.open();
@@ -1424,6 +2103,11 @@ export const createSlackChannelAdapter = (options) => {
1424
2103
  // Carried so a mid-turn approval question is asked in the thread
1425
2104
  // the turn belongs to rather than at the top of a busy channel.
1426
2105
  ...(thread ? { slackThread: thread } : {}),
2106
+ // This turn's sender, judged against the operator's list — per
2107
+ // message, never remembered from the first one in the thread. A
2108
+ // DM proves nothing about who is typing, so a DM from an unlisted
2109
+ // member is `unknown` exactly as a channel mention would be.
2110
+ [SENDER_TRUST_METADATA_KEY]: (connection.config.principals ?? []).includes(userId) ? 'user' : 'unknown',
1427
2111
  },
1428
2112
  });
1429
2113
  return { renderer, turn };
@@ -1569,6 +2253,14 @@ export const createSlackChannelAdapter = (options) => {
1569
2253
  }
1570
2254
  });
1571
2255
  const known = new Set(gateway.agents().map((agent) => agent.id));
2256
+ // Two passes, because who a message names has to be answerable from
2257
+ // the first event onwards. Identities are learned for every app
2258
+ // FIRST; only then does any socket start delivering. One pass would
2259
+ // leave the agents at the front of the roster taking messages while
2260
+ // the ones behind them were still authenticating, and a mention of an
2261
+ // agent not yet known reads as an untagged reply — which the agent
2262
+ // holding that thread would then answer.
2263
+ const authenticated = [];
1572
2264
  for (const config of options.agents) {
1573
2265
  if (!known.has(config.agentId)) {
1574
2266
  warn(`slack: no roster agent with id ${config.agentId}; skipping its Slack app`);
@@ -1577,14 +2269,27 @@ export const createSlackChannelAdapter = (options) => {
1577
2269
  try {
1578
2270
  const web = createWeb(config.botToken);
1579
2271
  const auth = await web.auth.test();
1580
- const socket = createSocket(config.appToken);
1581
- const connection = {
2272
+ const botUserId = auth.user_id ?? '';
2273
+ const teamId = auth.team_id ?? '';
2274
+ // Recorded before the socket is even built: an app that never
2275
+ // comes up must still be recognizable when somebody names it.
2276
+ botIdentities.set(config.agentId, { botUserId, teamId });
2277
+ authenticated.push({
1582
2278
  config,
1583
2279
  web,
1584
- socket,
1585
- botUserId: auth.user_id ?? '',
1586
- teamId: auth.team_id ?? '',
1587
- };
2280
+ socket: createSocket(config.appToken),
2281
+ botUserId,
2282
+ teamId,
2283
+ });
2284
+ }
2285
+ catch (error) {
2286
+ // One broken app must not take the rest of the fleet down.
2287
+ warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
2288
+ }
2289
+ }
2290
+ for (const connection of authenticated) {
2291
+ const { config, socket } = connection;
2292
+ try {
1588
2293
  const onEvent = (args) => {
1589
2294
  const handled = handleInbound(connection, args).catch((error) => {
1590
2295
  warn(`slack event handling failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -1604,7 +2309,8 @@ export const createSlackChannelAdapter = (options) => {
1604
2309
  log(`slack: ${config.agentId} connected (bot ${connection.botUserId} in team ${connection.teamId})`);
1605
2310
  }
1606
2311
  catch (error) {
1607
- // One broken app must not take the rest of the fleet down.
2312
+ // Authenticated but not serving: it answers nothing, and stays
2313
+ // recognizable so nobody answers in its place.
1608
2314
  warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
1609
2315
  }
1610
2316
  }
@@ -1636,6 +2342,9 @@ export const createSlackChannelAdapter = (options) => {
1636
2342
  gatewayRef = undefined;
1637
2343
  connections.length = 0;
1638
2344
  approvalPosts.clear();
2345
+ threadAddressee.clear();
2346
+ coldVerdicts.clear();
2347
+ botIdentities.clear();
1639
2348
  rendering.clear();
1640
2349
  resolvedWhileRendering.clear();
1641
2350
  },