@stratusagent/channel-slack 0.11.8 → 0.11.9

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
@@ -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);
@@ -254,6 +271,20 @@ class ReplyRenderer {
254
271
  */
255
272
  turnId = randomUUID();
256
273
  buffer = '';
274
+ /**
275
+ * In `final` mode, what the model has said since its last tool call. A
276
+ * turn that writes "On it, checking the PRs" and then works for an hour
277
+ * should not leave the thread showing only a loading status: text the
278
+ * model wrote before calling a tool is posted when the call starts, as a
279
+ * message of its own, and the finished reply follows it.
280
+ */
281
+ interim = '';
282
+ /** The last interim message posted, so a reply that repeats it is not posted twice. */
283
+ lastInterim;
284
+ /** Whether any interim message landed — something said, whatever the reply's fate. */
285
+ interimPosted = false;
286
+ /** Every interim message that landed, in order, for the thread's other agents to hear. */
287
+ interimSaid = [];
257
288
  turnBreakPending = false;
258
289
  toolLine;
259
290
  ref;
@@ -466,6 +497,7 @@ class ReplyRenderer {
466
497
  this.pendingEdit = undefined;
467
498
  }
468
499
  this.buffer = '';
500
+ this.interim = '';
469
501
  this.toolLine = undefined;
470
502
  this.runningTool = undefined;
471
503
  this.turnBreakPending = false;
@@ -502,6 +534,7 @@ class ReplyRenderer {
502
534
  }
503
535
  this.turnBreakPending = false;
504
536
  this.buffer += event.delta.text;
537
+ this.interim += event.delta.text;
505
538
  this.scheduleEdit();
506
539
  return;
507
540
  }
@@ -510,12 +543,22 @@ class ReplyRenderer {
510
543
  // mid-stream failure): discard everything streamed so far so two
511
544
  // attempts never fuse into one message.
512
545
  this.buffer = '';
546
+ this.interim = '';
513
547
  this.turnBreakPending = false;
514
548
  this.scheduleEdit();
515
549
  return;
516
550
  }
517
551
  if (event.type === 'provider.response') {
518
552
  this.turnBreakPending = true;
553
+ // A provider that answers in one piece streams no deltas: the
554
+ // response is the only place its words before a tool call appear.
555
+ // One that streamed them has them already, and is not given them twice.
556
+ if (this.interim.trim().length === 0) {
557
+ this.interim = event.parts
558
+ .filter((part) => part.type === 'text')
559
+ .map((part) => part.text)
560
+ .join('');
561
+ }
519
562
  return;
520
563
  }
521
564
  if (event.type === 'tool.called') {
@@ -527,6 +570,7 @@ class ReplyRenderer {
527
570
  this.turnBreakPending = true;
528
571
  this.toolLine = `⚙ ${event.call.toolName}…`;
529
572
  this.runningTool = event.call.toolName;
573
+ this.flushInterim(ofThisTurn);
530
574
  this.scheduleEdit();
531
575
  return;
532
576
  }
@@ -538,12 +582,18 @@ class ReplyRenderer {
538
582
  this.toolLine = undefined;
539
583
  this.runningTool = undefined;
540
584
  this.turnBreakPending = true;
585
+ // Words written before a call that was refused were still said
586
+ // before a tool, and must not run into the next response's.
587
+ this.flushInterim(ofThisTurn);
541
588
  this.scheduleEdit();
542
589
  return;
543
590
  }
544
591
  if (event.type === 'tool.completed') {
545
592
  this.toolLine = undefined;
546
593
  this.runningTool = undefined;
594
+ // Settled here too for a call rejected before it ran, which never
595
+ // reached `tool.called`; after one that did, this is empty.
596
+ this.flushInterim(ofThisTurn);
547
597
  // Also a boundary, and not only for symmetry: a call rejected before
548
598
  // execution settles as tool.completed without ever having emitted
549
599
  // tool.called, so this is the only mark that attempt leaves.
@@ -623,6 +673,68 @@ class ReplyRenderer {
623
673
  })
624
674
  .catch((error) => this.warn(`files.uploadV2 failed for ${filePath}: ${error instanceof Error ? error.message : String(error)}`));
625
675
  }
