@stratusagent/channel-slack 0.11.8 → 0.11.10

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,7 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { lstat, open, readFile } from 'node:fs/promises';
3
3
  import { createRequire } from 'node:module';
4
4
  import path from 'node:path';
5
- import { filePathsOf, isLatestTurn, latestTurnReply, MAX_IDEMPOTENCY_KEY_LENGTH, turnFailureFor, turnFilesFor, turnReplyFor, CONVERSATION_METADATA_KEY, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, escapeControlCharacters, } from '@stratusagent/core';
5
+ import { filePathsOf, isLatestTurn, latestTurnReply, MAX_IDEMPOTENCY_KEY_LENGTH, turnFailureFor, turnFilesFor, turnReplyFor, CONVERSATION_METADATA_KEY, SENDER_TRUST_METADATA_KEY, sessionWriteTrust, imageDimensions, isImageAttachmentMediaType, IMAGE_ATTACHMENT_MAX_BYTES, IMAGE_ATTACHMENT_MAX_DIMENSION, IMAGE_ATTACHMENTS_MAX_TOTAL_BYTES, escapeControlCharacters, leastTrusted, sessionTrustOf, SESSION_TRUST_METADATA_KEY, } from '@stratusagent/core';
6
6
  import { admitsSender, channelSessionKey, senderTrustFor, } from '@stratusagent/channels';
7
7
  import { codeRunsOf, toSlackMrkdwn } from "./mrkdwn.js";
8
8
  /**
@@ -83,6 +83,23 @@ const ATTENTION_MS = 15 * 60 * 1000;
83
83
  const DEFAULT_FILE_DOWNLOAD_TIMEOUT_MS = 30_000;
84
84
  /** What a turn that produced no text puts in its message, wherever it is posted from. */
85
85
  const NO_REPLY_TEXT = '(no reply)';
86
+ /**
87
+ * How much of a thread a late arrival is handed: the parent, which is
88
+ * usually the question, and the newest messages before the mention. A
89
+ * longer thread is cut in the middle and says so, rather than flooding the
90
+ * opening turn or dropping the message that started it.
91
+ */
92
+ const EARLIER_MESSAGE_LIMIT = 40;
93
+ /** The most pages of 200 read from a thread for that: 4,000 replies. */
94
+ const EARLIER_THREAD_PAGES = 20;
95
+ /**
96
+ * At the top of a channel, how many of its most recent messages a mention
97
+ * opens with, and how many are read to find them.
98
+ */
99
+ const EARLIER_CHANNEL_LIMIT = 20;
100
+ const EARLIER_CHANNEL_READ = 100;
101
+ /** The most pages read to find them: 1,000 messages. */
102
+ const EARLIER_CHANNEL_PAGES = 10;
86
103
  // Lazy (and CJS-interoperable) so tests with injected fakes never load the
87
104
  // real SDK stack.
88
105
  const requireModule = createRequire(import.meta.url);
@@ -90,6 +107,37 @@ const defaultSocketClient = (appToken) => {
90
107
  const { SocketModeClient } = requireModule('@slack/socket-mode');
91
108
  return new SocketModeClient({ appToken });
92
109
  };
