@stratusagent/channel-slack 0.12.3 → 0.12.4

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
@@ -3,7 +3,7 @@ import { constants } from 'node:fs';
3
3
  import { lstat, mkdir, open, readdir, readFile, realpath } from 'node:fs/promises';
4
4
  import { createRequire } from 'node:module';
5
5
  import path from 'node:path';
6
- 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
+ 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, RunAbortedError, leastTrusted, sessionTrustOf, SESSION_TRUST_METADATA_KEY, } from '@stratusagent/core';
7
7
  import { admitsSender, channelSessionKey, senderTrustFor, } from '@stratusagent/channels';
8
8
  import { codeRunsOf, toSlackMrkdwn } from "./mrkdwn.js";
9
9
  /**
@@ -44,6 +44,13 @@ const COLD_VERDICT_CAPACITY = 256;
44
44
  * socket stays behind for all of them.
45
45
  */
46
46
  const THREAD_HANDOVER_DEPTH = 32;
47
+ /**
48
+ * How many agent mentions one thread admits in a row before a person has
49
+ * to speak there again (card 513): enough for a question, an answer and a
50
+ * follow-up each way, and a stop to two agents tagging each other forever.
51
+ * Counted per daemon, in memory; a restart starts it again.
52
+ */
53
+ export const AGENT_MENTION_HOP_LIMIT = 4;
47
54
  /**
48
55
  * How many files an unrendered turn may queue for its outcome. Nothing
49
56
  * drains that queue until the outcome arrives, so a session whose outcome
@@ -323,6 +330,14 @@ class ReplyRenderer {
323
330
  * not be this one.
324
331
  */
325
332
  turnId = randomUUID();
333
+ /**
334
+ * What a Slack stop aborts (card 337): the signal this renderer's dispatch
335
+ * runs under, so a stop cancels this turn whether it is running or still
336
+ * queued behind another.
337
+ */
338
+ controller = new AbortController();
339
+ /** The Slack user who stopped this turn, once someone has. */
340
+ stoppedBy;
326
341
  buffer = '';