676
+ /**
677
+ * Post what the model said before the tool it is now calling, in `final`
678
+ * mode only: a streaming turn already shows it in its placeholder, and a
679
+ * turn nobody asked for must not speak before it decides to. Behind the
680
+ * turn ahead and any handover, on the upload chain, so interim text,
681
+ * files, and the reply land in the order they happened.
682
+ */
683
+ flushInterim(ofThisTurn) {
684
+ const text = this.interim.trim();
685
+ this.interim = '';
686
+ if (this.streaming || this.lazy || !this.turnStarted || !ofThisTurn || text.length === 0) {
687
+ return;
688
+ }
689
+ const handover = this.handover;
690
+ const after = this.after;
691
+ this.uploadChain = this.uploadChain
692
+ .then(() => handover)
693
+ .then(() => after)
694
+ .then(async () => {
695
+ for (const chunk of messageChunks(text)) {
696
+ const posted = await this.web.chat.postMessage({
697
+ channel: this.channel,
698
+ text: chunk,
699
+ ...(this.threadTs ? { thread_ts: this.threadTs } : {}),
700
+ });
701
+ this.placeText(posted.ts);
702
+ }
703
+ this.interimPosted = true;
704
+ this.lastInterim = text;
705
+ this.interimSaid.push(text);
706
+ // A post of the app's in this thread takes the status down while
707
+ // the turn is still working; put it back.
708
+ this.refreshLoading();
709
+ })
710
+ .catch((error) => this.warn(`chat.postMessage failed for an interim message: ${error instanceof Error ? error.message : String(error)}`));
711
+ }
712
+ /**
713
+ * Everything this turn said in the thread, for its other agents to hear:
714
+ * the interim messages that landed, then the reply unless it only
715
+ * repeats the last of them. Read once the turn's posts have landed.
716
+ */
717
+ spokenText(reply) {
718
+ const rest = this.unsaid(reply);
719
+ return [...this.interimSaid, ...(rest.trim().length > 0 ? [rest] : [])].join('\n\n');
720
+ }
721
+ /**
722
+ * The part of a reply not already posted as interim messages. A provider
723
+ * that hosts its own loop (codex) returns every message of the turn
724
+ * joined into one reply, so the interim messages are stripped from its
725
+ * front in order; one whose last words came before its final tool call
726
+ * returns just those words, which were posted already.
727
+ */
728
+ unsaid(reply) {
729
+ let rest = reply.trim();
730
+ for (const said of this.interimSaid) {
731
+ if (!rest.startsWith(said)) {
732
+ break;
733
+ }
734
+ rest = rest.slice(said.length).trimStart();
735
+ }
736
+ return rest === this.lastInterim ? '' : rest;
737
+ }
626
738
  currentText() {
627
739
  const parts = [this.buffer.trim(), this.toolLine].filter((part) => Boolean(part && part.length > 0));
628
740
  return parts.length > 0 ? parts.join('\n\n') : PLACEHOLDER_TEXT;
@@ -755,6 +867,22 @@ class ReplyRenderer {
755
867
  await this.uploadChain;
756
868
  return this.outcome(false, this.uploaded);
757
869
  }
870
+ // Interim messages land before the reply is judged: one that already
871
+ // said what the reply says (the model's last words came before a
872
+ // tool call) is not posted again, and a turn that said things along
873
+ // the way is not followed by `(no reply)`.
874
+ await this.uploadChain;
875
+ if (this.interimPosted && !this.ref) {
876
+ reply = this.unsaid(reply);
877
+ }
878
+ if (!this.ref && this.interimPosted && reply.trim().length === 0) {
879
+ // Nothing posts after this to take the status down, so it is cleared.
880
+ if (hadStatus) {
881
+ this.setStatus('');
882
+ await this.statusChain;
883
+ }
884
+ return this.outcome(true, true);
885
+ }
758
886
  const text = reply.trim().length > 0 ? reply : NO_REPLY_TEXT;
759
887
  const chunks = messageChunks(text);
760
888
  // No placeholder — a handover could not open a fresh one — and the
@@ -1721,6 +1849,8 @@ const credentialModal = (post, requestId) => ({
1721
1849
  * those were in it receives only `app_mention` and behaves as it always
1722
1850
  * did, which makes the workspace's own grant the switch.
1723
1851
  */
1852
+ /** Uncached author names one `message.read` looks up; the rest keep their id. */
1853
+ const READ_NAME_LOOKUPS = 10;
1724
1854
  export const createSlackChannelAdapter = (options) => {
1725
1855
  const log = options.log ?? (() => { });
1726
1856
  const warn = options.warn ?? (() => { });
@@ -1934,6 +2064,205 @@ export const createSlackChannelAdapter = (options) => {
1934
2064
  }
1935
2065
  return boundedDisplayName(await displayNameFor(connection, userId));
1936
2066
  };
2067
+ /**
2068
+ * What was said before an agent was mentioned, for the dispatch that
2069
+ * creates its session (`earlier` on the gateway's dispatch). Without it
2070
+ * an agent named partway through a conversation holds one message —
2071
+ * "can you do this?" — and nothing it refers to. In a thread that is the
2072
+ * thread so far: its parent and the newest messages before the mention.
2073
+ * At the top of a channel it is the channel's most recent messages,
2074
+ * which is where a channel-per-client or channel-per-project keeps the
2075
+ * context a mention leans on.
2076
+ *
2077
+ * Each message keeps its own speaker and its own trust, so the session
2078
+ * starts at the least trusted of everyone in it: a principal's mention
2079
+ * cannot carry a stranger's earlier words in under the principal's
2080
+ * label. Under `admit: 'principals'` a sender the operator did not list
2081
+ * gets no place here, as they get none anywhere else. A bot's message —
2082
+ * another agent, an integration — is `unknown`: nothing here can say
2083
+ * what its words were built from. Messages that will not be shown are
2084
+ * dropped before the cap is applied, so they never crowd out ones that
2085
+ * would.
2086
+ *
2087
+ * A read that fails warns and returns nothing: the turn still runs, on
2088
+ * the one message, as it did before this.
2089
+ */
2090
+ const earlierContext = async (connection, channel, thread, before, senderTrust) => {
2091
+ const usable = (message) => {
2092
+ if (message.ts === undefined || !(Number(message.ts) < Number(before))) {
2093
+ return false;
2094
+ }
2095
+ // The agent's own mention is stripped when the message is shown, so
2096
+ // a bare ping is as empty as no text, and takes no slot.
2097
+ const text = (message.text ?? '').replaceAll(`<@${connection.botUserId}>`, '').trim();
2098
+ if (text.length === 0 && (message.files ?? []).length === 0) {
2099
+ return false;
2100
+ }
2101
+ if (message.bot_id !== undefined || message.subtype === 'bot_message') {
2102
+ // Under `admit: 'principals'` an unlisted integration is as kept out
2103
+ // as an unlisted person. This fleet's own agents are not: they are
2104
+ // the conversation the mention joins — an authenticated peer whose
2105
+ // socket failed to start included, since what it said is still its.
2106
+ return admitsSender(connection.config, message.user ?? '')
2107
+ || (message.user !== undefined && [...botIdentities.values()].some((peer) => peer.teamId === connection.teamId && peer.botUserId === message.user));
2108
+ }
2109
+ return message.user !== undefined
2110
+ && isPersonSpeaking({ type: 'message', ts: message.ts, channel, ...(message.subtype ? { subtype: message.subtype } : {}) })
2111
+ && admitsSender(connection.config, message.user);
2112
+ };
2113
+ let kept = [];
2114
+ let omitted = 0;
2115
+ let more = false;
2116
+ // The channel read stopped at its page bound short of enough messages.
2117
+ let historyCut = false;
2118
+ let firstOfTail = 0;
2119
+ try {
2120
+ if (thread !== undefined) {
2121
+ const replies = connection.web.conversations.replies;
2122
+ if (!replies) {
2123
+ return [];
2124
+ }
2125
+ // Slack pages a thread oldest first, so the whole thread is walked
2126
+ // and only a bounded tail is held: the newest messages are the
2127
+ // last ones read.
2128
+ let parent;
2129
+ const tail = [];
2130
+ let cursor;
2131
+ let pages = 0;
2132
+ do {
2133
+ const page = await replies.call(connection.web.conversations, {
2134
+ channel,
2135
+ ts: thread,
2136
+ latest: before,
2137
+ inclusive: false,
2138
+ limit: 200,
2139
+ ...(cursor ? { cursor } : {}),
2140
+ });
2141
+ pages += 1;
2142
+ for (const message of page.messages ?? []) {
2143
+ if (!usable(message)) {
2144
+ continue;
2145
+ }
2146
+ if (message.ts === thread) {
2147
+ parent = message;
2148
+ continue;
2149
+ }
2150
+ tail.push(message);
2151
+ if (tail.length > EARLIER_MESSAGE_LIMIT) {
2152
+ tail.shift();
2153
+ omitted += 1;
2154
+ }
2155
+ }
2156
+ // Slack's cursor is the signal, not `has_more`: a short page can
2157
+ // still have more behind it.
2158
+ cursor = page.response_metadata?.next_cursor || undefined;
2159
+ } while (cursor && pages < EARLIER_THREAD_PAGES);
2160
+ more = cursor !== undefined;
2161
+ if (more) {
2162
+ // Past the read bound the newest replies were never reached, and a
2163
+ // slice from the middle must not pass for them: the parent alone,
2164
+ // and the note says the rest was not read.
2165
+ omitted += tail.length;
2166
+ tail.length = 0;
2167
+ }
2168
+ // The parent takes a slot only when it is shown.
2169
+ while (tail.length > (parent !== undefined ? EARLIER_MESSAGE_LIMIT - 1 : EARLIER_MESSAGE_LIMIT)) {
2170
+ tail.shift();
2171
+ omitted += 1;
2172
+ }
2173
+ kept = parent !== undefined ? [parent, ...tail] : tail;
2174
+ firstOfTail = parent !== undefined ? 1 : 0;
2175
+ }
2176
+ else {
2177
+ const history = connection.web.conversations.history;
2178
+ if (!history) {
2179
+ return [];
2180
+ }
2181
+ // Newest first, paged until enough usable messages are found (joins
2182
+ // and bots' chatter do not count against the limit), then put back
2183
+ // in the order they were said.
2184
+ const recent = [];
2185
+ let cursor;
2186
+ let pages = 0;
2187
+ do {
2188
+ const page = await history.call(connection.web.conversations, {
2189
+ channel,
2190
+ latest: before,
2191
+ inclusive: false,
2192
+ limit: EARLIER_CHANNEL_READ,
2193
+ ...(cursor ? { cursor } : {}),
2194
+ });
2195
+ pages += 1;
2196
+ recent.push(...(page.messages ?? []).filter(usable));
2197
+ cursor = page.response_metadata?.next_cursor || undefined;
2198
+ } while (cursor && recent.length < EARLIER_CHANNEL_LIMIT && pages < EARLIER_CHANNEL_PAGES);
2199
+ historyCut = cursor !== undefined && recent.length < EARLIER_CHANNEL_LIMIT;
2200
+ kept = recent.slice(0, EARLIER_CHANNEL_LIMIT).reverse();
2201
+ }
2202
+ }
2203
+ catch (error) {
2204
+ warn(`slack: ${connection.config.agentId} could not read the earlier messages in ${channel}, so it sees only the message that named it: `
2205
+ + `${error instanceof Error ? error.message : String(error)} (the history scopes are channels:history, groups:history, and mpim:history)`);
2206
+ return [];
2207
+ }
2208
+ const entries = [];
2209
+ for (const [index, message] of kept.entries()) {
2210
+ const bot = message.bot_id !== undefined || message.subtype === 'bot_message';
2211
+ const text = await humanizeMentions(connection, message.text ?? '');
2212
+ const files = message.files ?? [];
2213
+ const author = bot
2214
+ ? message.user === connection.botUserId
2215
+ ? 'you'
2216
+ : boundedDisplayName(message.bot_profile?.name ?? message.username ?? 'a bot')
2217
+ : await authorFor(connection, message.user);
2218
+ const reasons = new Map(files.map((file) => [file, 'not-opened']));
2219
+ let note = '';
2220
+ if (thread === undefined && index === 0) {
2221
+ note = historyCut
2222
+ ? `[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`
2223
+ : `[The ${kept.length} most recent messages in this channel before you were mentioned, oldest first:]\n`;
2224
+ }
2225
+ else if (thread !== undefined && index === firstOfTail && omitted > 0 && !more) {
2226
+ note = `[${omitted} earlier messages in this thread are not shown.]\n`;
2227
+ }
2228
+ entries.push({
2229
+ message: `${note}${author}: ${text}${attachmentNote(files, reasons)}`,
2230
+ metadata: {
2231
+ channel: 'slack',
2232
+ slackChannel: channel,
2233
+ ...(thread !== undefined ? { slackThread: thread } : {}),
2234
+ ...(message.user !== undefined ? { slackUser: message.user } : {}),
2235
+ [SENDER_TRUST_METADATA_KEY]: bot ? 'unknown' : senderTrustFor(connection.config, message.user),
2236
+ },
2237
+ });
2238
+ }
2239
+ if (historyCut && entries.length === 0) {
2240
+ entries.push({
2241
+ 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.]',
2242
+ // The adapter's own words, nobody else's: they carry the trust of
2243
+ // the message they arrive with, and lower nothing.
2244
+ metadata: { channel: 'slack', slackChannel: channel, [SENDER_TRUST_METADATA_KEY]: senderTrust },
2245
+ });
2246
+ }
2247
+ if (more) {
2248
+ // With no first message to show (unusable, or from someone the room
2249
+ // does not admit), the notice stands alone: an empty backfill would
2250
+ // read as a thread with nothing before the mention. It is the
2251
+ // adapter's own words, so it carries the trust of the message it
2252
+ // arrives with and lowers nothing.
2253
+ const notice = '[This thread is too long to read here: the replies after its first message are not shown. Ask for what you need.]';
2254
+ if (entries.length > 0) {
2255
+ entries[entries.length - 1].message += `\n${notice}`;
2256
+ }
2257
+ else {
2258
+ entries.push({
2259
+ message: notice,
2260
+ metadata: { channel: 'slack', slackChannel: channel, ...(thread !== undefined ? { slackThread: thread } : {}), [SENDER_TRUST_METADATA_KEY]: senderTrust },
2261
+ });
2262
+ }
2263
+ }
2264
+ return entries;
2265
+ };
1937
2266
  /**
1938
2267
  * What kind of room a conversation is and how many are in it, for the
1939
2268
  * turn's `conversation` metadata: an agent that did not know told
@@ -1980,6 +2309,7 @@ export const createSlackChannelAdapter = (options) => {
1980
2309
  kind,
1981
2310
  ...(kind !== 'direct' && typeof info.num_members === 'number' ? { members: info.num_members } : {}),
1982
2311
  ...(kind !== 'direct' && (info.is_ext_shared === true || info.is_org_shared === true) ? { shared: true } : {}),
2312
+ ...(kind !== 'direct' && typeof info.id === 'string' ? { id: info.id } : {}),
1983
2313
  };
1984
2314
  }
1985
2315
  catch {
@@ -2009,9 +2339,108 @@ export const createSlackChannelAdapter = (options) => {
2009
2339
  }
2010
2340
  return result.trim();
2011
2341
  };
2342
+ /**
2343
+ * Slack's stored `mrkdwn` as the plain text a read promises
2344
+ * (`ConversationMessage.text`): `<@U…>` mentions, `<#C…|name>` channels,
2345
+ * `<!here>`-style broadcasts, `<!date^…|fallback>` dates, and
2346
+ * `<url|label>` links, then the `&lt; &gt; &amp;` escapes, decoded last
2347
+ * so text someone typed is never read as markup. A mention is named only
2348
+ * for a principal or the agent's own app, for the reason
2349
+ * `humanizeMentions` gives; anyone else stays their stable id.
2350
+ */
2351
+ const nameableMentions = (connection, text) => {
2352
+ const principals = new Set(connection.config.principals ?? []);
2353
+ const ids = new Set();
2354
+ for (const match of text.matchAll(/<@([^|<>]+)(?:\|[^<>]*)?>/g)) {
2355
+ const id = match[1];
2356
+ if (principals.has(id) || id === connection.botUserId) {
2357
+ ids.add(id);
2358
+ }
2359
+ }
2360
+ return [...ids];
2361
+ };
2362
+ /**
2363
+ * `resolved` is the only source of names: the caller has already looked
2364
+ * them up under its own bound (`readConversation`), and a mention it did
2365
+ * not resolve stays an id rather than costing a lookup here.
2366
+ */
2367
+ const plainSlackText = (connection, text, resolved) => {
2368
+ const names = new Map();
2369
+ for (const id of nameableMentions(connection, text)) {
2370
+ const name = resolved.get(id);
2371
+ if (name !== undefined) {
2372
+ names.set(id, boundedDisplayName(name));
2373
+ }
2374
+ }
2375
+ return text
2376
+ .replace(/<([^<>]*)>/g, (_whole, inner) => {
2377
+ const bar = inner.indexOf('|');
2378
+ const target = bar === -1 ? inner : inner.slice(0, bar);
2379
+ const label = bar === -1 ? undefined : inner.slice(bar + 1);
2380
+ if (target.startsWith('@')) {
2381
+ const id = target.slice(1);
2382
+ return `@${names.get(id) ?? id}`;
2383
+ }
2384
+ if (target.startsWith('#')) {
2385
+ return `#${label || target.slice(1)}`;
2386
+ }
2387
+ if (target.startsWith('!')) {
2388
+ const command = target.slice(1);
2389
+ if (command.startsWith('subteam^')) {
2390
+ return label || `@${command.slice('subteam^'.length)}`;
2391
+ }
2392
+ if (command.startsWith('date^')) {
2393
+ return label ?? command;
2394
+ }
2395
+ return `@${command}`;
2396
+ }
2397
+ if (label && label !== target) {
2398
+ return `${label} (${target})`;
2399
+ }
2400
+ return target;
2401
+ })
2402
+ .replaceAll('&lt;', '<')
2403
+ .replaceAll('&gt;', '>')
2404
+ .replaceAll('&amp;', '&');
2405
+ };
2012
2406
  const connectionFor = (agentId) => connections.find((candidate) => candidate.config.agentId === agentId);
2013
2407
  /** An agent's configuration, whether or not its socket came up. */
2014
2408
  const agentConfigFor = (agentId) => options.agents.find((candidate) => candidate.agentId === agentId);
2409
+ /**
2410
+ * Which agent each home channel belongs to, keyed `team:channel` because
2411
+ * a channel id is unique only within its workspace. Settled at start,
2412
+ * once every app has authenticated and its team is known. A channel two
2413
+ * agents in one workspace both call home belongs to neither: two agents
2414
+ * answering every message there is the double reply the thread rules
2415
+ * exist to prevent, and picking one would hang on an order the config
2416
+ * file does not reliably carry (JSON objects enumerate integer-like keys
2417
+ * numerically). Both still answer there when mentioned, and a startup
2418
+ * warning says so.
2419
+ */
2420
+ const homeOwners = new Map();
2421
+ const settleHomeOwners = () => {
2422
+ homeOwners.clear();
2423
+ const claims = new Map();
2424
+ for (const agent of options.agents) {
2425
+ const teamId = botIdentities.get(agent.agentId)?.teamId;
2426
+ if (teamId === undefined) {
2427
+ continue;
2428
+ }
2429
+ for (const channelId of agent.homeChannels ?? []) {
2430
+ const key = `${teamId}:${channelId}`;
2431
+ claims.set(key, [...(claims.get(key) ?? []), agent.agentId]);
2432
+ }
2433
+ }
2434
+ for (const [key, claimants] of claims) {
2435
+ const agentIds = [...new Set(claimants)];
2436
+ if (agentIds.length === 1) {
2437
+ homeOwners.set(key, agentIds[0]);
2438
+ continue;
2439
+ }
2440
+ const channelId = key.slice(key.indexOf(':') + 1);
2441
+ 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.`);
2442
+ }
2443
+ };
2015
2444
  /**
2016
2445
  * How an agent listens, from its soul — read per message, so a roster
2017
2446
  * reload takes effect on the next one. `thread` when the soul says
@@ -2153,7 +2582,7 @@ export const createSlackChannelAdapter = (options) => {
2153
2582
  */
2154
2583
  const overhearReply = async (speaker, channel, thread, reply, spoken, published) => {
2155
2584
  const gateway = gatewayRef;
2156
- if (!gateway?.observe || reply.trim().length === 0) {
2585
+ if (!gateway?.observe || (typeof reply === 'string' && reply.trim().length === 0)) {
2157
2586
  return;
2158
2587
  }
2159
2588
  const team = speaker.teamId;
@@ -2181,6 +2610,12 @@ export const createSlackChannelAdapter = (options) => {
2181
2610
  if (!(await published)) {
2182
2611
  return { observed: undefined };
2183
2612
  }
2613
+ // Read now that the posts have landed: what the turn said along
2614
+ // the way is known only once they have.
2615
+ const text = typeof reply === 'string' ? reply : reply();
2616
+ if (text.trim().length === 0) {
2617
+ return { observed: undefined };
2618
+ }
2184
2619
  let member;
2185
2620
  try {
2186
2621
  member = (await hearer.web.conversations.info({ channel })).channel?.is_member;
@@ -2195,7 +2630,7 @@ export const createSlackChannelAdapter = (options) => {
2195
2630
  // spoke, by display name. A bot user's is the app's own, set by
2196
2631
  // whoever installed it.
2197
2632
  const author = await displayNameFor(hearer, speaker.botUserId);
2198
- const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${reply}`, metadata });
2633
+ const observed = gateway.observe?.({ sessionId, agentId: hearer.config.agentId, message: `${author}: ${text}`, metadata });
2199
2634
  return { observed: observed === undefined ? undefined : placeObserve(sessionId, observed) };
2200
2635
  });