110
+ /**
111
+ * Scopes the manifest asks for. An app created before they shipped has
112
+ * fewer, and some features silently break — threads are answered without
113
+ * their context, images arrive as names. Checked once at connect, so the
114
+ * operator finds out at start, not at the first broken thread.
115
+ *
116
+ * Events (`message.channels`, `message.groups`, …) cannot be read from a
117
+ * bot token, so the warning says they are needed too.
118
+ */
119
+ const REQUIRED_SCOPES = [
120
+ 'app_mentions:read',
121
+ 'channels:history', 'channels:read',
122
+ 'chat:write',
123
+ 'files:read', 'files:write',
124
+ 'groups:history', 'groups:read',
125
+ 'im:history', 'im:read', 'im:write',
126
+ 'mpim:history', 'mpim:read',
127
+ 'users:read',
128
+ ];
129
+ const checkBotScopes = (agentId, granted, warn) => {
130
+ const have = new Set(granted);
131
+ const missing = REQUIRED_SCOPES.filter((scope) => !have.has(scope));
132
+ if (missing.length === 0) {
133
+ return;
134
+ }
135
+ warn(`slack: ${agentId}'s app is missing bot scopes: ${missing.join(', ')}. `
136
+ + 'Add them under OAuth & Permissions, add the matching message.* events '
137
+ + 'under Event Subscriptions, and reinstall the app to the workspace. '
138
+ + 'Without them, some features (thread follow-through, image viewing, '
139
+ + 'channel history) will silently fail.');
140
+ };
93
141
  const defaultWebClient = (botToken) => {
94
142
  const { WebClient } = requireModule('@slack/web-api');
95
143
  return new WebClient(botToken);
@@ -254,6 +302,28 @@ class ReplyRenderer {
254
302
  */
255
303
  turnId = randomUUID();
256
304
  buffer = '';
305
+ /**
306
+ * In `final` mode, what the model has said since its last tool call. A
307
+ * turn that writes "On it, checking the PRs" and then works for an hour
308
+ * should not leave the thread showing only a loading status: text the
309
+ * model wrote before calling a tool is posted when the call starts, as a
310
+ * message of its own, and the finished reply follows it.
311
+ */
312
+ interim = '';
313
+ /** The last interim message posted, so a reply that repeats it is not posted twice. */
314
+ lastInterim;
315
+ /** Whether any interim message landed — something said, whatever the reply's fate. */
316
+ interimPosted = false;
317
+ /** Every interim message that landed whole, in order: what a hosted-loop reply is stripped of. */
318
+ interimSaid = [];
319
+ /**
320
+ * Told of each interim message as it is queued — before the reply, and
321
+ * before anything typed while its tool runs — with what of it landed,
322
+ * once that is known, so the thread's other agents hear it in its place.
323
+ */
324
+ onInterim;
325
+ /** The lowest trust this turn's session was lowered to while it ran (`session.tainted`). */
326
+ taintedTo;
257
327
  turnBreakPending = false;
258
328
  toolLine;
259
329
  ref;
@@ -376,6 +446,7 @@ class ReplyRenderer {
376
446
  this.statusWarned = options.statusWarned ?? { value: false };
377
447
  this.after = options.after ?? Promise.resolve();
378
448
  this.dmWith = options.dmWith;
449
+ this.onInterim = options.onInterim;
379
450
  }
380
451
  /**
381
452
  * Show Slack's loading status for this turn, and keep it up until the
@@ -466,6 +537,7 @@ class ReplyRenderer {
466
537
  this.pendingEdit = undefined;
467
538
  }
468
539
  this.buffer = '';
540
+ this.interim = '';
469
541
  this.toolLine = undefined;
470
542
  this.runningTool = undefined;
471
543
  this.turnBreakPending = false;
@@ -493,6 +565,12 @@ class ReplyRenderer {
493
565
  if (this.finalized) {
494
566
  return;
495
567
  }
568
+ if (event.type === 'session.tainted') {
569
+ if (ofThisTurn && this.turnStarted) {
570
+ this.taintedTo = this.taintedTo === undefined ? event.trust : leastTrusted(this.taintedTo, event.trust);
571
+ }
572
+ return;
573
+ }
496
574
  if (event.type === 'provider.delta' && event.delta.type === 'text') {
497
575
  // Text from a new provider turn (after tools ran) must not fuse
498
576
  // with the previous turn's — "I'll check.The result is…" reads as
@@ -502,6 +580,7 @@ class ReplyRenderer {
502
580
  }
503
581
  this.turnBreakPending = false;
504
582
  this.buffer += event.delta.text;
583
+ this.interim += event.delta.text;
505
584
  this.scheduleEdit();
506
585
  return;
507
586
  }
@@ -510,12 +589,22 @@ class ReplyRenderer {
510
589
  // mid-stream failure): discard everything streamed so far so two
511
590
  // attempts never fuse into one message.
512
591
  this.buffer = '';
592
+ this.interim = '';
513
593
  this.turnBreakPending = false;
514
594
  this.scheduleEdit();
515
595
  return;
516
596
  }
517
597
  if (event.type === 'provider.response') {
518
598
  this.turnBreakPending = true;
599
+ // A provider that answers in one piece streams no deltas: the
600
+ // response is the only place its words before a tool call appear.
601
+ // One that streamed them has them already, and is not given them twice.
602
+ if (this.interim.trim().length === 0) {
603
+ this.interim = event.parts
604
+ .filter((part) => part.type === 'text')
605
+ .map((part) => part.text)
606
+ .join('');
607
+ }
519
608
  return;
520
609
  }
521
610
  if (event.type === 'tool.called') {
@@ -527,6 +616,7 @@ class ReplyRenderer {
527
616
  this.turnBreakPending = true;
528
617
  this.toolLine = `⚙ ${event.call.toolName}…`;
529
618
  this.runningTool = event.call.toolName;
619
+ this.flushInterim(ofThisTurn);
530
620
  this.scheduleEdit();
531
621
  return;
532
622
  }
@@ -538,12 +628,18 @@ class ReplyRenderer {
538
628
  this.toolLine = undefined;
539
629
  this.runningTool = undefined;
540
630
  this.turnBreakPending = true;
631
+ // Words written before a call that was refused were still said
632
+ // before a tool, and must not run into the next response's.
633
+ this.flushInterim(ofThisTurn);
541
634
  this.scheduleEdit();
542
635
  return;
543
636
  }
544
637
  if (event.type === 'tool.completed') {
545
638
  this.toolLine = undefined;
546
639
  this.runningTool = undefined;
640
+ // Settled here too for a call rejected before it ran, which never
641
+ // reached `tool.called`; after one that did, this is empty.
642
+ this.flushInterim(ofThisTurn);
547
643
  // Also a boundary, and not only for symmetry: a call rejected before
548
644
  // execution settles as tool.completed without ever having emitted
549
645
  // tool.called, so this is the only mark that attempt leaves.
@@ -623,6 +719,98 @@ class ReplyRenderer {
623
719
  })
624
720
  .catch((error) => this.warn(`files.uploadV2 failed for ${filePath}: ${error instanceof Error ? error.message : String(error)}`));
625
721
  }
722
+ /**
723
+ * Post what the model said before the tool it is now calling, in `final`
724
+ * mode only: a streaming turn already shows it in its placeholder, and a
725
+ * turn nobody asked for must not speak before it decides to. Behind the
726
+ * turn ahead and any handover, on the upload chain, so interim text,
727
+ * files, and the reply land in the order they happened.
728
+ */
729
+ flushInterim(ofThisTurn) {
730
+ const text = this.interim.trim();
731
+ this.interim = '';
732
+ if (this.streaming || this.lazy || !this.turnStarted || !ofThisTurn || text.length === 0) {
733
+ return;
734
+ }
735
+ const handover = this.handover;
736
+ const after = this.after;
737
+ let heard = '';
738
+ let settleLanded;
739
+ const landedNow = new Promise((resolve) => {
740
+ settleLanded = resolve;
741
+ });
742
+ this.onInterim?.(() => heard, landedNow);
743
+ this.uploadChain = this.uploadChain
744
+ .then(() => handover)
745
+ .then(() => after)
746
+ .then(async () => {
747
+ const landed = [];
748
+ try {
749
+ for (const chunk of messageChunks(text)) {
750
+ const posted = await this.web.chat.postMessage({
751
+ channel: this.channel,
752
+ text: chunk,
753
+ ...(this.threadTs ? { thread_ts: this.threadTs } : {}),
754
+ });
755
+ this.placeText(posted.ts);
756
+ landed.push(chunk);
757
+ }
758
+ }
759
+ finally {
760
+ // Whatever landed was said, whole or not: it counts as speaking,
761
+ // and the others hear it. Only a message that landed whole is
762
+ // stripped from a hosted-loop reply; part of one is left in the
763
+ // reply, where repeating a few lines beats losing the rest.
764
+ if (landed.length > 0) {
765
+ this.interimPosted = true;
766
+ heard = landed.length === messageChunks(text).length ? text : landed.join('\n\n');
767
+ }
768
+ settleLanded(landed.length > 0);
769
+ }
770
+ this.lastInterim = text;
771
+ this.interimSaid.push(text);
772
+ // A post of the app's in this thread takes the status down while
773
+ // the turn is still working; put it back.
774
+ this.refreshLoading();
775
+ })
776
+ .catch((error) => this.warn(`chat.postMessage failed for an interim message: ${error instanceof Error ? error.message : String(error)}`))
777
+ // Settled whatever happened above, or the other agents' intake would
778
+ // wait on it for good. A second settle is a no-op.
779
+ .finally(() => settleLanded(false));
780
+ }
781
+ /**
782
+ * Everything this turn said in the thread, for its other agents to hear:
783
+ * the interim messages that landed, then the reply unless it only
784
+ * repeats the last of them. Read once the turn's posts have landed.
785
+ */
786
+ spokenText(reply) {
787
+ return this.unsaid(reply);
788
+ }
789
+ /**
790
+ * The part of a reply not already posted as interim messages. A provider
791
+ * that hosts its own loop (codex) returns every message of the turn
792
+ * joined into one reply, so the interim messages are stripped from its
793
+ * front in order; one whose last words came before its final tool call
794
+ * returns just those words, which were posted already.
795
+ */
796
+ unsaid(reply) {
797
+ let rest = reply.trim();
798
+ // Whole messages only, at the separator the codex provider joins them
799
+ // with: a reply of its own that merely begins with the same words
800
+ // ("Sure" then "Sure, the build passes.") is not cut.
801
+ for (const said of this.interimSaid) {
802
+ if (rest === said) {
803
+ rest = '';
804
+ }
805
+ else if (rest.startsWith(`${said}\n\n`)) {
806
+ rest = rest.slice(said.length + 2).trimStart();
807
+ }
808
+ else {
809
+ break;
810
+ }
811
+ }
812
+ return rest === this.lastInterim ? '' : rest;
813
+ }
626
814
  currentText() {
627
815
  const parts = [this.buffer.trim(), this.toolLine].filter((part) => Boolean(part && part.length > 0));
628
816
  return parts.length > 0 ? parts.join('\n\n') : PLACEHOLDER_TEXT;
@@ -755,6 +943,22 @@ class ReplyRenderer {
755
943
  await this.uploadChain;
756
944
  return this.outcome(false, this.uploaded);
757
945
  }
946
+ // Interim messages land before the reply is judged: one that already
947
+ // said what the reply says (the model's last words came before a
948
+ // tool call) is not posted again, and a turn that said things along
949
+ // the way is not followed by `(no reply)`.
950
+ await this.uploadChain;
951
+ if (this.interimPosted && !this.ref) {
952
+ reply = this.unsaid(reply);
953
+ }
954
+ if (!this.ref && this.interimPosted && reply.trim().length === 0) {
955
+ // Nothing posts after this to take the status down, so it is cleared.
956
+ if (hadStatus) {
957
+ this.setStatus('');
958
+ await this.statusChain;
959
+ }
960
+ return this.outcome(true, true);
961
+ }
758
962
  const text = reply.trim().length > 0 ? reply : NO_REPLY_TEXT;
759
963
  const chunks = messageChunks(text);
760
964
  // No placeholder — a handover could not open a fresh one — and the
@@ -1031,6 +1235,31 @@ const pastCodePoint = (text, index) => {
1031
1235
  const code = text.charCodeAt(index - 1);
1032
1236
  return code >= 0xd800 && code <= 0xdbff ? index + 1 : index;
1033
1237
  };
1238
+ /**
1239
+ * `cut` moved in front of any Slack markup it would land inside — a link
1240
+ * `<https://…>` or `<https://…|label>`, a mention `<@U…>` — when that still
1241
+ * leaves half a message. Cut through, the first message ends on an
1242
+ * unterminated `<https://…` and the next starts with the rest of the
1243
+ * address and a stray `>`, and neither half is a link. Markup is one line,
1244
+ * so only the cut's own line is looked at.
1245
+ */
1246
+ const outsideSlackMarkup = (text, cut) => {
1247
+ const open = text.lastIndexOf('<', cut - 1);
1248
+ if (open <= SLACK_MAX_MESSAGE_CHARS / 2) {
1249
+ return cut;
1250
+ }
1251
+ const closedBefore = text.lastIndexOf('>', cut - 1);
1252
+ const lineBreak = text.lastIndexOf('\n', cut - 1);
1253
+ if (closedBefore > open || lineBreak > open) {
1254
+ return cut;
1255
+ }
1256
+ const close = text.indexOf('>', cut);
1257
+ const nextLine = text.indexOf('\n', cut);
1258
+ if (close === -1 || (nextLine !== -1 && nextLine < close)) {
1259
+ return cut;
1260
+ }
1261
+ return open;
1262
+ };
1034
1263
  const truncateForSlack = (text) => text.length <= SLACK_MAX_MESSAGE_CHARS
1035
1264
  ? text
1036
1265
  : `${text.slice(0, safeCutIndex(text, SLACK_MAX_MESSAGE_CHARS - 1))}…`;
@@ -1087,8 +1316,9 @@ const splitForSlack = (text) => {
1087
1316
  // point, or the step that guarantees progress lands inside an emoji.
1088
1317
  const fence = run !== undefined && run.closer.startsWith('\n');
1089
1318
  if (run === undefined || !fence || reopen === undefined || reopen.length + run.closer.length > SLACK_MAX_MESSAGE_CHARS / 4 || floor + 2 > budget) {
1090
- chunks.push(rest.slice(0, cut));
1091
- rest = rest.slice(cut).replace(/^\n+/, '');
1319
+ const at = run === undefined ? outsideSlackMarkup(rest, cut) : cut;
1320
+ chunks.push(rest.slice(0, at));
1321
+ rest = rest.slice(at).replace(/^\n+/, '');
1092
1322
  continue;
1093
1323
  }
1094
1324
  const inner = rest.lastIndexOf('\n', budget - 1);
@@ -1721,6 +1951,8 @@ const credentialModal = (post, requestId) => ({
1721
1951
  * those were in it receives only `app_mention` and behaves as it always
1722
1952
  * did, which makes the workspace's own grant the switch.
1723
1953
  */
1954
+ /** Uncached author names one `message.read` looks up; the rest keep their id. */
1955
+ const READ_NAME_LOOKUPS = 10;
1724
1956
  export const createSlackChannelAdapter = (options) => {
1725
1957
  const log = options.log ?? (() => { });
1726
1958
  const warn = options.warn ?? (() => { });
@@ -1934,6 +2166,205 @@ export const createSlackChannelAdapter = (options) => {
1934
2166
  }
1935
2167
  return boundedDisplayName(await displayNameFor(connection, userId));
1936
2168
  };
2169
+ /**
2170
+ * What was said before an agent was mentioned, for the dispatch that
2171
+ * creates its session (`earlier` on the gateway's dispatch). Without it
2172
+ * an agent named partway through a conversation holds one message —
2173
+ * "can you do this?" — and nothing it refers to. In a thread that is the
2174
+ * thread so far: its parent and the newest messages before the mention.
2175
+ * At the top of a channel it is the channel's most recent messages,
2176
+ * which is where a channel-per-client or channel-per-project keeps the
2177
+ * context a mention leans on.
2178
+ *
2179
+ * Each message keeps its own speaker and its own trust, so the session
2180
+ * starts at the least trusted of everyone in it: a principal's mention
2181
+ * cannot carry a stranger's earlier words in under the principal's
2182
+ * label. Under `admit: 'principals'` a sender the operator did not list
2183
+ * gets no place here, as they get none anywhere else. A bot's message —
2184
+ * another agent, an integration — is `unknown`: nothing here can say
2185
+ * what its words were built from. Messages that will not be shown are
2186
+ * dropped before the cap is applied, so they never crowd out ones that
2187
+ * would.
2188
+ *
2189
+ * A read that fails warns and returns nothing: the turn still runs, on
2190
+ * the one message, as it did before this.
2191
+ */
2192
+ const earlierContext = async (connection, channel, thread, before, senderTrust) => {
2193
+ const usable = (message) => {
2194
+ if (message.ts === undefined || !(Number(message.ts) < Number(before))) {
2195
+ return false;
2196
+ }
2197
+ // The agent's own mention is stripped when the message is shown, so
2198
+ // a bare ping is as empty as no text, and takes no slot.
2199
+ const text = (message.text ?? '').replaceAll(`<@${connection.botUserId}>`, '').trim();
2200
+ if (text.length === 0 && (message.files ?? []).length === 0) {
2201
+ return false;
2202
+ }
2203
+ if (message.bot_id !== undefined || message.subtype === 'bot_message') {
2204
+ // Under `admit: 'principals'` an unlisted integration is as kept out
2205
+ // as an unlisted person. This fleet's own agents are not: they are
2206
+ // the conversation the mention joins — an authenticated peer whose
2207
+ // socket failed to start included, since what it said is still its.
2208
+ return admitsSender(connection.config, message.user ?? '')
2209
+ || (message.user !== undefined && [...botIdentities.values()].some((peer) => peer.teamId === connection.teamId && peer.botUserId === message.user));
2210
+ }
2211
+ return message.user !== undefined
2212
+ && isPersonSpeaking({ type: 'message', ts: message.ts, channel, ...(message.subtype ? { subtype: message.subtype } : {}) })
2213
+ && admitsSender(connection.config, message.user);
2214
+ };
2215
+ let kept = [];
2216
+ let omitted = 0;
2217
+ let more = false;
2218
+ // The channel read stopped at its page bound short of enough messages.
2219
+ let historyCut = false;
2220
+ let firstOfTail = 0;
2221
+ try {
2222
+ if (thread !== undefined) {
2223
+ const replies = connection.web.conversations.replies;
2224
+ if (!replies) {
2225
+ return [];
2226
+ }
2227
+ // Slack pages a thread oldest first, so the whole thread is walked
2228
+ // and only a bounded tail is held: the newest messages are the
2229
+ // last ones read.
2230
+ let parent;
2231
+ const tail = [];
2232
+ let cursor;
2233
+ let pages = 0;
2234
+ do {
2235
+ const page = await replies.call(connection.web.conversations, {
2236
+ channel,
2237
+ ts: thread,
2238
+ latest: before,
2239
+ inclusive: false,
2240
+ limit: 200,
2241
+ ...(cursor ? { cursor } : {}),
2242
+ });
2243
+ pages += 1;
2244
+ for (const message of page.messages ?? []) {
2245
+ if (!usable(message)) {
2246
+ continue;
2247
+ }
2248
+ if (message.ts === thread) {
2249
+ parent = message;
2250
+ continue;
2251
+ }
2252
+ tail.push(message);
2253
+ if (tail.length > EARLIER_MESSAGE_LIMIT) {
2254
+ tail.shift();
2255
+ omitted += 1;
2256
+ }
2257
+ }
2258
+ // Slack's cursor is the signal, not `has_more`: a short page can
2259
+ // still have more behind it.
2260
+ cursor = page.response_metadata?.next_cursor || undefined;
2261
+ } while (cursor && pages < EARLIER_THREAD_PAGES);
2262
+ more = cursor !== undefined;
2263
+ if (more) {
2264
+ // Past the read bound the newest replies were never reached, and a
2265
+ // slice from the middle must not pass for them: the parent alone,
2266
+ // and the note says the rest was not read.
2267
+ omitted += tail.length;
2268
+ tail.length = 0;
2269
+ }
2270
+ // The parent takes a slot only when it is shown.
2271
+ while (tail.length > (parent !== undefined ? EARLIER_MESSAGE_LIMIT - 1 : EARLIER_MESSAGE_LIMIT)) {
2272
+ tail.shift();
2273
+ omitted += 1;
2274
+ }
2275
+ kept = parent !== undefined ? [parent, ...tail] : tail;
2276
+ firstOfTail = parent !== undefined ? 1 : 0;
2277
+ }
2278
+ else {
2279
+ const history = connection.web.conversations.history;
2280
+ if (!history) {
2281
+ return [];
2282
+ }
2283
+ // Newest first, paged until enough usable messages are found (joins
2284
+ // and bots' chatter do not count against the limit), then put back
2285
+ // in the order they were said.
2286
+ const recent = [];
2287
+ let cursor;
2288
+ let pages = 0;
2289
+ do {
2290
+ const page = await history.call(connection.web.conversations, {
2291
+ channel,
2292
+ latest: before,
2293
+ inclusive: false,
2294
+ limit: EARLIER_CHANNEL_READ,
2295
+ ...(cursor ? { cursor } : {}),
2296
+ });
2297
+ pages += 1;
2298
+ recent.push(...(page.messages ?? []).filter(usable));
2299
+ cursor = page.response_metadata?.next_cursor || undefined;
2300
+ } while (cursor && recent.length < EARLIER_CHANNEL_LIMIT && pages < EARLIER_CHANNEL_PAGES);
2301
+ historyCut = cursor !== undefined && recent.length < EARLIER_CHANNEL_LIMIT;
2302
+ kept = recent.slice(0, EARLIER_CHANNEL_LIMIT).reverse();
2303
+ }
2304
+ }
2305
+ catch (error) {
2306
+ warn(`slack: ${connection.config.agentId} could not read the earlier messages in ${channel}, so it sees only the message that named it: `
2307
+ + `${error instanceof Error ? error.message : String(error)} (the history scopes are channels:history, groups:history, and mpim:history)`);
2308
+ return [];
2309
+ }
2310
+ const entries = [];
2311
+ for (const [index, message] of kept.entries()) {
2312
+ const bot = message.bot_id !== undefined || message.subtype === 'bot_message';
2313
+ const text = await humanizeMentions(connection, message.text ?? '');
2314
+ const files = message.files ?? [];
2315
+ const author = bot
2316
+ ? message.user === connection.botUserId
2317
+ ? 'you'
2318
+ : boundedDisplayName(message.bot_profile?.name ?? message.username ?? 'a bot')
2319
+ : await authorFor(connection, message.user);
2320
+ const reasons = new Map(files.map((file) => [file, 'not-opened']));
2321
+ let note = '';
2322
+ if (thread === undefined && index === 0) {
2323
+ note = historyCut
2324
+ ? `[The ${kept.length} messages found in this channel's recent history before you were mentioned, oldest first. Older history was not read; ask for what you need.]\n`
2325
+ : `[The ${kept.length} most recent messages in this channel before you were mentioned, oldest first:]\n`;
2326
+ }
2327
+ else if (thread !== undefined && index === firstOfTail && omitted > 0 && !more) {
2328
+ note = `[${omitted} earlier messages in this thread are not shown.]\n`;
2329
+ }
2330
+ entries.push({
2331
+ message: `${note}${author}: ${text}${attachmentNote(files, reasons)}`,
2332
+ metadata: {
2333
+ channel: 'slack',
2334
+ slackChannel: channel,
2335
+ ...(thread !== undefined ? { slackThread: thread } : {}),
2336
+ ...(message.user !== undefined ? { slackUser: message.user } : {}),
2337
+ [SENDER_TRUST_METADATA_KEY]: bot ? 'unknown' : senderTrustFor(connection.config, message.user),
2338
+ },
2339
+ });
2340
+ }
2341
+ if (historyCut && entries.length === 0) {
2342
+ entries.push({
2343
+ message: '[None of this channel\'s recent history before you were mentioned could be shown, and older history was not read. Ask for what you need.]',
2344
+ // The adapter's own words, nobody else's: they carry the trust of
2345
+ // the message they arrive with, and lower nothing.
2346
+ metadata: { channel: 'slack', slackChannel: channel, [SENDER_TRUST_METADATA_KEY]: senderTrust },
2347
+ });
2348
+ }
2349
+ if (more) {
2350
+ // With no first message to show (unusable, or from someone the room
2351
+ // does not admit), the notice stands alone: an empty backfill would
2352
+ // read as a thread with nothing before the mention. It is the
2353
+ // adapter's own words, so it carries the trust of the message it
2354
+ // arrives with and lowers nothing.
2355
+ const notice = '[This thread is too long to read here: the replies after its first message are not shown. Ask for what you need.]';
2356
+ if (entries.length > 0) {
2357
+ entries[entries.length - 1].message += `\n${notice}`;
2358
+ }
2359
+ else {
2360
+ entries.push({
2361
+ message: notice,
2362
+ metadata: { channel: 'slack', slackChannel: channel, ...(thread !== undefined ? { slackThread: thread } : {}), [SENDER_TRUST_METADATA_KEY]: senderTrust },
2363
+ });
2364
+ }
2365
+ }
2366
+ return entries;
2367
+ };
1937
2368
  /**
1938
2369
  * What kind of room a conversation is and how many are in it, for the
1939
2370
  * turn's `conversation` metadata: an agent that did not know told
@@ -1980,6 +2411,7 @@ export const createSlackChannelAdapter = (options) => {
1980
2411
  kind,
1981
2412
  ...(kind !== 'direct' && typeof info.num_members === 'number' ? { members: info.num_members } : {}),
1982
2413
  ...(kind !== 'direct' && (info.is_ext_shared === true || info.is_org_shared === true) ? { shared: true } : {}),
2414
+ ...(kind !== 'direct' && typeof info.id === 'string' ? { id: info.id } : {}),
1983
2415
  };
1984
2416
  }
1985
2417
  catch {
@@ -2009,9 +2441,108 @@ export const createSlackChannelAdapter = (options) => {
2009
2441
  }
2010
2442
  return result.trim();
2011
2443
  };
2444
+ /**
2445
+ * Slack's stored `mrkdwn` as the plain text a read promises
2446
+ * (`ConversationMessage.text`): `<@U…>` mentions, `<#C…|name>` channels,
2447
+ * `<!here>`-style broadcasts, `<!date^…|fallback>` dates, and
2448
+ * `<url|label>` links, then the `&lt; &gt; &amp;` escapes, decoded last
2449
+ * so text someone typed is never read as markup. A mention is named only
2450
+ * for a principal or the agent's own app, for the reason
2451
+ * `humanizeMentions` gives; anyone else stays their stable id.
2452
+ */
2453
+ const nameableMentions = (connection, text) => {
2454
+ const principals = new Set(connection.config.principals ?? []);
2455
+ const ids = new Set();
2456
+ for (const match of text.matchAll(/<@([^|<>]+)(?:\|[^<>]*)?>/g)) {
2457
+ const id = match[1];
2458
+ if (principals.has(id) || id === connection.botUserId) {
2459
+ ids.add(id);
2460
+ }
2461
+ }
2462
+ return [...ids];
2463
+ };
2464
+ /**
2465
+ * `resolved` is the only source of names: the caller has already looked
2466
+ * them up under its own bound (`readConversation`), and a mention it did
2467
+ * not resolve stays an id rather than costing a lookup here.
2468
+ */
2469
+ const plainSlackText = (connection, text, resolved) => {
2470
+ const names = new Map();
2471
+ for (const id of nameableMentions(connection, text)) {
2472
+ const name = resolved.get(id);
2473
+ if (name !== undefined) {
2474
+ names.set(id, boundedDisplayName(name));
2475
+ }
2476
+ }
2477
+ return text
2478
+ .replace(/<([^<>]*)>/g, (_whole, inner) => {
2479
+ const bar = inner.indexOf('|');
2480
+ const target = bar === -1 ? inner : inner.slice(0, bar);
2481
+ const label = bar === -1 ? undefined : inner.slice(bar + 1);
2482
+ if (target.startsWith('@')) {
2483
+ const id = target.slice(1);
2484
+ return `@${names.get(id) ?? id}`;
2485
+ }
2486
+ if (target.startsWith('#')) {
2487
+ return `#${label || target.slice(1)}`;
2488
+ }
2489
+ if (target.startsWith('!')) {
2490
+ const command = target.slice(1);
2491
+ if (command.startsWith('subteam^')) {
2492
+ return label || `@${command.slice('subteam^'.length)}`;
2493
+ }
2494
+ if (command.startsWith('date^')) {
2495
+ return label ?? command;
2496
+ }
2497
+ return `@${command}`;
2498
+ }
2499
+ if (label && label !== target) {
2500
+ return `${label} (${target})`;
2501
+ }
2502
+ return target;
2503
+ })
2504
+ .replaceAll('&lt;', '<')
2505
+ .replaceAll('&gt;', '>')
2506
+ .replaceAll('&amp;', '&');
2507
+ };
2012
2508
  const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
2013
2509
  /** An agent's configuration, whether or not its socket came up. */
2014
2510
  const agentConfigFor = (agentId) => options.agents.find((candidate) => candidate.agentId === agentId);
2511
+ /**
2512
+ * Which agent each home channel belongs to, keyed `team:channel` because
2513
+ * a channel id is unique only within its workspace. Settled at start,
2514
+ * once every app has authenticated and its team is known. A channel two
2515
+ * agents in one workspace both call home belongs to neither: two agents
2516
+ * answering every message there is the double reply the thread rules
2517
+ * exist to prevent, and picking one would hang on an order the config
2518
+ * file does not reliably carry (JSON objects enumerate integer-like keys
2519
+ * numerically). Both still answer there when mentioned, and a startup
2520
+ * warning says so.
2521
+ */
2522
+ const homeOwners = new Map();
2523
+ const settleHomeOwners = () => {
2524
+ homeOwners.clear();
2525
+ const claims = new Map();
2526
+ for (const agent of options.agents) {
2527
+ const teamId = botIdentities.get(agent.agentId)?.teamId;
2528
+ if (teamId === undefined) {
2529
+ continue;
2530
+ }
2531
+ for (const channelId of agent.homeChannels ?? []) {
2532
+ const key = `${teamId}:${channelId}`;
2533
+ claims.set(key, [...(claims.get(key) ?? []), agent.agentId]);
2534
+ }
2535
+ }
2536
+ for (const [key, claimants] of claims) {
2537
+ const agentIds = [...new Set(claimants)];
2538
+ if (agentIds.length === 1) {
2539
+ homeOwners.set(key, agentIds[0]);
2540
+ continue;
2541
+ }
2542
+ const channelId = key.slice(key.indexOf(':') + 1);
2543
+ warn(`slack: ${channelId} is a home channel for ${agentIds.join(' and ')}; a channel can be home to one agent, so none of them answers there unless mentioned.`);
2544
+ }
2545
+ };
2015
2546
  /**
2016
2547
  * How an agent listens, from its soul — read per message, so a roster
2017
2548
  * reload takes effect on the next one. `thread` when the soul says
@@ -2153,7 +2684,7 @@ export const createSlackChannelAdapter = (options) => {
2153
2684
  */
2154
2685
  const overhearReply = async (speaker, channel, thread, reply, spoken, published) => {
2155
2686
  const gateway = gatewayRef;
2156
- if (!gateway?.observe || reply.trim().length === 0) {
2687
+ if (!gateway?.observe || (typeof reply === 'string' && reply.trim().length === 0)) {
2157
2688
  return;
2158
2689
  }
2159
2690
  const team = speaker.teamId;
@@ -2163,7 +2694,6 @@ export const createSlackChannelAdapter = (options) => {
2163
2694
  slackChannel: channel,
2164
2695
  slackUser: speaker.botUserId,
2165
2696
  slackThread: thread,
2166
- [SENDER_TRUST_METADATA_KEY]: sessionWriteTrust(spoken),
2167
2697
  };
2168
2698
  // In the reply's own workspace only, because a session key carries a
2169
2699
  // team — the same reason `resolveFollowUpWinner` stays in-workspace.
@@ -2181,6 +2711,13 @@ export const createSlackChannelAdapter = (options) => {
2181
2711
  if (!(await published)) {
2182
2712
  return { observed: undefined };
2183
2713
  }
2714
+ // Read now that the posts have landed: what the turn said along
2715
+ // the way is known only once they have.
2716
+ const text = typeof reply === 'string' ? reply : reply();
2717
+ if (text.trim().length === 0) {
2718
+ return { observed: undefined };
2719
+ }
2720
+ const trust = sessionWriteTrust(typeof spoken === 'function' ? await spoken() : spoken);
2184
2721
  let member;
2185
2722
  try {
2186
2723
  member = (await hearer.web.conversations.info({ channel })).channel?.is_member;
@@ -2195,7 +2732,7 @@ export const createSlackChannelAdapter = (options) => {
2195
2732
  // spoke, by display name. A bot user's is the app's own, set by
2196
2733
  // whoever installed it.
2197
2734
  const author = await displayNameFor(hearer, speaker.botUserId);
2198
- const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${reply}`, metadata });
2735
+ const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${text}`, metadata: { ...metadata, [SENDER_TRUST_METADATA_KEY]: trust } });
2199
2736
  return { observed: observed === undefined ? undefined : placeObserve(sessionId, observed) };
2200
2737
  });
2201
2738
  await observed;
@@ -2247,12 +2784,15 @@ export const createSlackChannelAdapter = (options) => {
2247
2784
  }
2248
2785
  const channelId = conversation.id;
2249
2786
  const web = connection.web;
2787
+ // A reply under a message rather than a post beside it. Every chunk of
2788
+ // a long one goes to the same thread, so a split reply stays together.
2789
+ const thread = address.thread?.trim() || undefined;
2250
2790
  return {
2251
2791
  async post(text) {
2252
2792
  const chunks = messageChunks(text.trim().length > 0 ? text : '(empty message)');
2253
2793
  let first;
2254
2794
  for (const chunk of chunks) {
2255
- const posted = await web.chat.postMessage({ channel: channelId, text: chunk });
2795
+ const posted = await web.chat.postMessage({ channel: channelId, text: chunk, ...(thread ? { thread_ts: thread } : {}) });
2256
2796
  if (!first) {
2257
2797
  first = { channel: posted.channel ?? channelId, ts: posted.ts ?? '' };
2258
2798
  }
@@ -2275,6 +2815,130 @@ export const createSlackChannelAdapter = (options) => {
2275
2815
  },
2276
2816
  };
2277
2817
  };
2818
+ /**
2819
+ * The read side of the same boundary `resolveOutbound` draws: a channel
2820
+ * the agent's own app is a member of, public or private. DMs and group
2821
+ * DMs are refused outright, member or not. Their only members are the
2822
+ * people in them, and a read from a conversation elsewhere — a public
2823
+ * thread above all — would carry what they said to people who were never
2824
+ * in the room. A thread in a channel is fine for the same reason the
2825
+ * channel is.
2826
+ */
2827
+ const readConversation = async (request) => {
2828
+ const channel = request.conversation.trim();
2829
+ if (!channel) {
2830
+ throw new Error('slack: a read needs a channel id (C…/G…).');
2831
+ }
2832
+ const connection = connectionFor(request.agentId);
2833
+ if (!connection) {
2834
+ throw new Error(configuredAgents.has(request.agentId)
2835
+ ? `slack: ${request.agentId}'s Slack app is not connected right now, so ${channel} cannot be read.`
2836
+ : `slack: agent ${request.agentId} has no Slack app, so it cannot read Slack at all.`);
2837
+ }
2838
+ let info;
2839
+ try {
2840
+ info = (await connection.web.conversations.info({ channel })).channel;
2841
+ }
2842
+ catch (error) {
2843
+ throw new Error(`slack: ${request.agentId}'s app cannot see ${channel} (${error instanceof Error ? error.message : String(error)}). `
2844
+ + 'Use the channel id, not a name.');
2845
+ }
2846
+ if (!info?.id) {
2847
+ throw new Error(`slack: ${request.agentId}'s app cannot see a conversation ${channel}.`);
2848
+ }
2849
+ if (info.is_im === true || info.is_mpim === true) {
2850
+ throw new Error(`slack: ${channel} is a direct message, and direct messages are never read from outside them.`);
2851
+ }
2852
+ if (info.is_member !== true) {
2853
+ throw new Error(`slack: ${request.agentId}'s app is not a member of ${channel} — invite it there before it can read.`);
2854
+ }
2855
+ const { history, replies } = connection.web.conversations;
2856
+ const fetchPage = request.thread !== undefined
2857
+ ? (replies ? (args) => replies({ ...args, ts: request.thread }) : undefined)
2858
+ : history;
2859
+ if (!fetchPage) {
2860
+ throw new Error(`slack: this Slack client cannot read ${request.thread !== undefined ? 'threads' : 'channel history'}.`);
2861
+ }
2862
+ const base = {
2863
+ channel: info.id,
2864
+ inclusive: false,
2865
+ ...(request.after !== undefined ? { oldest: request.after } : {}),
2866
+ ...(request.before !== undefined ? { latest: request.before } : {}),
2867
+ };
2868
+ const raw = [];
2869
+ let cursor;
2870
+ let more = false;
2871
+ do {
2872
+ const page = await fetchPage({ ...base, limit: Math.min(200, request.limit - raw.length), ...(cursor ? { cursor } : {}) });
2873
+ raw.push(...(page.messages ?? []));
2874
+ cursor = page.response_metadata?.next_cursor || undefined;
2875
+ more = cursor !== undefined || page.has_more === true;
2876
+ } while (cursor && raw.length < request.limit);
2877
+ if (raw.length > request.limit) {
2878
+ raw.length = request.limit;
2879
+ more = true;
2880
+ }
2881
+ // History rarely embeds profiles, so names mostly come from users.info.
2882
+ // Cached names are free; at most READ_NAME_LOOKUPS uncached people —
2883
+ // authors and the mentions in their text together — are looked up per
2884
+ // read, at once, and the rest keep their id, so a read of many strangers
2885
+ // never waits through a rate limit one call at a time.
2886
+ const lookups = new Map();
2887
+ let fresh = 0;
2888
+ const want = (user) => {
2889
+ if (lookups.has(user)) {
2890
+ return;
2891
+ }
2892
+ const cached = displayNames.get(`${connection.teamId}:${user}`);
2893
+ if (cached) {
2894
+ lookups.set(user, Promise.resolve(cached));
2895
+ }
2896
+ else if (fresh < READ_NAME_LOOKUPS) {
2897
+ fresh += 1;
2898
+ lookups.set(user, displayNameFor(connection, user));
2899
+ }
2900
+ };
2901
+ for (const message of raw) {
2902
+ const user = message.user;
2903
+ if (user && !message.user_profile?.display_name && !message.user_profile?.real_name) {
2904
+ want(user);
2905
+ }
2906
+ }
2907
+ for (const message of raw) {
2908
+ for (const id of nameableMentions(connection, message.text ?? '')) {
2909
+ want(id);
2910
+ }
2911
+ }
2912
+ const names = new Map();
2913
+ for (const [user, name] of lookups) {
2914
+ names.set(user, await name);
2915
+ }
2916
+ const messages = [];
2917
+ for (const message of raw) {
2918
+ if (!message.ts) {
2919
+ continue;
2920
+ }
2921
+ const author = message.user ?? message.bot_id ?? 'unknown';
2922
+ const name = message.user_profile?.display_name
2923
+ || message.user_profile?.real_name
2924
+ || message.bot_profile?.name
2925
+ || message.username
2926
+ || (message.user ? names.get(message.user) : undefined);
2927
+ const at = Number.parseFloat(message.ts);
2928
+ const files = (message.files ?? []).map((file) => file.name ?? file.title ?? file.id ?? 'file');
2929
+ messages.push({
2930
+ id: message.ts,
2931
+ author,
2932
+ text: plainSlackText(connection, message.text ?? '', names),
2933
+ ...(name && name !== author ? { authorName: boundedDisplayName(name) } : {}),
2934
+ ...(Number.isFinite(at) ? { at: new Date(at * 1000).toISOString() } : {}),
2935
+ ...(message.thread_ts && message.thread_ts !== message.ts ? { thread: message.thread_ts } : {}),
2936
+ ...(message.reply_count ? { replies: message.reply_count } : {}),
2937
+ ...(files.length > 0 ? { files } : {}),
2938
+ });
2939
+ }
2940
+ return { messages, more };
2941
+ };
2278
2942
  /**
2279
2943
  * Renders one parked call as buttons and remembers where it went.
2280
2944
  *
@@ -3251,6 +3915,24 @@ export const createSlackChannelAdapter = (options) => {
3251
3915
  }
3252
3916
  const first = engaged[0];
3253
3917
  if (!first) {
3918
+ // No agent has a session in this thread, but one may have STARTED
3919
+ // it: a post made through message.send or a schedule's delivery
3920
+ // lands at the top level from some other session, so nothing ties
3921
+ // the thread under it to the agent that wrote it. A reply there is
3922
+ // a reply to that agent. Read off the event, so every connection
3923
+ // reaches the same answer, and after a restart too. Looked up among
3924
+ // authenticated identities, not live sockets: they are all learned
3925
+ // before any socket starts, so an agent whose socket is still coming
3926
+ // up is not judged absent and the memoized verdict cannot drop the
3927
+ // reply when its own socket delivers it.
3928
+ if (parts.parentUser === undefined) {
3929
+ return undefined;
3930
+ }
3931
+ for (const [agentId, identity] of botIdentities) {
3932
+ if (identity.teamId === parts.team && identity.botUserId === parts.parentUser) {
3933
+ return agentId;
3934
+ }
3935
+ }
3254
3936
  return undefined;
3255
3937
  }
3256
3938
  if (engaged.length === 1) {
@@ -3372,7 +4054,7 @@ export const createSlackChannelAdapter = (options) => {
3372
4054
  ? threadKeyFor({ team, conversation: event.channel, thread })
3373
4055
  : undefined;
3374
4056
  const text = event.text ?? '';
3375
- const addressed = isDm || mentions(text, connection.botUserId);
4057
+ const mentioned = isDm || mentions(text, connection.botUserId);
3376
4058
  // Who this message hands the thread to — worked out by EVERY connection
3377
4059
  // that sees it, not only by the one being named, and recorded before any
3378
4060
  // of the rejections below. Each agent is its own Socket Mode connection,
@@ -3395,6 +4077,15 @@ export const createSlackChannelAdapter = (options) => {
3395
4077
  // no policy, and nobody's to refuse here, so it admits.
3396
4078
  const handoverTo = agentNamedIn(text, team, (agentId) => admitsSender(agentConfigFor(agentId), sender));
3397
4079
  const named = handoverTo ?? agentNamedIn(text, team);
4080
+ // A new top-level message in this agent's home channel is addressed to
4081
+ // it as surely as a mention would be — unless it names somebody else,
4082
+ // which hands it to them exactly as it would anywhere. Top-level only:
4083
+ // inside a thread the soul's `listens` already says what an untagged
4084
+ // reply is, and the home channel does not overrule it.
4085
+ const homeOwner = !isDm && event.thread_ts === undefined && named === undefined
4086
+ ? homeOwners.get(`${team}:${event.channel}`)
4087
+ : undefined;
4088
+ const addressed = mentioned || homeOwner === connection.config.agentId;
3398
4089
  // The key one Slack MESSAGE is known by, whichever delivery carried
3399
4090
  // it; see the dedupe below for why it is not the event id.
3400
4091
  const eventKey = `${connection.config.agentId}:${event.channel}:${event.ts}`;
@@ -3409,6 +4100,15 @@ export const createSlackChannelAdapter = (options) => {
3409
4100
  if (threadKey && handoverTo) {
3410
4101
  rememberAddressee(threadKey, handoverTo, event.ts);
3411
4102
  }
4103
+ // A home-channel message hands its new thread to the channel's owner
4104
+ // the way a mention would, and is recorded by every socket for the same
4105
+ // reason: a reply in that thread can reach another agent's socket before
4106
+ // the owner's first turn has written any session routing, and without
4107
+ // this record every agent would stand down and the reply would be lost.
4108
+ // On the owner's admission, as a handover is.
4109
+ if (threadKey && homeOwner !== undefined && admitsSender(agentConfigFor(homeOwner), sender)) {
4110
+ rememberAddressee(threadKey, homeOwner, event.ts);
4111
+ }
3412
4112
  // Who may speak at all, judged before anything below remembers this
3413
4113
  // message. The adapter's own checks establish nothing about who is
3414
4114
  // typing; under `admit: 'principals'` the operator's list is the door,
@@ -3448,7 +4148,12 @@ export const createSlackChannelAdapter = (options) => {
3448
4148
  // place in that queue, and the line is an audit, not a turn; `track`
3449
4149
  // holds it so `stop()` drains it like any other background work.
3450
4150
  const thread = event.thread_ts;
3451
- track(followUpWinner({ team, conversation: event.channel, thread }, event.ts).then((winner) => {
4151
+ track(followUpWinner({
4152
+ team,
4153
+ conversation: event.channel,
4154
+ thread,
4155
+ ...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
4156
+ }, event.ts).then((winner) => {
3452
4157
  if (winner === connection.config.agentId) {
3453
4158
  log(refusal);
3454
4159
  }
@@ -3627,7 +4332,12 @@ export const createSlackChannelAdapter = (options) => {
3627
4332
  if (!admitted.settled) {
3628
4333
  // One verdict for this message, shared with whichever other agents
3629
4334
  // are asking about it — see `followUpWinner`.
3630
- const winner = await followUpWinner({ team, conversation: event.channel, thread: event.thread_ts ?? '' }, event.ts);
4335
+ const winner = await followUpWinner({
4336
+ team,
4337
+ conversation: event.channel,
4338
+ thread: event.thread_ts ?? '',
4339
+ ...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
4340
+ }, event.ts);
3631
4341
  if (winner !== connection.config.agentId) {
3632
4342
  // Not this agent's to answer — and, if it is in the thread,
3633
4343
  // this agent's to hear, same as the two warm stand-downs.
@@ -3735,6 +4445,29 @@ export const createSlackChannelAdapter = (options) => {
3735
4445
  // if it ever has any — see `ReplyRenderer.lazy`. One the agent's
3736
4446
  // reply mode does not stream opens none at all, and shows the
3737
4447
  // loading status instead — see `ReplyRenderer.streaming`.
4448
+ // Mentioned into a thread partway: hand the new session what was
4449
+ // said before this. Inside the chain, before the placeholder, so the
4450
+ // read cannot reorder this message against the next one.
4451
+ let earlier = [];
4452
+ if (!isDm && thread !== undefined) {
4453
+ // A top-level mention roots its own session, so it is new but for
4454
+ // a redelivery, which the gateway answers without reading this.
4455
+ let exists = false;
4456
+ if (thread !== event.ts && gateway.sessionRouting) {
4457
+ try {
4458
+ exists = (await gateway.sessionRouting(sessionId)) !== undefined;
4459
+ }
4460
+ catch {
4461
+ // Unknown is read as new: a session that does exist ignores
4462
+ // the list, so the cost of guessing wrong is one API read.
4463
+ }
4464
+ }
4465
+ if (!exists) {
4466
+ // A mention that starts its own thread follows the channel; one
4467
+ // inside a thread follows that thread.
4468
+ earlier = await earlierContext(connection, event.channel, thread === event.ts ? undefined : thread, event.ts, senderTrust);
4469
+ }
4470
+ }
3738
4471
  const streaming = connection.config.replies === 'stream';
3739
4472
  const ahead = streaming ? undefined : replyOrder.get(sessionId);
3740
4473
  const renderer = new ReplyRenderer(connection.web, event.channel, thread, editIntervalMs, warn, {
@@ -3745,6 +4478,25 @@ export const createSlackChannelAdapter = (options) => {
3745
4478
  statusWarned: connection.statusWarned,
3746
4479
  ...(ahead ? { after: ahead } : {}),
3747
4480
  ...(isDm ? { dmWith: userId } : {}),
4481
+ // Each interim message, as it lands, takes its place in the
4482
+ // thread's other agents' sessions — ahead of a follow-up typed
4483
+ // while the tool it preceded is still running. Under the session's
4484
+ // label as stored, lowered by anything that tainted it during this
4485
+ // turn, and the least trusted label where it cannot be read.
4486
+ ...(thread !== undefined
4487
+ ? {
4488
+ onInterim: (heard, landed) => {
4489
+ // Tracked, so a shutdown drains it like any other reaction:
4490
+ // the text is already in the thread, whatever the turn does.
4491
+ track(overhearReply(connection, event.channel, thread, heard, async () => {
4492
+ const routed = await gateway.sessionRouting?.(sessionId).catch(() => undefined);
4493
+ const stored = routed ? sessionTrustOf(routed) : 'external';
4494
+ const live = renderer.taintedTo === undefined ? stored : leastTrusted(stored, renderer.taintedTo);
4495
+ return { metadata: { [SESSION_TRUST_METADATA_KEY]: live } };
4496
+ }, landed));
4497
+ },
4498
+ }
4499
+ : {}),
3748
4500
  });
3749
4501
  if (!streaming) {
3750
4502
  replyOrder.set(sessionId, renderer.posted);
@@ -3782,6 +4534,7 @@ export const createSlackChannelAdapter = (options) => {
3782
4534
  userMessage,
3783
4535
  ...(images.length > 0 ? { images } : {}),
3784
4536
  ...(judged ? { addressed: false } : {}),
4537
+ ...(earlier.length > 0 ? { earlier } : {}),
3785
4538
  turnId: renderer.turnId,
3786
4539
  metadata,
3787
4540
  // A redelivery this process no longer remembers (it restarted, or
@@ -3902,8 +4655,17 @@ export const createSlackChannelAdapter = (options) => {
3902
4655
  // rule with the gateway's `sessionRouting`, which posts the same
3903
4656
  // message for a turn this adapter did not start.
3904
4657
  const finalized = renderer.finalize(renderer.lazy ? reply ?? '' : reply ?? NO_REPLY_TEXT);
3905
- const heard = thread !== undefined && reply !== undefined
3906
- ? overhearReply(connection, event.channel, thread, reply, session, finalized.then((outcome) => outcome.published))
4658
+ // What the turn said along the way as well as its reply: interim
4659
+ // messages are in the thread, so they are in what the others hear.
4660
+ // Heard once the posts have settled, and the reply only if it landed:
4661
+ // interim messages already in the thread are heard either way.
4662
+ let replyLanded = false;
4663
+ const settled = finalized.then((outcome) => {
4664
+ replyLanded = outcome.published;
4665
+ return true;
4666
+ }, () => true);
4667
+ const heard = thread !== undefined
4668
+ ? overhearReply(connection, event.channel, thread, () => renderer.spokenText(replyLanded ? reply ?? '' : ''), session, settled)
3907
4669
  : undefined;
3908
4670
  const { spoke, spokeAt } = await finalized;
3909
4671
  if (spoke && threadKey !== undefined) {
@@ -3938,6 +4700,7 @@ export const createSlackChannelAdapter = (options) => {
3938
4700
  return {
3939
4701
  name: 'slack',
3940
4702
  resolveOutbound,
4703
+ readConversation,
3941
4704
  requestCredential,
3942
4705
  async start(gateway) {
3943
4706
  gatewayRef = gateway;
@@ -4072,6 +4835,7 @@ export const createSlackChannelAdapter = (options) => {
4072
4835
  const auth = await web.auth.test();
4073
4836
  const botUserId = auth.user_id ?? '';
4074
4837
  const teamId = auth.team_id ?? '';
4838
+ checkBotScopes(config.agentId, auth.response_metadata?.scopes ?? [], warn);
4075
4839
  // Recorded before the socket is even built: an app that never
4076
4840
  // comes up must still be recognizable when somebody names it.
4077
4841
  botIdentities.set(config.agentId, { botUserId, teamId });
@@ -4089,6 +4853,7 @@ export const createSlackChannelAdapter = (options) => {
4089
4853
  warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
4090
4854
  }
4091
4855
  }
4856
+ settleHomeOwners();
4092
4857
  for (const connection of authenticated) {
4093
4858
  const { config, socket } = connection;
4094
4859
  try {
@@ -4153,6 +4918,7 @@ export const createSlackChannelAdapter = (options) => {
4153
4918
  coldVerdicts.clear();
4154
4919
  pendingObserves.clear();
4155
4920
  botIdentities.clear();
4921
+ homeOwners.clear();
4156
4922
  rendering.clear();
4157
4923
  resolvedWhileRendering.clear();
4158
4924
  credentialsPosting.clear();