327
342
  /**
328
343
  * In `final` mode, what the model has said since its last tool call. A
@@ -1113,6 +1128,18 @@ class ReplyRenderer {
1113
1128
  this.settle();
1114
1129
  }
1115
1130
  }
1131
+ /**
1132
+ * Ends a turn an approver stopped from Slack: says who stopped it where
1133
+ * the reply would have gone. A turn nobody asked for that had said
1134
+ * nothing stays silent, as its failure would.
1135
+ */
1136
+ async stopped(by) {
1137
+ await this.editChain;
1138
+ if (this.lazy && !this.ref && this.buffer.trim().length === 0) {
1139
+ return this.finalize('');
1140
+ }
1141
+ return this.finalize(`Stopped by <@${by}>.`);
1142
+ }
1116
1143
  async fail(message) {
1117
1144
  // The same wait `finalize` takes, for the same reason: a lazy turn's
1118
1145
  // first text may be opening its placeholder right now, and a failure
@@ -1411,6 +1438,8 @@ const alwaysOutcome = (always, agentName, agentId) => {
1411
1438
  return 'Allowed, and this site is now acted on without asking — until revoked';
1412
1439
  case 'tool':
1413
1440
  return `Allowed, and granted to ${escapeSlackText(agentName)} until revoked (stratus grants ${escapeSlackText(agentId)})`;
1441
+ case 'destination':
1442
+ return `Allowed, and granted to ${escapeSlackText(agentName)} for this conversation until revoked (stratus grants ${escapeSlackText(agentId)})`;
1414
1443
  case 'session':
1415
1444
  return 'Allowed for the rest of this session';
1416
1445
  default:
@@ -1430,6 +1459,8 @@ const alwaysOffer = (always, agentName) => {
1430
1459
  switch (always) {
1431
1460
  case 'tool':
1432
1461
  return `*Always allow* grants this tool to ${escapeSlackText(agentName)} until an operator revokes it.`;
1462
+ case 'destination':
1463
+ return `*Always allow* lets ${escapeSlackText(agentName)} use this tool on this conversation without asking, until revoked.`;
1433
1464
  case 'session':
1434
1465
  return '*Always allow* stops this tool asking again for the rest of this session.';
1435
1466
  case 'scope':
@@ -1458,6 +1489,12 @@ const APPROVAL_SUMMARY_LIMIT = 160;
1458
1489
  * words, and marked only by how it is rendered.
1459
1490
  */
1460
1491
  const HUMAN_MESSAGE_SUBTYPES = new Set(['file_share', 'me_message', 'thread_broadcast']);
1492
+ /**
1493
+ * Message subtypes that are another agent's bot speaking (card 513): a plain
1494
+ * bot post, an upload with a comment, or a thread reply also sent to the
1495
+ * channel. Edits, deletions and joins are never a mention to answer.
1496
+ */
1497
+ const AGENT_SPEECH_SUBTYPES = new Set(['bot_message', 'file_share', 'thread_broadcast']);
1461
1498
  const isPersonSpeaking = (event) => event.subtype === undefined || HUMAN_MESSAGE_SUBTYPES.has(event.subtype);
1462
1499
  /** At most this many attachment names are listed; the rest are counted. */
1463
1500
  const MAX_LISTED_ATTACHMENTS = 5;
@@ -1921,6 +1958,20 @@ const textAttachmentBlocks = (texts) => texts.map(({ name, text }) => `\n[Attach
1921
1958
  * sees.
1922
1959
  */
1923
1960
  const mentions = (text, botUserId) => botUserId.length > 0 && text.includes(`<@${botUserId}>`);
1961
+ /**
1962
+ * Whether a message asks this agent to stop (card 337): the word "stop"
1963
+ * and nothing else but mentions and closing punctuation, in any case. A
1964
+ * message that mentions someone and not this agent is for them. Exported
1965
+ * for tests.
1966
+ */
1967
+ export const isStopRequest = (text, botUserId) => {
1968
+ const mentioned = [...text.matchAll(/<@([^>|]+)(?:\|[^>]*)?>/g)].map((match) => match[1]);
1969
+ if (mentioned.length > 0 && !mentioned.includes(botUserId)) {
1970
+ return false;
1971
+ }
1972
+ const words = text.replace(/<@[^>|]+(?:\|[^>]*)?>/g, ' ').trim().replace(/[.!]+$/, '').trim();
1973
+ return words.toLowerCase() === 'stop';
1974
+ };
1924
1975
  /**
1925
1976
  * Slack's three markup characters. Tool input is written by a model, so it
1926
1977
  * reaches this message as untrusted text: unescaped, `<@U123>` becomes a
@@ -2124,6 +2175,19 @@ const credentialModal = (post, requestId) => ({
2124
2175
  */
2125
2176
  /** Uncached author names one `message.read` looks up; the rest keep their id. */
2126
2177
  const READ_NAME_LOOKUPS = 10;
2178
+ /**
2179
+ * Orders two Slack message timestamps ("seconds.micros"). Compared as
2180
+ * integers, not floats: a current ts has more digits than a double holds.
2181
+ */
2182
+ const compareSlackTs = (left, right) => {
2183
+ const [leftSeconds = '0', leftMicros = ''] = left.split('.');
2184
+ const [rightSeconds = '0', rightMicros = ''] = right.split('.');
2185
+ const seconds = Number(leftSeconds) - Number(rightSeconds);
2186
+ if (seconds !== 0) {
2187
+ return Math.sign(seconds);
2188
+ }
2189
+ return Math.sign(Number(leftMicros.padEnd(6, '0').slice(0, 6)) - Number(rightMicros.padEnd(6, '0').slice(0, 6)));
2190
+ };
2127
2191
  export const createSlackChannelAdapter = (options) => {
2128
2192
  const log = options.log ?? (() => { });
2129
2193
  const warn = options.warn ?? (() => { });
@@ -2161,6 +2225,15 @@ export const createSlackChannelAdapter = (options) => {
2161
2225
  // Per-session intake chains: the pre-dispatch pipeline (lookups,
2162
2226
  // placeholder, dispatch call) runs in Slack receipt order.
2163
2227
  const intakeChains = new Map();
2228
+ /**
2229
+ * Stop (card 337), for messages still in intake: how many each session
2230
+ * has between claiming a place on its chain and reaching `renderers`, and
2231
+ * the latest stop to land there. A message compares the stop it claimed
2232
+ * its place under with the one in force when it is about to dispatch, so a
2233
+ * stop also cancels a message whose lookups were still running.
2234
+ */
2235
+ const intakeInFlight = new Map();
2236
+ const stopsBySession = new Map();
2164
2237
  const displayNames = new Map();
2165
2238
  // Socket Mode redelivers on missed acks; a slow turn must not run twice.
2166
2239
  const seenEvents = new Set();
@@ -2382,8 +2455,12 @@ export const createSlackChannelAdapter = (options) => {
2382
2455
  // as an unlisted person. This fleet's own agents are not: they are
2383
2456
  // the conversation the mention joins — an authenticated peer whose
2384
2457
  // socket failed to start included, since what it said is still its.
2458
+ // Nor is a remote team agent this one answers (card 513): its
2459
+ // mention is admitted, so what it said before the mention is too.
2385
2460
  return admitsSender(connection.config, message.user ?? '')
2386
- || (message.user !== undefined && [...botIdentities.values()].some((peer) => peer.teamId === connection.teamId && peer.botUserId === message.user));
2461
+ || (message.user !== undefined && [...botIdentities.values()].some((peer) => peer.teamId === connection.teamId && peer.botUserId === message.user))
2462
+ || (message.user !== undefined && connection.teamId !== undefined
2463
+ && answersAgentMention(connection.config.agentId, connection.teamId, message.user));
2387
2464
  }
2388
2465
  return message.user !== undefined
2389
2466
  && isPersonSpeaking({ type: 'message', ts: message.ts, channel, ...(message.subtype ? { subtype: message.subtype } : {}) })
@@ -3972,6 +4049,13 @@ export const createSlackChannelAdapter = (options) => {
3972
4049
  * their sessions answer for them after a restart.
3973
4050
  */
3974
4051
  const isFleetBot = (team, user) => user !== undefined && [...botIdentities.values()].some((identity) => identity.teamId === team && identity.botUserId === user);
4052
+ /**
4053
+ * Whether a bot message is a team agent asking one of this daemon's
4054
+ * agents something (card 513): a question, not another voice taking the
4055
+ * thread. The agent asked answers it, and the thread stays with whoever
4056
+ * held it. Used live and when a thread is read back after a restart.
4057
+ */
4058
+ const asksAgentHere = (team, asker, text) => asker !== undefined && [...botIdentities].some(([agentId, identity]) => identity.teamId === team && mentions(text ?? '', identity.botUserId) && answersAgentMention(agentId, team, asker));
3975
4059
  /**
3976
4060
  * Records a bot this adapter does not serve as the thread's holder from
3977
4061
  * the message it posted. An agent on another gateway answers in the same
@@ -4000,6 +4084,9 @@ export const createSlackChannelAdapter = (options) => {
4000
4084
  if (speaker === undefined || isFleetBot(team, event.user)) {
4001
4085
  return;
4002
4086
  }
4087
+ if (asksAgentHere(team, event.user, event.text)) {
4088
+ return;
4089
+ }
4003
4090
  const thread = event.thread_ts ?? event.ts;
4004
4091
  rememberAddressee(threadKeyFor({ team, conversation: event.channel, thread }), `${FOREIGN_SPEAKER_PREFIX}${speaker}`, event.ts);
4005
4092
  };
@@ -4069,7 +4156,7 @@ export const createSlackChannelAdapter = (options) => {
4069
4156
  if (bot) {
4070
4157
  // A bot's mention hands nothing over live (admit ignores bots),
4071
4158
  // so here it is only another app speaking, or a peer's words.
4072
- if (!isFleetBot(parts.team, message.user)) {
4159
+ if (!isFleetBot(parts.team, message.user) && !asksAgentHere(parts.team, message.user, message.text)) {
4073
4160
  foreign = Math.max(foreign, at);
4074
4161
  }
4075
4162
  continue;
@@ -4388,11 +4475,178 @@ export const createSlackChannelAdapter = (options) => {
4388
4475
  * case, where by definition no message of this thread has been seen in
4389
4476
  * this process.
4390
4477
  */
4478
+ /**
4479
+ * Stop (card 337): cancels every turn this agent is running or has queued
4480
+ * in this conversation, including messages still in intake, and says
4481
+ * nothing was stopped when there was nothing to stop. Synchronous, and
4482
+ * called off the intake chain before any dispatch, so a stop is never
4483
+ * queued behind the turn it stops nor merged into the next one. The
4484
+ * caller has checked that the sender is an approver: a stop throws away
4485
+ * work someone else asked for.
4486
+ *
4487
+ * `fromIntake` is a stop that could only be placed on the intake chain,
4488
+ * once the sessions named this agent (the cold case). Everything ahead of
4489
+ * it on the chain has already been dispatched, and everything still in
4490
+ * intake arrived after it, so it cancels the dispatched turns only.
4491
+ */
4492
+ const stopTurns = (connection, sessionId, userId, channel, fromIntake = false) => {
4493
+ const running = renderers.get(sessionId) ?? [];
4494
+ const inIntake = fromIntake ? 0 : intakeInFlight.get(sessionId) ?? 0;
4495
+ if (running.length === 0 && inIntake === 0) {
4496
+ return false;
4497
+ }
4498
+ for (const renderer of running) {
4499
+ if (renderer.stoppedBy === undefined) {
4500
+ renderer.stoppedBy = userId;
4501
+ renderer.controller.abort(new RunAbortedError(`Stopped from Slack by ${userId}`));
4502
+ }
4503
+ }
4504
+ if (inIntake > 0) {
4505
+ stopsBySession.set(sessionId, { count: (stopsBySession.get(sessionId)?.count ?? 0) + 1, by: userId });
4506
+ }
4507
+ log(`slack: ${userId} stopped ${running.length + inIntake} turn(s) of ${connection.config.agentId} in ${channel}`);
4508
+ return true;
4509
+ };
4510
+ /**
4511
+ * Agent mentions admitted per thread since a person last spoke there, by
4512
+ * message ts (card 513). A copy of one message reaching a second socket,
4513
+ * or redelivered, is the same hop. Bounded like the addressee map.
4514
+ */
4515
+ const agentHops = new Map();
4516
+ /**
4517
+ * Which team agent a bot user in `team` is, for the agent configured by
4518
+ * `config` in workspace `agentTeam`, if any. A configured id counts only
4519
+ * in that agent's own workspace: user ids are unique per workspace, so the
4520
+ * same id elsewhere is some other bot.
4521
+ */
4522
+ const teamAgentFor = (config, agentTeam, team, botUser) => {
4523
+ for (const [agentId, identity] of botIdentities) {
4524
+ if (identity.teamId === team && identity.botUserId === botUser) {
4525
+ return agentId;
4526
+ }
4527
+ }
4528
+ if (agentTeam === undefined || agentTeam !== team) {
4529
+ return undefined;
4530
+ }
4531
+ for (const [name, id] of Object.entries(config.agentMentions?.team ?? {})) {
4532
+ if (id === botUser) {
4533
+ return name;
4534
+ }
4535
+ }
4536
+ return undefined;
4537
+ };
4538
+ /**
4539
+ * Whether `agentId` answers a mention from `botUser` in `team`: it has
4540
+ * agent mentions on, the bot is a team agent to it, and on its list if it
4541
+ * keeps one. The hop limit is not asked here.
4542
+ */
4543
+ const answersAgentMention = (agentId, team, botUser) => {
4544
+ const config = agentConfigFor(agentId);
4545
+ const settings = config?.agentMentions;
4546
+ if (!config || !settings) {
4547
+ return false;
4548
+ }
4549
+ const name = teamAgentFor(config, botIdentities.get(agentId)?.teamId, team, botUser);
4550
+ return name !== undefined && (settings.only === undefined || settings.only.includes(name));
4551
+ };
4552
+ /**
4553
+ * A message another agent's bot posted, for an agent that answers team
4554
+ * agents (`agentMentions`, card 513). Admitted only when it names this
4555
+ * agent, comes from a team agent this one listens to, and the thread is
4556
+ * under its hop limit; anything else from a bot is dropped, as every
4557
+ * bot message was before. Never part of the holder rule: a bot naming an
4558
+ * agent asks it one thing, and hands it nothing.
4559
+ */
4560
+ const admitAgentMention = (connection, args) => {
4561
+ const event = args.event;
4562
+ const settings = connection.config.agentMentions;
4563
+ if (!settings || !event || event.user === undefined || event.user === connection.botUserId) {
4564
+ return undefined;
4565
+ }
4566
+ if ((event.type !== 'app_mention' && event.type !== 'message') || event.channel_type === 'im') {
4567
+ return undefined;
4568
+ }
4569
+ if (event.subtype !== undefined && !AGENT_SPEECH_SUBTYPES.has(event.subtype)) {
4570
+ return undefined;
4571
+ }
4572
+ const text = event.text ?? '';
4573
+ if (!mentions(text, connection.botUserId)) {
4574
+ return undefined;
4575
+ }
4576
+ const team = args.body?.team_id ?? connection.teamId;
4577
+ const name = teamAgentFor(connection.config, connection.teamId, team, event.user);
4578
+ if (name === undefined) {
4579
+ return undefined;
4580
+ }
4581
+ const eventKey = `${connection.config.agentId}:${event.channel}:${event.ts}`;
4582
+ if (settings.only !== undefined && !settings.only.includes(name)) {
4583
+ if (!alreadySeen(eventKey)) {
4584
+ log(`slack: ${connection.config.agentId} ignored a mention from agent ${name}: not on its agent mention list`);
4585
+ }
4586
+ return undefined;
4587
+ }
4588
+ const thread = event.thread_ts ?? event.ts;
4589
+ const threadKey = threadKeyFor({ team, conversation: event.channel, thread });
4590
+ const hops = agentHops.get(threadKey) ?? [];
4591
+ if (!hops.includes(event.ts)) {
4592
+ if (hops.length >= AGENT_MENTION_HOP_LIMIT) {
4593
+ if (!alreadySeen(eventKey)) {
4594
+ log(`slack: ${connection.config.agentId} ignored a mention from agent ${name}: the thread reached its hop limit of ${AGENT_MENTION_HOP_LIMIT} agent mentions; a person has to speak there first`);
4595
+ }
4596
+ return undefined;
4597
+ }
4598
+ agentHops.delete(threadKey);
4599
+ agentHops.set(threadKey, [...hops, event.ts]);
4600
+ if (agentHops.size > THREAD_ADDRESSEE_CAPACITY) {
4601
+ const oldest = agentHops.keys().next();
4602
+ if (!oldest.done) {
4603
+ agentHops.delete(oldest.value);
4604
+ }
4605
+ }
4606
+ }
4607
+ if (alreadySeen(eventKey)) {
4608
+ return undefined;
4609
+ }
4610
+ return {
4611
+ event,
4612
+ isDm: false,
4613
+ team,
4614
+ eventKey,
4615
+ userId: event.user,
4616
+ sessionId: channelSessionKey({ channel: 'slack', agentId: connection.config.agentId, team, conversation: event.channel, thread }),
4617
+ thread,
4618
+ threadKey,
4619
+ settled: true,
4620
+ overhear: false,
4621
+ judge: false,
4622
+ fromAgent: name,
4623
+ };
4624
+ };
4391
4625
  const admit = (connection, args) => {
4392
4626
  const event = args.event;
4393
4627
  if (gatewayRef) {
4394
4628
  noteForeignSpeaker(connection, args);
4395
4629
  }
4630
+ if (gatewayRef && event && (event.bot_id !== undefined || event.subtype === 'bot_message')) {
4631
+ return admitAgentMention(connection, args);
4632
+ }
4633
+ if (event && event.user !== undefined && event.channel_type !== 'im' && isPersonSpeaking(event) && admitsSender(connection.config, event.user)) {
4634
+ // A person this agent admits, speaking in a thread, ends a run of
4635
+ // agent mentions there. Someone it refuses can't restart one. Only
4636
+ // hops before the person's message reset: another agent's socket can
4637
+ // deliver an older human message late, after newer hops.
4638
+ const hopKey = threadKeyFor({ team: args.body?.team_id ?? connection.teamId, conversation: event.channel, thread: event.thread_ts ?? event.ts });
4639
+ const hops = agentHops.get(hopKey);
4640
+ if (hops) {
4641
+ const newer = hops.filter((hop) => compareSlackTs(hop, event.ts) > 0);
4642
+ if (newer.length > 0) {
4643
+ agentHops.set(hopKey, newer);
4644
+ }
4645
+ else {
4646
+ agentHops.delete(hopKey);
4647
+ }
4648
+ }
4649
+ }
4396
4650
  if (!gatewayRef || !event || !event.user || event.bot_id || !isPersonSpeaking(event)) {
4397
4651
  return undefined;
4398
4652
  }
@@ -4496,6 +4750,27 @@ export const createSlackChannelAdapter = (options) => {
4496
4750
  // Consuming the key here is safe because a refused message records
4497
4751
  // nothing a later copy could be needed for.
4498
4752
  if (!admitsSender(connection.config, sender)) {
4753
+ // An approver the operator did not list as a principal can still stop
4754
+ // the agent (card 337): approving is its own grant. Only when the
4755
+ // message names this agent (or is a DM to it), and nothing else they
4756
+ // say gets through.
4757
+ if (addressed &&
4758
+ (connection.config.approvers ?? []).includes(sender) &&
4759
+ isStopRequest(text, connection.botUserId) &&
4760
+ !alreadySeen(eventKey)) {
4761
+ const stopSession = channelSessionKey({
4762
+ channel: 'slack',
4763
+ agentId: connection.config.agentId,
4764
+ team,
4765
+ conversation: event.channel,
4766
+ ...(thread ? { thread } : {}),
4767
+ });
4768
+ if (stopTurns(connection, stopSession, sender, event.channel)) {
4769
+ return undefined;
4770
+ }
4771
+ log(`slack: ${connection.config.agentId} refused a message from ${sender}: not a listed principal, and admit is "principals"`);
4772
+ return undefined;
4773
+ }
4499
4774
  const refusal = `slack: ${connection.config.agentId} refused a message from ${sender}: not a listed principal, and admit is "principals"`;
4500
4775
  const held = threadKey !== undefined ? holderAt(threadKey, event.ts) : undefined;
4501
4776
  if (addressed || held === connection.config.agentId) {
@@ -4592,11 +4867,25 @@ export const createSlackChannelAdapter = (options) => {
4592
4867
  // "whose is an untagged reply", and for these two agents the soul has
4593
4868
  // already answered.
4594
4869
  const listens = listensOf(connection.config.agentId);
4595
- if (listens === 'mentions') {
4596
- return { ...admission, overhear: true };
4597
- }
4598
- if (listens === 'judge') {
4599
- return { ...admission, overhear: true, judge: true };
4870
+ if (listens === 'mentions' || listens === 'judge') {
4871
+ // Except a stop (card 337): the holder rule decides whose it is, as
4872
+ // for any agent, and it only acts if there is something to stop. A
4873
+ // thread the record has forgotten is left to the sessions, as below,
4874
+ // so a stop for a turn that outlived its record still reaches it.
4875
+ const approverStop = threadKey !== undefined &&
4876
+ (connection.config.approvers ?? []).includes(sender) &&
4877
+ isStopRequest(text, connection.botUserId);
4878
+ const stopHolder = approverStop ? holderAt(threadKey, event.ts) : undefined;
4879
+ // A stop held by another agent is only heard: judging it would let an
4880
+ // attentive agent take a turn on a stop that was never its own.
4881
+ const stopForAnother = stopHolder !== undefined && stopHolder !== connection.config.agentId;
4882
+ return {
4883
+ ...admission,
4884
+ overhear: true,
4885
+ ...(listens === 'judge' && !stopForAnother ? { judge: true } : {}),
4886
+ ...(stopHolder === connection.config.agentId ? { holderStop: true } : {}),
4887
+ ...(approverStop && stopHolder === undefined ? { settled: false } : {}),
4888
+ };
4600
4889
  }
4601
4890
  // The record is what this process has actually seen, in the order it
4602
4891
  // saw it — right even inside the window before a handover reaches the
@@ -4652,6 +4941,19 @@ export const createSlackChannelAdapter = (options) => {
4652
4941
  return;
4653
4942
  }
4654
4943
  const { event, isDm, team, userId, thread, sessionId, threadKey, eventKey } = admitted;
4944
+ // Stop (card 337), see `stopTurns`. Only a message addressed to this
4945
+ // agent: an untagged reply this agent merely overhears is the holder's
4946
+ // (unless this agent is the holder and only `listens` made it an
4947
+ // overhear, see `holderStop`), and one only the sessions can place is
4948
+ // not this agent's to act on yet.
4949
+ if ((!admitted.overhear || admitted.holderStop === true) &&
4950
+ admitted.settled &&
4951
+ admitted.fromAgent === undefined &&
4952
+ (connection.config.approvers ?? []).includes(userId) &&
4953
+ isStopRequest(event.text ?? '', connection.botUserId) &&
4954
+ stopTurns(connection, sessionId, userId, event.channel)) {
4955
+ return;
4956
+ }
4655
4957
  // What the gateway knows this message by (see `dispatchKeyFor`).
4656
4958
  const dispatchKey = dispatchKeyFor(eventKey);
4657
4959
  // Everything up to (and including) the dispatch call is serialized per
@@ -4661,6 +4963,30 @@ export const createSlackChannelAdapter = (options) => {
4661
4963
  // reverse order in durable history. The chain is claimed here,
4662
4964
  // synchronously, so a message's place in it is fixed by when Slack
4663
4965
  // delivered it and not by how long anything it does takes.
4966
+ const stopsAtClaim = stopsBySession.get(sessionId)?.count ?? 0;
4967
+ // Only intake that can still become a turn is something a stop can
4968
+ // cancel. A settled overhear that is not judged only ever reaches
4969
+ // `gateway.observe`, so counting it would let a stop with nothing to
4970
+ // stop be consumed instead of passing on as an ordinary message.
4971
+ const mayRun = !admitted.overhear || admitted.judge || !admitted.settled;
4972
+ if (mayRun) {
4973
+ intakeInFlight.set(sessionId, (intakeInFlight.get(sessionId) ?? 0) + 1);
4974
+ }
4975
+ let leftIntake = !mayRun;
4976
+ const leaveIntake = () => {
4977
+ if (leftIntake) {
4978
+ return;
4979
+ }
4980
+ leftIntake = true;
4981
+ const left = (intakeInFlight.get(sessionId) ?? 1) - 1;
4982
+ if (left > 0) {
4983
+ intakeInFlight.set(sessionId, left);
4984
+ }
4985
+ else {
4986
+ intakeInFlight.delete(sessionId);
4987
+ stopsBySession.delete(sessionId);
4988
+ }
4989
+ };
4664
4990
  const intake = onIntakeChain(sessionId, async () => {
4665
4991
  // The durable half of addressing runs INSIDE the chain, for the same
4666
4992
  // reason: it awaits, and a message that had to ask the sessions must
@@ -4703,13 +5029,26 @@ export const createSlackChannelAdapter = (options) => {
4703
5029
  }, event.ts);
4704
5030
  if (winner !== connection.config.agentId) {
4705
5031
  // Not this agent's to answer — and, if it is in the thread,
4706
- // this agent's to hear, same as the two warm stand-downs.
5032
+ // this agent's to hear, same as the two warm stand-downs. Only a
5033
+ // stop sends a judging agent here, and a stop the sessions place
5034
+ // with another agent is heard, never judged, as it is warm.
5035
+ judged = false;
4707
5036
  overhear = true;
4708
5037
  }
4709
- else if (threadKey) {
4710
- // Answered from the store rather than from memory: hold it, so
4711
- // the rest of the thread is ordered by receipt like any other.
4712
- rememberAddressee(threadKey, connection.config.agentId, event.ts);
5038
+ else {
5039
+ if (threadKey) {
5040
+ // Answered from the store rather than from memory: hold it, so
5041
+ // the rest of the thread is ordered by receipt like any other.
5042
+ rememberAddressee(threadKey, connection.config.agentId, event.ts);
5043
+ }
5044
+ // Stop (card 337) for a message only the sessions could place:
5045
+ // now that they say it is this agent's, it is a stop like the
5046
+ // warm one above, not a message to queue behind the turn it stops.
5047
+ if ((connection.config.approvers ?? []).includes(userId) &&
5048
+ isStopRequest(event.text ?? '', connection.botUserId) &&
5049
+ stopTurns(connection, sessionId, userId, event.channel, true)) {
5050
+ return undefined;
5051
+ }
4713
5052
  }
4714
5053
  }
4715
5054
  const cleaned = await humanizeMentions(connection, event.text ?? '');
@@ -4737,7 +5076,7 @@ export const createSlackChannelAdapter = (options) => {
4737
5076
  const { texts, saved, unread } = overhear || judged
4738
5077
  ? { texts: [], saved: [], unread: notImages }
4739
5078
  : await readTextAttachments(notImages, connection.config.botToken, fetchFile, fileDownloadTimeoutMs, deadline, warn, reasons, options.attachmentStore
4740
- ? createAttachmentSaver(options.attachmentStore, connection.config.agentId, senderTrustFor(connection.config, userId), options.now ?? Date.now)
5079
+ ? createAttachmentSaver(options.attachmentStore, connection.config.agentId, admitted.fromAgent !== undefined ? 'unknown' : senderTrustFor(connection.config, userId), options.now ?? Date.now)
4741
5080
  : undefined);
4742
5081
  // An image or a document with nothing said is still a question
4743
5082
  // ("what's this?"); a file the turn could not open with nothing said
@@ -4745,7 +5084,14 @@ export const createSlackChannelAdapter = (options) => {
4745
5084
  if (cleaned.length === 0 && images.length === 0 && texts.length === 0 && saved.length === 0) {
4746
5085
  return undefined;
4747
5086
  }
4748
- const author = await authorFor(connection, userId);
5087
+ // A team agent goes by the name the operator gave it, not a profile
5088
+ // its own app chose.
5089
+ // A team agent's daemon hears only what tags its bot (card 513), so
5090
+ // the model gets that tag and decides whether its answer needs it
5091
+ // (Dylan, 2026-10-15: the model tags back, the adapter never does).
5092
+ const author = admitted.fromAgent !== undefined
5093
+ ? `${admitted.fromAgent} (an agent; it sees your answer only if you tag <@${userId}>)`
5094
+ : await authorFor(connection, userId);
4749
5095
  // In shared channels the model should know who is speaking; a DM is
4750
5096
  // unambiguous. A bare image in a channel still says who sent it.
4751
5097
  const spoken = isDm ? cleaned : (cleaned.length > 0 ? `${author}: ${cleaned}` : `${author}:`);
@@ -4756,7 +5102,10 @@ export const createSlackChannelAdapter = (options) => {
4756
5102
  // `unknown` exactly as a channel mention would be. The same label
4757
5103
  // whether the agent answers or only hears: the text is in its
4758
5104
  // transcript either way.
4759
- const senderTrust = senderTrustFor(connection.config, userId);
5105
+ // A team agent is below a principal whatever the list says: its
5106
+ // words carry whatever its own session was exposed to, which nothing
5107
+ // here can see (card 513).
5108
+ const senderTrust = admitted.fromAgent !== undefined ? 'unknown' : senderTrustFor(connection.config, userId);
4760
5109
  // Who can read this, so the agent writes for them, and a thread says
4761
5110
  // it is one. A DM names the other person only when they are one of
4762
5111
  // the operator's principals: this lands in the system prompt, and
@@ -4889,6 +5238,14 @@ export const createSlackChannelAdapter = (options) => {
4889
5238
  const queue = renderers.get(sessionId) ?? [];
4890
5239
  queue.push(renderer);
4891
5240
  renderers.set(sessionId, queue);
5241
+ // Stopped while its lookups ran: dispatched under an aborted signal,
5242
+ // so it ends the way a queued turn a stop reached does.
5243
+ const stopped = stopsBySession.get(sessionId);
5244
+ leaveIntake();
5245
+ if (stopped !== undefined && stopped.count !== stopsAtClaim && renderer.stoppedBy === undefined) {
5246
+ renderer.stoppedBy = stopped.by;
5247
+ renderer.controller.abort(new RunAbortedError(`Stopped from Slack by ${stopped.by}`));
5248
+ }
4892
5249
  // In a DM each message is its own status key, and Slack stops showing
4893
5250
  // the running turn's once a newer message sits below it — so a
4894
5251
  // message queued there that stayed dark read as the agent having
@@ -4906,6 +5263,7 @@ export const createSlackChannelAdapter = (options) => {
4906
5263
  ...(earlier.length > 0 ? { earlier } : {}),
4907
5264
  turnId: renderer.turnId,
4908
5265
  metadata,
5266
+ signal: renderer.controller.signal,
4909
5267
  // A redelivery this process no longer remembers (it restarted, or
4910
5268
  // the dedupe evicted it) is still the message it was: the gateway
4911
5269
  // runs no second turn for it, and says so through `onRepeat`.
@@ -4922,6 +5280,8 @@ export const createSlackChannelAdapter = (options) => {
4922
5280
  });
4923
5281
  return { renderer, turn, repeat: () => repeat, absorbed: () => absorbed };
4924
5282
  });
5283
+ // Whatever way the intake ended, it is no longer in flight.
5284
+ void intake.then(leaveIntake, leaveIntake);
4925
5285
  const started = await intake;
4926
5286
  if (!started) {
4927
5287
  return;
@@ -5071,9 +5431,11 @@ export const createSlackChannelAdapter = (options) => {
5071
5431
  // not a failure of the agent. Matched by name, as this package does
5072
5432
  // not import the gateway. A turn nobody asked for stays silent.
5073
5433
  const pausedReply = failure instanceof Error && failure.name === 'IntakePausedError';
5074
- const { spoke, spokeAt } = pausedReply
5075
- ? await renderer.finalize(renderer.lazy ? '' : failure.message)
5076
- : await renderer.fail(failure instanceof Error ? failure.message : String(failure));
5434
+ const { spoke, spokeAt } = renderer.stoppedBy !== undefined
5435
+ ? await renderer.stopped(renderer.stoppedBy)
5436
+ : pausedReply
5437
+ ? await renderer.finalize(renderer.lazy ? '' : failure.message)
5438
+ : await renderer.fail(failure instanceof Error ? failure.message : String(failure));
5077
5439
  if (spoke && threadKey !== undefined) {
5078
5440
  // A file it posted before breaking is still the last thing said.
5079
5441
  rememberAddressee(threadKey, connection.config.agentId, spokeAt ?? event.ts);
@@ -5085,6 +5447,9 @@ export const createSlackChannelAdapter = (options) => {
5085
5447
  name: 'slack',
5086
5448
  resolveOutbound,
5087
5449
  readConversation,
5450
+ // A channel id is unique only within its workspace, so a standing
5451
+ // grant on one is bound to the team the agent's app authenticated in.
5452
+ accountFor: (agentId) => botIdentities.get(agentId)?.teamId || undefined,
5088
5453
  requestCredential,
5089
5454
  async start(gateway) {
5090
5455
  gatewayRef = gateway;