2201
2636
  await observed;
@@ -2275,6 +2710,130 @@ export const createSlackChannelAdapter = (options) => {
2275
2710
  },
2276
2711
  };
2277
2712
  };
2713
+ /**
2714
+ * The read side of the same boundary `resolveOutbound` draws: a channel
2715
+ * the agent's own app is a member of, public or private. DMs and group
2716
+ * DMs are refused outright, member or not. Their only members are the
2717
+ * people in them, and a read from a conversation elsewhere — a public
2718
+ * thread above all — would carry what they said to people who were never
2719
+ * in the room. A thread in a channel is fine for the same reason the
2720
+ * channel is.
2721
+ */
2722
+ const readConversation = async (request) => {
2723
+ const channel = request.conversation.trim();
2724
+ if (!channel) {
2725
+ throw new Error('slack: a read needs a channel id (C…/G…).');
2726
+ }
2727
+ const connection = connectionFor(request.agentId);
2728
+ if (!connection) {
2729
+ throw new Error(configuredAgents.has(request.agentId)
2730
+ ? `slack: ${request.agentId}'s Slack app is not connected right now, so ${channel} cannot be read.`
2731
+ : `slack: agent ${request.agentId} has no Slack app, so it cannot read Slack at all.`);
2732
+ }
2733
+ let info;
2734
+ try {
2735
+ info = (await connection.web.conversations.info({ channel })).channel;
2736
+ }
2737
+ catch (error) {
2738
+ throw new Error(`slack: ${request.agentId}'s app cannot see ${channel} (${error instanceof Error ? error.message : String(error)}). `
2739
+ + 'Use the channel id, not a name.');
2740
+ }
2741
+ if (!info?.id) {
2742
+ throw new Error(`slack: ${request.agentId}'s app cannot see a conversation ${channel}.`);
2743
+ }
2744
+ if (info.is_im === true || info.is_mpim === true) {
2745
+ throw new Error(`slack: ${channel} is a direct message, and direct messages are never read from outside them.`);
2746
+ }
2747
+ if (info.is_member !== true) {
2748
+ throw new Error(`slack: ${request.agentId}'s app is not a member of ${channel} — invite it there before it can read.`);
2749
+ }
2750
+ const { history, replies } = connection.web.conversations;
2751
+ const fetchPage = request.thread !== undefined
2752
+ ? (replies ? (args) => replies({ ...args, ts: request.thread }) : undefined)
2753
+ : history;
2754
+ if (!fetchPage) {
2755
+ throw new Error(`slack: this Slack client cannot read ${request.thread !== undefined ? 'threads' : 'channel history'}.`);
2756
+ }
2757
+ const base = {
2758
+ channel: info.id,
2759
+ inclusive: false,
2760
+ ...(request.after !== undefined ? { oldest: request.after } : {}),
2761
+ ...(request.before !== undefined ? { latest: request.before } : {}),
2762
+ };
2763
+ const raw = [];
2764
+ let cursor;
2765
+ let more = false;
2766
+ do {
2767
+ const page = await fetchPage({ ...base, limit: Math.min(200, request.limit - raw.length), ...(cursor ? { cursor } : {}) });
2768
+ raw.push(...(page.messages ?? []));
2769
+ cursor = page.response_metadata?.next_cursor || undefined;
2770
+ more = cursor !== undefined || page.has_more === true;
2771
+ } while (cursor && raw.length < request.limit);
2772
+ if (raw.length > request.limit) {
2773
+ raw.length = request.limit;
2774
+ more = true;
2775
+ }
2776
+ // History rarely embeds profiles, so names mostly come from users.info.
2777
+ // Cached names are free; at most READ_NAME_LOOKUPS uncached people —
2778
+ // authors and the mentions in their text together — are looked up per
2779
+ // read, at once, and the rest keep their id, so a read of many strangers
2780
+ // never waits through a rate limit one call at a time.
2781
+ const lookups = new Map();
2782
+ let fresh = 0;
2783
+ const want = (user) => {
2784
+ if (lookups.has(user)) {
2785
+ return;
2786
+ }
2787
+ const cached = displayNames.get(`${connection.teamId}:${user}`);
2788
+ if (cached) {
2789
+ lookups.set(user, Promise.resolve(cached));
2790
+ }
2791
+ else if (fresh < READ_NAME_LOOKUPS) {
2792
+ fresh += 1;
2793
+ lookups.set(user, displayNameFor(connection, user));
2794
+ }
2795
+ };
2796
+ for (const message of raw) {
2797
+ const user = message.user;
2798
+ if (user && !message.user_profile?.display_name && !message.user_profile?.real_name) {
2799
+ want(user);
2800
+ }
2801
+ }
2802
+ for (const message of raw) {
2803
+ for (const id of nameableMentions(connection, message.text ?? '')) {
2804
+ want(id);
2805
+ }
2806
+ }
2807
+ const names = new Map();
2808
+ for (const [user, name] of lookups) {
2809
+ names.set(user, await name);
2810
+ }
2811
+ const messages = [];
2812
+ for (const message of raw) {
2813
+ if (!message.ts) {
2814
+ continue;
2815
+ }
2816
+ const author = message.user ?? message.bot_id ?? 'unknown';
2817
+ const name = message.user_profile?.display_name
2818
+ || message.user_profile?.real_name
2819
+ || message.bot_profile?.name
2820
+ || message.username
2821
+ || (message.user ? names.get(message.user) : undefined);
2822
+ const at = Number.parseFloat(message.ts);
2823
+ const files = (message.files ?? []).map((file) => file.name ?? file.title ?? file.id ?? 'file');
2824
+ messages.push({
2825
+ id: message.ts,
2826
+ author,
2827
+ text: plainSlackText(connection, message.text ?? '', names),
2828
+ ...(name && name !== author ? { authorName: boundedDisplayName(name) } : {}),
2829
+ ...(Number.isFinite(at) ? { at: new Date(at * 1000).toISOString() } : {}),
2830
+ ...(message.thread_ts && message.thread_ts !== message.ts ? { thread: message.thread_ts } : {}),
2831
+ ...(message.reply_count ? { replies: message.reply_count } : {}),
2832
+ ...(files.length > 0 ? { files } : {}),
2833
+ });
2834
+ }
2835
+ return { messages, more };
2836
+ };
2278
2837
  /**
2279
2838
  * Renders one parked call as buttons and remembers where it went.
2280
2839
  *
@@ -3251,6 +3810,24 @@ export const createSlackChannelAdapter = (options) => {
3251
3810
  }
3252
3811
  const first = engaged[0];
3253
3812
  if (!first) {
3813
+ // No agent has a session in this thread, but one may have STARTED
3814
+ // it: a post made through message.send or a schedule's delivery
3815
+ // lands at the top level from some other session, so nothing ties
3816
+ // the thread under it to the agent that wrote it. A reply there is
3817
+ // a reply to that agent. Read off the event, so every connection
3818
+ // reaches the same answer, and after a restart too. Looked up among
3819
+ // authenticated identities, not live sockets: they are all learned
3820
+ // before any socket starts, so an agent whose socket is still coming
3821
+ // up is not judged absent and the memoized verdict cannot drop the
3822
+ // reply when its own socket delivers it.
3823
+ if (parts.parentUser === undefined) {
3824
+ return undefined;
3825
+ }
3826
+ for (const [agentId, identity] of botIdentities) {
3827
+ if (identity.teamId === parts.team && identity.botUserId === parts.parentUser) {
3828
+ return agentId;
3829
+ }
3830
+ }
3254
3831
  return undefined;
3255
3832
  }
3256
3833
  if (engaged.length === 1) {
@@ -3372,7 +3949,7 @@ export const createSlackChannelAdapter = (options) => {
3372
3949
  ? threadKeyFor({ team, conversation: event.channel, thread })
3373
3950
  : undefined;
3374
3951
  const text = event.text ?? '';
3375
- const addressed = isDm || mentions(text, connection.botUserId);
3952
+ const mentioned = isDm || mentions(text, connection.botUserId);
3376
3953
  // Who this message hands the thread to — worked out by EVERY connection
3377
3954
  // that sees it, not only by the one being named, and recorded before any
3378
3955
  // of the rejections below. Each agent is its own Socket Mode connection,
@@ -3395,6 +3972,15 @@ export const createSlackChannelAdapter = (options) => {
3395
3972
  // no policy, and nobody's to refuse here, so it admits.
3396
3973
  const handoverTo = agentNamedIn(text, team, (agentId) => admitsSender(agentConfigFor(agentId), sender));
3397
3974
  const named = handoverTo ?? agentNamedIn(text, team);
3975
+ // A new top-level message in this agent's home channel is addressed to
3976
+ // it as surely as a mention would be — unless it names somebody else,
3977
+ // which hands it to them exactly as it would anywhere. Top-level only:
3978
+ // inside a thread the soul's `listens` already says what an untagged
3979
+ // reply is, and the home channel does not overrule it.
3980
+ const homeOwner = !isDm && event.thread_ts === undefined && named === undefined
3981
+ ? homeOwners.get(`${team}:${event.channel}`)
3982
+ : undefined;
3983
+ const addressed = mentioned || homeOwner === connection.config.agentId;
3398
3984
  // The key one Slack MESSAGE is known by, whichever delivery carried
3399
3985
  // it; see the dedupe below for why it is not the event id.
3400
3986
  const eventKey = `${connection.config.agentId}:${event.channel}:${event.ts}`;
@@ -3409,6 +3995,15 @@ export const createSlackChannelAdapter = (options) => {
3409
3995
  if (threadKey && handoverTo) {
3410
3996
  rememberAddressee(threadKey, handoverTo, event.ts);
3411
3997
  }
3998
+ // A home-channel message hands its new thread to the channel's owner
3999
+ // the way a mention would, and is recorded by every socket for the same
4000
+ // reason: a reply in that thread can reach another agent's socket before
4001
+ // the owner's first turn has written any session routing, and without
4002
+ // this record every agent would stand down and the reply would be lost.
4003
+ // On the owner's admission, as a handover is.
4004
+ if (threadKey && homeOwner !== undefined && admitsSender(agentConfigFor(homeOwner), sender)) {
4005
+ rememberAddressee(threadKey, homeOwner, event.ts);
4006
+ }
3412
4007
  // Who may speak at all, judged before anything below remembers this
3413
4008
  // message. The adapter's own checks establish nothing about who is
3414
4009
  // typing; under `admit: 'principals'` the operator's list is the door,
@@ -3448,7 +4043,12 @@ export const createSlackChannelAdapter = (options) => {
3448
4043
  // place in that queue, and the line is an audit, not a turn; `track`
3449
4044
  // holds it so `stop()` drains it like any other background work.
3450
4045
  const thread = event.thread_ts;
3451
- track(followUpWinner({ team, conversation: event.channel, thread }, event.ts).then((winner) => {
4046
+ track(followUpWinner({
4047
+ team,
4048
+ conversation: event.channel,
4049
+ thread,
4050
+ ...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
4051
+ }, event.ts).then((winner) => {
3452
4052
  if (winner === connection.config.agentId) {
3453
4053
  log(refusal);
3454
4054
  }
@@ -3627,7 +4227,12 @@ export const createSlackChannelAdapter = (options) => {
3627
4227
  if (!admitted.settled) {
3628
4228
  // One verdict for this message, shared with whichever other agents
3629
4229
  // are asking about it — see `followUpWinner`.
3630
- const winner = await followUpWinner({ team, conversation: event.channel, thread: event.thread_ts ?? '' }, event.ts);
4230
+ const winner = await followUpWinner({
4231
+ team,
4232
+ conversation: event.channel,
4233
+ thread: event.thread_ts ?? '',
4234
+ ...(event.parent_user_id !== undefined ? { parentUser: event.parent_user_id } : {}),
4235
+ }, event.ts);
3631
4236
  if (winner !== connection.config.agentId) {
3632
4237
  // Not this agent's to answer — and, if it is in the thread,
3633
4238
  // this agent's to hear, same as the two warm stand-downs.
@@ -3735,6 +4340,29 @@ export const createSlackChannelAdapter = (options) => {
3735
4340
  // if it ever has any — see `ReplyRenderer.lazy`. One the agent's
3736
4341
  // reply mode does not stream opens none at all, and shows the
3737
4342
  // loading status instead — see `ReplyRenderer.streaming`.
4343
+ // Mentioned into a thread partway: hand the new session what was
4344
+ // said before this. Inside the chain, before the placeholder, so the
4345
+ // read cannot reorder this message against the next one.
4346
+ let earlier = [];
4347
+ if (!isDm && thread !== undefined) {
4348
+ // A top-level mention roots its own session, so it is new but for
4349
+ // a redelivery, which the gateway answers without reading this.
4350
+ let exists = false;
4351
+ if (thread !== event.ts && gateway.sessionRouting) {
4352
+ try {
4353
+ exists = (await gateway.sessionRouting(sessionId)) !== undefined;
4354
+ }
4355
+ catch {
4356
+ // Unknown is read as new: a session that does exist ignores
4357
+ // the list, so the cost of guessing wrong is one API read.
4358
+ }
4359
+ }
4360
+ if (!exists) {
4361
+ // A mention that starts its own thread follows the channel; one
4362
+ // inside a thread follows that thread.
4363
+ earlier = await earlierContext(connection, event.channel, thread === event.ts ? undefined : thread, event.ts, senderTrust);
4364
+ }
4365
+ }
3738
4366
  const streaming = connection.config.replies === 'stream';
3739
4367
  const ahead = streaming ? undefined : replyOrder.get(sessionId);
3740
4368
  const renderer = new ReplyRenderer(connection.web, event.channel, thread, editIntervalMs, warn, {
@@ -3782,6 +4410,7 @@ export const createSlackChannelAdapter = (options) => {
3782
4410
  userMessage,
3783
4411
  ...(images.length > 0 ? { images } : {}),
3784
4412
  ...(judged ? { addressed: false } : {}),
4413
+ ...(earlier.length > 0 ? { earlier } : {}),
3785
4414
  turnId: renderer.turnId,
3786
4415
  metadata,
3787
4416
  // A redelivery this process no longer remembers (it restarted, or
@@ -3902,8 +4531,17 @@ export const createSlackChannelAdapter = (options) => {
3902
4531
  // rule with the gateway's `sessionRouting`, which posts the same
3903
4532
  // message for a turn this adapter did not start.
3904
4533
  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))
4534
+ // What the turn said along the way as well as its reply: interim
4535
+ // messages are in the thread, so they are in what the others hear.
4536
+ // Heard once the posts have settled, and the reply only if it landed:
4537
+ // interim messages already in the thread are heard either way.
4538
+ let replyLanded = false;
4539
+ const settled = finalized.then((outcome) => {
4540
+ replyLanded = outcome.published;
4541
+ return true;
4542
+ }, () => true);
4543
+ const heard = thread !== undefined
4544
+ ? overhearReply(connection, event.channel, thread, () => renderer.spokenText(replyLanded ? reply ?? '' : ''), session, settled)
3907
4545
  : undefined;
3908
4546
  const { spoke, spokeAt } = await finalized;
3909
4547
  if (spoke && threadKey !== undefined) {
@@ -3928,6 +4566,14 @@ export const createSlackChannelAdapter = (options) => {
3928
4566
  }
3929
4567
  else {
3930
4568
  const { spoke, spokeAt } = await renderer.fail(failure instanceof Error ? failure.message : String(failure));
4569
+ // What the turn said before it broke is in the thread, so the
4570
+ // others hear it; the error note is the failure's, and is not. Under
4571
+ // the session's own label where it can be read, and the least
4572
+ // trusted one where it cannot.
4573
+ if (thread !== undefined && renderer.spokenText('').length > 0) {
4574
+ const routed = await gateway.sessionRouting?.(sessionId).catch(() => undefined);
4575
+ await overhearReply(connection, event.channel, thread, renderer.spokenText(''), routed ?? { metadata: {} }, Promise.resolve(true));
4576
+ }
3931
4577
  if (spoke && threadKey !== undefined) {
3932
4578
  // A file it posted before breaking is still the last thing said.
3933
4579
  rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
@@ -3938,6 +4584,7 @@ export const createSlackChannelAdapter = (options) => {
3938
4584
  return {
3939
4585
  name: 'slack',
3940
4586
  resolveOutbound,
4587
+ readConversation,
3941
4588
  requestCredential,
3942
4589
  async start(gateway) {
3943
4590
  gatewayRef = gateway;
@@ -4089,6 +4736,7 @@ export const createSlackChannelAdapter = (options) => {
4089
4736
  warn(`slack: could not connect ${config.agentId}: ${error instanceof Error ? error.message : String(error)}`);
4090
4737
  }
4091
4738
  }
4739
+ settleHomeOwners();
4092
4740
  for (const connection of authenticated) {
4093
4741
  const { config, socket } = connection;
4094
4742
  try {
@@ -4153,6 +4801,7 @@ export const createSlackChannelAdapter = (options) => {
4153
4801
  coldVerdicts.clear();
4154
4802
  pendingObserves.clear();
4155
4803
  botIdentities.clear();
4804
+ homeOwners.clear();
4156
4805
  rendering.clear();
4157
4806
  resolvedWhileRendering.clear();
4158
4807
  credentialsPosting.clear();